Merge remote-tracking branch 'origin/develop' into migrate/vuex-to-pinia
This commit is contained in:
commit
58e18d48df
489 changed files with 31167 additions and 9871 deletions
|
|
@ -68,8 +68,6 @@ const MASTODON_UNBLOCK_USER_URL = id => `/api/v1/accounts/${id}/unblock`
|
|||
const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute`
|
||||
const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute`
|
||||
const MASTODON_REMOVE_USER_FROM_FOLLOWERS = id => `/api/v1/accounts/${id}/remove_from_followers`
|
||||
const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe`
|
||||
const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe`
|
||||
const MASTODON_USER_NOTE_URL = id => `/api/v1/accounts/${id}/note`
|
||||
const MASTODON_BOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/bookmark`
|
||||
const MASTODON_UNBOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/unbookmark`
|
||||
|
|
@ -107,6 +105,25 @@ const PLEROMA_ANNOUNCEMENTS_URL = '/api/v1/pleroma/admin/announcements'
|
|||
const PLEROMA_POST_ANNOUNCEMENT_URL = '/api/v1/pleroma/admin/announcements'
|
||||
const PLEROMA_EDIT_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}`
|
||||
const PLEROMA_DELETE_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}`
|
||||
const PLEROMA_SCROBBLES_URL = id => `/api/v1/pleroma/accounts/${id}/scrobbles`
|
||||
const PLEROMA_STATUS_QUOTES_URL = id => `/api/v1/pleroma/statuses/${id}/quotes`
|
||||
const PLEROMA_USER_FAVORITES_TIMELINE_URL = id => `/api/v1/pleroma/accounts/${id}/favourites`
|
||||
const PLEROMA_BOOKMARK_FOLDERS_URL = '/api/v1/pleroma/bookmark_folders'
|
||||
const PLEROMA_BOOKMARK_FOLDER_URL = id => `/api/v1/pleroma/bookmark_folders/${id}`
|
||||
|
||||
const PLEROMA_ADMIN_CONFIG_URL = '/api/pleroma/admin/config'
|
||||
const PLEROMA_ADMIN_DESCRIPTIONS_URL = '/api/pleroma/admin/config/descriptions'
|
||||
const PLEROMA_ADMIN_FRONTENDS_URL = '/api/pleroma/admin/frontends'
|
||||
const PLEROMA_ADMIN_FRONTENDS_INSTALL_URL = '/api/pleroma/admin/frontends/install'
|
||||
|
||||
const PLEROMA_EMOJI_RELOAD_URL = '/api/pleroma/admin/reload_emoji'
|
||||
const PLEROMA_EMOJI_IMPORT_FS_URL = '/api/pleroma/emoji/packs/import'
|
||||
const PLEROMA_EMOJI_PACKS_URL = (page, pageSize) => `/api/v1/pleroma/emoji/packs?page=${page}&page_size=${pageSize}`
|
||||
const PLEROMA_EMOJI_PACK_URL = (name) => `/api/v1/pleroma/emoji/pack?name=${name}`
|
||||
const PLEROMA_EMOJI_PACKS_DL_REMOTE_URL = '/api/v1/pleroma/emoji/packs/download'
|
||||
const PLEROMA_EMOJI_PACKS_LS_REMOTE_URL =
|
||||
(url, page, pageSize) => `/api/v1/pleroma/emoji/packs/remote?url=${url}&page=${page}&page_size=${pageSize}`
|
||||
const PLEROMA_EMOJI_UPDATE_FILE_URL = (name) => `/api/v1/pleroma/emoji/packs/files?name=${name}`
|
||||
|
||||
const oldfetch = window.fetch
|
||||
|
||||
|
|
@ -256,6 +273,7 @@ const followUser = ({ id, credentials, ...options }) => {
|
|||
const url = MASTODON_FOLLOW_URL(id)
|
||||
const form = {}
|
||||
if (options.reblogs !== undefined) { form.reblogs = options.reblogs }
|
||||
if (options.notify !== undefined) { form.notify = options.notify }
|
||||
return fetch(url, {
|
||||
body: JSON.stringify(form),
|
||||
headers: {
|
||||
|
|
@ -665,13 +683,16 @@ const fetchTimeline = ({
|
|||
timeline,
|
||||
credentials,
|
||||
since = false,
|
||||
minId = false,
|
||||
until = false,
|
||||
userId = false,
|
||||
listId = false,
|
||||
statusId = false,
|
||||
tag = false,
|
||||
withMuted = false,
|
||||
replyVisibility = 'all',
|
||||
includeTypes = []
|
||||
includeTypes = [],
|
||||
bookmarkFolderId = false
|
||||
}) => {
|
||||
const timelineUrls = {
|
||||
public: MASTODON_PUBLIC_TIMELINE,
|
||||
|
|
@ -683,14 +704,20 @@ const fetchTimeline = ({
|
|||
media: MASTODON_USER_TIMELINE_URL,
|
||||
list: MASTODON_LIST_TIMELINE_URL,
|
||||
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
|
||||
publicFavorites: PLEROMA_USER_FAVORITES_TIMELINE_URL,
|
||||
tag: MASTODON_TAG_TIMELINE_URL,
|
||||
bookmarks: MASTODON_BOOKMARK_TIMELINE_URL
|
||||
bookmarks: MASTODON_BOOKMARK_TIMELINE_URL,
|
||||
quotes: PLEROMA_STATUS_QUOTES_URL
|
||||
}
|
||||
const isNotifications = timeline === 'notifications'
|
||||
const params = []
|
||||
|
||||
let url = timelineUrls[timeline]
|
||||
|
||||
if (timeline === 'favorites' && userId) {
|
||||
url = timelineUrls.publicFavorites(userId)
|
||||
}
|
||||
|
||||
if (timeline === 'user' || timeline === 'media') {
|
||||
url = url(userId)
|
||||
}
|
||||
|
|
@ -699,6 +726,13 @@ const fetchTimeline = ({
|
|||
url = url(listId)
|
||||
}
|
||||
|
||||
if (timeline === 'quotes') {
|
||||
url = url(statusId)
|
||||
}
|
||||
|
||||
if (minId) {
|
||||
params.push(['min_id', minId])
|
||||
}
|
||||
if (since) {
|
||||
params.push(['since_id', since])
|
||||
}
|
||||
|
|
@ -728,6 +762,9 @@ const fetchTimeline = ({
|
|||
params.push(['include_types[]', type])
|
||||
})
|
||||
}
|
||||
if (timeline === 'bookmarks' && bookmarkFolderId) {
|
||||
params.push(['folder_id', bookmarkFolderId])
|
||||
}
|
||||
|
||||
params.push(['limit', 20])
|
||||
|
||||
|
|
@ -797,11 +834,14 @@ const unretweet = ({ id, credentials }) => {
|
|||
.then((data) => parseStatus(data))
|
||||
}
|
||||
|
||||
const bookmarkStatus = ({ id, credentials }) => {
|
||||
const bookmarkStatus = ({ id, credentials, ...options }) => {
|
||||
return promisedRequest({
|
||||
url: MASTODON_BOOKMARK_STATUS_URL(id),
|
||||
headers: authHeaders(credentials),
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
payload: {
|
||||
folder_id: options.folder_id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -822,6 +862,7 @@ const postStatus = ({
|
|||
poll,
|
||||
mediaIds = [],
|
||||
inReplyToStatusId,
|
||||
quoteId,
|
||||
contentType,
|
||||
preview,
|
||||
idempotencyKey
|
||||
|
|
@ -854,6 +895,9 @@ const postStatus = ({
|
|||
if (inReplyToStatusId) {
|
||||
form.append('in_reply_to_id', inReplyToStatusId)
|
||||
}
|
||||
if (quoteId) {
|
||||
form.append('quote_id', quoteId)
|
||||
}
|
||||
if (preview) {
|
||||
form.append('preview', 'true')
|
||||
}
|
||||
|
|
@ -923,8 +967,9 @@ const editStatus = ({
|
|||
}
|
||||
|
||||
const deleteStatus = ({ id, credentials }) => {
|
||||
return fetch(MASTODON_DELETE_URL(id), {
|
||||
headers: authHeaders(credentials),
|
||||
return promisedRequest({
|
||||
url: MASTODON_DELETE_URL(id),
|
||||
credentials,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
|
@ -1113,8 +1158,12 @@ const generateMfaBackupCodes = ({ credentials }) => {
|
|||
}).then((data) => data.json())
|
||||
}
|
||||
|
||||
const fetchMutes = ({ credentials }) => {
|
||||
return promisedRequest({ url: MASTODON_USER_MUTES_URL, credentials })
|
||||
const fetchMutes = ({ maxId, credentials }) => {
|
||||
const query = new URLSearchParams({ with_relationships: true })
|
||||
if (maxId) {
|
||||
query.append('max_id', maxId)
|
||||
}
|
||||
return promisedRequest({ url: `${MASTODON_USER_MUTES_URL}?${query.toString()}`, credentials })
|
||||
.then((users) => users.map(parseUser))
|
||||
}
|
||||
|
||||
|
|
@ -1130,16 +1179,12 @@ const unmuteUser = ({ id, credentials }) => {
|
|||
return promisedRequest({ url: MASTODON_UNMUTE_USER_URL(id), credentials, method: 'POST' })
|
||||
}
|
||||
|
||||
const subscribeUser = ({ id, credentials }) => {
|
||||
return promisedRequest({ url: MASTODON_SUBSCRIBE_USER(id), credentials, method: 'POST' })
|
||||
}
|
||||
|
||||
const unsubscribeUser = ({ id, credentials }) => {
|
||||
return promisedRequest({ url: MASTODON_UNSUBSCRIBE_USER(id), credentials, method: 'POST' })
|
||||
}
|
||||
|
||||
const fetchBlocks = ({ credentials }) => {
|
||||
return promisedRequest({ url: MASTODON_USER_BLOCKS_URL, credentials })
|
||||
const fetchBlocks = ({ maxId, credentials }) => {
|
||||
const query = new URLSearchParams({ with_relationships: true })
|
||||
if (maxId) {
|
||||
query.append('max_id', maxId)
|
||||
}
|
||||
return promisedRequest({ url: `${MASTODON_USER_BLOCKS_URL}?${query.toString()}`, credentials })
|
||||
.then((users) => users.map(parseUser))
|
||||
}
|
||||
|
||||
|
|
@ -1439,17 +1484,18 @@ const deleteAnnouncement = ({ id, credentials }) => {
|
|||
})
|
||||
}
|
||||
|
||||
export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => {
|
||||
return Object.entries({
|
||||
...(credentials
|
||||
? { access_token: credentials }
|
||||
: {}
|
||||
),
|
||||
stream,
|
||||
...args
|
||||
}).reduce((acc, [key, val]) => {
|
||||
return acc + `${key}=${val}&`
|
||||
}, MASTODON_STREAMING + '?')
|
||||
export const getMastodonSocketURI = ({ credentials, stream, args = {} }, base) => {
|
||||
const url = new URL(MASTODON_STREAMING, base)
|
||||
if (credentials) {
|
||||
url.searchParams.append('access_token', credentials)
|
||||
}
|
||||
if (stream) {
|
||||
url.searchParams.append('stream', stream)
|
||||
}
|
||||
Object.entries(args).forEach(([key, val]) => {
|
||||
url.searchParams.append(key, val)
|
||||
})
|
||||
return url
|
||||
}
|
||||
|
||||
const MASTODON_STREAMING_EVENTS = new Set([
|
||||
|
|
@ -1461,7 +1507,8 @@ const MASTODON_STREAMING_EVENTS = new Set([
|
|||
])
|
||||
|
||||
const PLEROMA_STREAMING_EVENTS = new Set([
|
||||
'pleroma:chat_update'
|
||||
'pleroma:chat_update',
|
||||
'pleroma:respond'
|
||||
])
|
||||
|
||||
// A thin wrapper around WebSocket API that allows adding a pre-processor to it
|
||||
|
|
@ -1469,7 +1516,8 @@ const PLEROMA_STREAMING_EVENTS = new Set([
|
|||
export const ProcessedWS = ({
|
||||
url,
|
||||
preprocessor = handleMastoWS,
|
||||
id = 'Unknown'
|
||||
id = 'Unknown',
|
||||
credentials
|
||||
}) => {
|
||||
const eventTarget = new EventTarget()
|
||||
const socket = new WebSocket(url)
|
||||
|
|
@ -1484,6 +1532,12 @@ export const ProcessedWS = ({
|
|||
}
|
||||
socket.addEventListener('open', (wsEvent) => {
|
||||
console.debug(`[WS][${id}] Socket connected`, wsEvent)
|
||||
if (credentials) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'pleroma:authenticate',
|
||||
token: credentials
|
||||
}))
|
||||
}
|
||||
})
|
||||
socket.addEventListener('error', (wsEvent) => {
|
||||
console.debug(`[WS][${id}] Socket errored`, wsEvent)
|
||||
|
|
@ -1504,19 +1558,47 @@ export const ProcessedWS = ({
|
|||
})
|
||||
/**/
|
||||
|
||||
const onAuthenticated = () => {
|
||||
eventTarget.dispatchEvent(new CustomEvent('pleroma:authenticated'))
|
||||
}
|
||||
|
||||
proxy(socket, 'open')
|
||||
proxy(socket, 'close')
|
||||
proxy(socket, 'message', preprocessor)
|
||||
proxy(socket, 'message', (event) => preprocessor(event, { onAuthenticated }))
|
||||
proxy(socket, 'error')
|
||||
|
||||
// 1000 = Normal Closure
|
||||
eventTarget.close = () => { socket.close(1000, 'Shutting down socket') }
|
||||
eventTarget.getState = () => socket.readyState
|
||||
eventTarget.subscribe = (stream, args = {}) => {
|
||||
console.debug(
|
||||
`[WS][${id}] Subscribing to stream ${stream} with args`,
|
||||
args
|
||||
)
|
||||
socket.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
stream,
|
||||
...args
|
||||
}))
|
||||
}
|
||||
eventTarget.unsubscribe = (stream, args = {}) => {
|
||||
console.debug(
|
||||
`[WS][${id}] Unsubscribing from stream ${stream} with args`,
|
||||
args
|
||||
)
|
||||
socket.send(JSON.stringify({
|
||||
type: 'unsubscribe',
|
||||
stream,
|
||||
...args
|
||||
}))
|
||||
}
|
||||
|
||||
return eventTarget
|
||||
}
|
||||
|
||||
export const handleMastoWS = (wsEvent) => {
|
||||
export const handleMastoWS = (wsEvent, {
|
||||
onAuthenticated = () => {}
|
||||
} = {}) => {
|
||||
const { data } = wsEvent
|
||||
if (!data) return
|
||||
const parsedEvent = JSON.parse(data)
|
||||
|
|
@ -1527,7 +1609,18 @@ export const handleMastoWS = (wsEvent) => {
|
|||
return { event, id: payload }
|
||||
}
|
||||
const data = payload ? JSON.parse(payload) : null
|
||||
if (event === 'update') {
|
||||
if (event === 'pleroma:respond') {
|
||||
if (data.type === 'pleroma:authenticate') {
|
||||
if (data.result === 'success') {
|
||||
console.debug('[WS] Successfully authenticated')
|
||||
onAuthenticated()
|
||||
} else {
|
||||
console.error('[WS] Unable to authenticate:', data.error)
|
||||
wsEvent.target.close()
|
||||
}
|
||||
}
|
||||
return null
|
||||
} else if (event === 'update') {
|
||||
return { event, status: parseStatus(data) }
|
||||
} else if (event === 'status.update') {
|
||||
return { event, status: parseStatus(data) }
|
||||
|
|
@ -1659,6 +1752,233 @@ const setReportState = ({ id, state, credentials }) => {
|
|||
})
|
||||
}
|
||||
|
||||
// ADMIN STUFF // EXPERIMENTAL
|
||||
const fetchInstanceDBConfig = ({ credentials }) => {
|
||||
return fetch(PLEROMA_ADMIN_CONFIG_URL, {
|
||||
headers: authHeaders(credentials)
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json()
|
||||
} else {
|
||||
return {
|
||||
error: response
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fetchInstanceConfigDescriptions = ({ credentials }) => {
|
||||
return fetch(PLEROMA_ADMIN_DESCRIPTIONS_URL, {
|
||||
headers: authHeaders(credentials)
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json()
|
||||
} else {
|
||||
return {
|
||||
error: response
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fetchAvailableFrontends = ({ credentials }) => {
|
||||
return fetch(PLEROMA_ADMIN_FRONTENDS_URL, {
|
||||
headers: authHeaders(credentials)
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json()
|
||||
} else {
|
||||
return {
|
||||
error: response
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const pushInstanceDBConfig = ({ credentials, payload }) => {
|
||||
return fetch(PLEROMA_ADMIN_CONFIG_URL, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders(credentials)
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json()
|
||||
} else {
|
||||
return {
|
||||
error: response
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const installFrontend = ({ credentials, payload }) => {
|
||||
return fetch(PLEROMA_ADMIN_FRONTENDS_INSTALL_URL, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders(credentials)
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json()
|
||||
} else {
|
||||
return {
|
||||
error: response
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fetchScrobbles = ({ accountId, limit = 1 }) => {
|
||||
let url = PLEROMA_SCROBBLES_URL(accountId)
|
||||
const params = [['limit', limit]]
|
||||
const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
|
||||
url += `?${queryString}`
|
||||
return fetch(url, {})
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json()
|
||||
} else {
|
||||
return {
|
||||
error: response
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const deleteEmojiPack = ({ name }) => {
|
||||
return fetch(PLEROMA_EMOJI_PACK_URL(name), { method: 'DELETE' })
|
||||
}
|
||||
|
||||
const reloadEmoji = () => {
|
||||
return fetch(PLEROMA_EMOJI_RELOAD_URL, { method: 'POST' })
|
||||
}
|
||||
|
||||
const importEmojiFromFS = () => {
|
||||
return fetch(PLEROMA_EMOJI_IMPORT_FS_URL)
|
||||
}
|
||||
|
||||
const createEmojiPack = ({ name }) => {
|
||||
return fetch(PLEROMA_EMOJI_PACK_URL(name), { method: 'POST' })
|
||||
}
|
||||
|
||||
const listEmojiPacks = ({ page, pageSize }) => {
|
||||
return fetch(PLEROMA_EMOJI_PACKS_URL(page, pageSize))
|
||||
}
|
||||
|
||||
const listRemoteEmojiPacks = ({ instance, page, pageSize }) => {
|
||||
if (!instance.startsWith('http')) {
|
||||
instance = 'https://' + instance
|
||||
}
|
||||
|
||||
return fetch(
|
||||
PLEROMA_EMOJI_PACKS_LS_REMOTE_URL(instance, page, pageSize),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const downloadRemoteEmojiPack = ({ instance, packName, as }) => {
|
||||
return fetch(
|
||||
PLEROMA_EMOJI_PACKS_DL_REMOTE_URL,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: instance, name: packName, as
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const saveEmojiPackMetadata = ({ name, newData }) => {
|
||||
return fetch(
|
||||
PLEROMA_EMOJI_PACK_URL(name),
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metadata: newData })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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 fetch(
|
||||
PLEROMA_EMOJI_UPDATE_FILE_URL(packName),
|
||||
{ method: 'POST', body: data }
|
||||
)
|
||||
}
|
||||
|
||||
const updateEmojiFile = ({ packName, shortcode, newShortcode, newFilename, force }) => {
|
||||
return fetch(
|
||||
PLEROMA_EMOJI_UPDATE_FILE_URL(packName),
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shortcode, new_shortcode: newShortcode, new_filename: newFilename, force })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteEmojiFile = ({ packName, shortcode }) => {
|
||||
return fetch(`${PLEROMA_EMOJI_UPDATE_FILE_URL(packName)}&shortcode=${shortcode}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
const fetchBookmarkFolders = ({ credentials }) => {
|
||||
const url = PLEROMA_BOOKMARK_FOLDERS_URL
|
||||
return fetch(url, { headers: authHeaders(credentials) })
|
||||
.then((data) => data.json())
|
||||
}
|
||||
|
||||
const createBookmarkFolder = ({ name, emoji, credentials }) => {
|
||||
const url = PLEROMA_BOOKMARK_FOLDERS_URL
|
||||
const headers = authHeaders(credentials)
|
||||
headers['Content-Type'] = 'application/json'
|
||||
|
||||
return fetch(url, {
|
||||
headers,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, emoji })
|
||||
}).then((data) => data.json())
|
||||
}
|
||||
|
||||
const updateBookmarkFolder = ({ folderId, name, emoji, credentials }) => {
|
||||
const url = PLEROMA_BOOKMARK_FOLDER_URL(folderId)
|
||||
const headers = authHeaders(credentials)
|
||||
headers['Content-Type'] = 'application/json'
|
||||
|
||||
return fetch(url, {
|
||||
headers,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name, emoji })
|
||||
}).then((data) => data.json())
|
||||
}
|
||||
|
||||
const deleteBookmarkFolder = ({ folderId, credentials }) => {
|
||||
const url = PLEROMA_BOOKMARK_FOLDER_URL(folderId)
|
||||
return fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(credentials)
|
||||
})
|
||||
}
|
||||
|
||||
const apiService = {
|
||||
verifyCredentials,
|
||||
fetchTimeline,
|
||||
|
|
@ -1697,8 +2017,6 @@ const apiService = {
|
|||
fetchMutes,
|
||||
muteUser,
|
||||
unmuteUser,
|
||||
subscribeUser,
|
||||
unsubscribeUser,
|
||||
fetchBlocks,
|
||||
fetchOAuthTokens,
|
||||
revokeOAuthToken,
|
||||
|
|
@ -1772,7 +2090,28 @@ const apiService = {
|
|||
postAnnouncement,
|
||||
editAnnouncement,
|
||||
deleteAnnouncement,
|
||||
adminFetchAnnouncements
|
||||
fetchScrobbles,
|
||||
adminFetchAnnouncements,
|
||||
fetchInstanceDBConfig,
|
||||
fetchInstanceConfigDescriptions,
|
||||
fetchAvailableFrontends,
|
||||
pushInstanceDBConfig,
|
||||
installFrontend,
|
||||
importEmojiFromFS,
|
||||
reloadEmoji,
|
||||
listEmojiPacks,
|
||||
createEmojiPack,
|
||||
deleteEmojiPack,
|
||||
saveEmojiPackMetadata,
|
||||
addNewEmojiFile,
|
||||
updateEmojiFile,
|
||||
deleteEmojiFile,
|
||||
listRemoteEmojiPacks,
|
||||
downloadRemoteEmojiPack,
|
||||
fetchBookmarkFolders,
|
||||
createBookmarkFolder,
|
||||
updateBookmarkFolder,
|
||||
deleteBookmarkFolder
|
||||
}
|
||||
|
||||
export default apiService
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import timelineFetcher from '../timeline_fetcher/timeline_fetcher.service.js'
|
|||
import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js'
|
||||
import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service'
|
||||
import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js'
|
||||
import bookmarkFoldersFetcher from '../../services/bookmark_folders_fetcher/bookmark_folders_fetcher.service.js'
|
||||
|
||||
const backendInteractorService = credentials => ({
|
||||
startFetchingTimeline ({ timeline, store, userId = false, listId = false, tag }) {
|
||||
return timelineFetcher.startFetching({ timeline, store, credentials, userId, listId, tag })
|
||||
startFetchingTimeline ({ timeline, store, userId = false, listId = false, statusId = false, bookmarkFolderId = false, tag }) {
|
||||
return timelineFetcher.startFetching({ timeline, store, credentials, userId, listId, statusId, bookmarkFolderId, tag })
|
||||
},
|
||||
|
||||
fetchTimeline (args) {
|
||||
|
|
@ -29,10 +30,14 @@ const backendInteractorService = credentials => ({
|
|||
return listsFetcher.startFetching({ store, credentials })
|
||||
},
|
||||
|
||||
startFetchingBookmarkFolders ({ store }) {
|
||||
return bookmarkFoldersFetcher.startFetching({ store, credentials })
|
||||
},
|
||||
|
||||
startUserSocket ({ store }) {
|
||||
const serv = store.rootState.instance.server.replace('http', 'ws')
|
||||
const url = serv + getMastodonSocketURI({ credentials, stream: 'user' })
|
||||
return ProcessedWS({ url, id: 'User' })
|
||||
const url = getMastodonSocketURI({}, serv)
|
||||
return ProcessedWS({ url, id: 'Unified', credentials })
|
||||
},
|
||||
|
||||
...Object.entries(apiService).reduce((acc, [key, func]) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import apiService from '../api/api.service.js'
|
||||
import { promiseInterval } from '../promise_interval/promise_interval.js'
|
||||
|
||||
const fetchAndUpdate = ({ store, credentials }) => {
|
||||
return apiService.fetchBookmarkFolders({ credentials })
|
||||
.then(bookmarkFolders => {
|
||||
store.commit('setBookmarkFolders', bookmarkFolders)
|
||||
}, () => {})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const startFetching = ({ credentials, store }) => {
|
||||
const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store })
|
||||
boundFetchAndUpdate()
|
||||
return promiseInterval(boundFetchAndUpdate, 240000)
|
||||
}
|
||||
|
||||
const bookmarkFoldersFetcher = {
|
||||
startFetching
|
||||
}
|
||||
|
||||
export default bookmarkFoldersFetcher
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { invertLightness, contrastRatio } from 'chromatism'
|
||||
import { invertLightness, contrastRatio, convert } from 'chromatism'
|
||||
|
||||
// useful for visualizing color when debugging
|
||||
export const consoleColor = (color) => console.log('%c##########', 'background: ' + color + '; color: ' + color)
|
||||
// const consoleColor = (color) => console.debug('%c##########', 'background: ' + color + '; color: ' + color)
|
||||
|
||||
/**
|
||||
* Convert r, g, b values into hex notation. All components are [0-255]
|
||||
|
|
@ -52,15 +52,6 @@ const c2linear = (bit) => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts sRGB into linear RGB
|
||||
* @param {Object} srgb - sRGB color
|
||||
* @returns {Object} linear rgb color
|
||||
*/
|
||||
const srgbToLinear = (srgb) => {
|
||||
return 'rgb'.split('').reduce((acc, c) => { acc[c] = c2linear(srgb[c]); return acc }, {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates relative luminance for given color
|
||||
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
|
||||
|
|
@ -70,7 +61,10 @@ const srgbToLinear = (srgb) => {
|
|||
* @returns {Number} relative luminance
|
||||
*/
|
||||
export const relativeLuminance = (srgb) => {
|
||||
const { r, g, b } = srgbToLinear(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
|
||||
}
|
||||
|
||||
|
|
@ -110,13 +104,17 @@ export const getContrastRatioLayers = (text, layers, bedrock) => {
|
|||
* @returns {Object} sRGB of resulting color
|
||||
*/
|
||||
export const alphaBlend = (fg, fga, bg) => {
|
||||
if (fga === 1 || typeof fga === 'undefined') return fg
|
||||
return 'rgb'.split('').reduce((acc, c) => {
|
||||
// Simplified https://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending
|
||||
// for opaque bg and transparent fg
|
||||
acc[c] = (fg[c] * fga + bg[c] * (1 - fga))
|
||||
return acc
|
||||
}, {})
|
||||
if (fga === 1 || typeof fga === 'undefined') {
|
||||
return fg
|
||||
}
|
||||
|
||||
// Simplified https://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending
|
||||
// for opaque bg and transparent fg
|
||||
return {
|
||||
r: (fg.r * fga + bg.r * (1 - fga)),
|
||||
g: (fg.g * fga + bg.g * (1 - fga)),
|
||||
b: (fg.b * fga + bg.b * (1 - fga))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -130,10 +128,11 @@ export const alphaBlendLayers = (bedrock, layers) => layers.reduce((acc, [color,
|
|||
}, bedrock)
|
||||
|
||||
export const invert = (rgb) => {
|
||||
return 'rgb'.split('').reduce((acc, c) => {
|
||||
acc[c] = 255 - rgb[c]
|
||||
return acc
|
||||
}, {})
|
||||
return {
|
||||
r: 255 - rgb.r,
|
||||
g: 255 - rgb.g,
|
||||
b: 255 - rgb.b
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -144,6 +143,7 @@ export const invert = (rgb) => {
|
|||
*/
|
||||
export const hex2rgb = (hex) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
|
|
@ -161,11 +161,13 @@ export const hex2rgb = (hex) => {
|
|||
* @returns {Object} result
|
||||
*/
|
||||
export const mixrgb = (a, b) => {
|
||||
return 'rgb'.split('').reduce((acc, k) => {
|
||||
acc[k] = (a[k] + b[k]) / 2
|
||||
return acc
|
||||
}, {})
|
||||
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
|
||||
*
|
||||
|
|
@ -173,7 +175,33 @@ export const mixrgb = (a, b) => {
|
|||
* @returns {String} CSS rgba() color
|
||||
*/
|
||||
export const rgba2css = function (rgba) {
|
||||
return `rgba(${Math.floor(rgba.r)}, ${Math.floor(rgba.g)}, ${Math.floor(rgba.b)}, ${rgba.a})`
|
||||
const base = {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 1
|
||||
}
|
||||
|
||||
if (rgba !== null) {
|
||||
if (rgba.r !== undefined && !isNaN(rgba.r)) {
|
||||
base.r = rgba.r
|
||||
}
|
||||
if (rgba.g !== undefined && !isNaN(rgba.g)) {
|
||||
base.g = rgba.g
|
||||
}
|
||||
if (rgba.b !== undefined && !isNaN(rgba.b)) {
|
||||
base.b = rgba.b
|
||||
}
|
||||
if (rgba.a !== undefined && !isNaN(rgba.a)) {
|
||||
base.a = rgba.a
|
||||
}
|
||||
} else {
|
||||
base.r = 255
|
||||
base.g = 255
|
||||
base.b = 255
|
||||
}
|
||||
|
||||
return `rgba(${Math.floor(base.r)}, ${Math.floor(base.g)}, ${Math.floor(base.b)}, ${base.a})`
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -187,19 +215,37 @@ export const rgba2css = function (rgba) {
|
|||
* @param {Boolean} preserve - try to preserve intended text color's hue/saturation (i.e. no BW)
|
||||
*/
|
||||
export const getTextColor = function (bg, text, preserve) {
|
||||
const contrast = getContrastRatio(bg, text)
|
||||
|
||||
if (contrast < 4.5) {
|
||||
const base = typeof text.a !== 'undefined' ? { a: text.a } : {}
|
||||
const result = Object.assign(base, invertLightness(text).rgb)
|
||||
if (!preserve && getContrastRatio(bg, result) < 4.5) {
|
||||
const originalContrast = getContrastRatio(bg, text)
|
||||
if (!preserve) {
|
||||
if (originalContrast < 4.5) {
|
||||
// B&W
|
||||
return contrastRatio(bg, text).rgb
|
||||
}
|
||||
// Inverted color
|
||||
return result
|
||||
}
|
||||
return text
|
||||
|
||||
const originalColor = convert(text).hex
|
||||
const invertedColor = invertLightness(originalColor).hex
|
||||
const invertedContrast = getContrastRatio(bg, convert(invertedColor).rgb)
|
||||
let workColor
|
||||
|
||||
if (invertedContrast > originalContrast) {
|
||||
workColor = invertedColor
|
||||
} else {
|
||||
workColor = originalColor
|
||||
}
|
||||
|
||||
let contrast = getContrastRatio(bg, text)
|
||||
const result = convert(rgb2hex(workColor)).hsl
|
||||
const delta = result.l >= 50 ? 1 : -1
|
||||
const multiplier = 1
|
||||
while (contrast < 4.5 && result.l > 20 && result.l < 80) {
|
||||
result.l += delta * multiplier
|
||||
result.l = Math.min(100, Math.max(0, result.l))
|
||||
contrast = getContrastRatio(bg, convert(result).rgb)
|
||||
}
|
||||
|
||||
const base = typeof text.a !== 'undefined' ? { a: text.a } : {}
|
||||
return Object.assign(convert(result).rgb, base)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@ export const WEEK = 7 * DAY
|
|||
export const MONTH = 30 * DAY
|
||||
export const YEAR = 365.25 * DAY
|
||||
|
||||
export const relativeTime = (date, nowThreshold = 1) => {
|
||||
export const relativeTimeMs = (date) => {
|
||||
if (typeof date === 'string') date = Date.parse(date)
|
||||
return Math.abs(Date.now() - date)
|
||||
}
|
||||
export const relativeTime = (date, nowThreshold = 1) => {
|
||||
const round = Date.now() > date ? Math.floor : Math.ceil
|
||||
const d = Math.abs(Date.now() - date)
|
||||
const d = relativeTimeMs(date)
|
||||
const r = { num: round(d / YEAR), key: 'time.unit.years' }
|
||||
if (d < nowThreshold * SECOND) {
|
||||
r.num = 0
|
||||
|
|
@ -57,3 +60,39 @@ export const secondsToUnit = (unit, amount) => {
|
|||
case 'days': return (1000 * amount) / DAY
|
||||
}
|
||||
}
|
||||
|
||||
export const isSameYear = (a, b) => {
|
||||
return a.getFullYear() === b.getFullYear()
|
||||
}
|
||||
|
||||
export const isSameMonth = (a, b) => {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth()
|
||||
}
|
||||
|
||||
export const isSameDay = (a, b) => {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
}
|
||||
|
||||
export const durationStrToMs = (str) => {
|
||||
if (typeof str !== 'string') {
|
||||
return 0
|
||||
}
|
||||
|
||||
const unit = str.replace(/[0-9,.]+/, '')
|
||||
const value = str.replace(/[^0-9,.]+/, '')
|
||||
switch (unit) {
|
||||
case 'd':
|
||||
return value * DAY
|
||||
case 'h':
|
||||
return value * HOUR
|
||||
case 'm':
|
||||
return value * MINUTE
|
||||
case 's':
|
||||
return value * SECOND
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,38 @@
|
|||
import {
|
||||
showDesktopNotification as swDesktopNotification,
|
||||
closeDesktopNotification as swCloseDesktopNotification,
|
||||
isSWSupported
|
||||
} from '../sw/sw.js'
|
||||
const state = { failCreateNotif: false }
|
||||
|
||||
export const showDesktopNotification = (rootState, desktopNotificationOpts) => {
|
||||
if (!('Notification' in window && window.Notification.permission === 'granted')) return
|
||||
if (rootState.statuses.notifications.desktopNotificationSilence) { return }
|
||||
if (rootState.notifications.desktopNotificationSilence) { return }
|
||||
|
||||
const desktopNotification = new window.Notification(desktopNotificationOpts.title, desktopNotificationOpts)
|
||||
// Chrome is known for not closing notifications automatically
|
||||
// according to MDN, anyway.
|
||||
setTimeout(desktopNotification.close.bind(desktopNotification), 5000)
|
||||
if (isSWSupported()) {
|
||||
swDesktopNotification(desktopNotificationOpts)
|
||||
} else if (!state.failCreateNotif) {
|
||||
try {
|
||||
const desktopNotification = new window.Notification(desktopNotificationOpts.title, desktopNotificationOpts)
|
||||
setTimeout(desktopNotification.close.bind(desktopNotification), 5000)
|
||||
} catch {
|
||||
state.failCreateNotif = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const closeDesktopNotification = (rootState, { id }) => {
|
||||
if (!('Notification' in window && window.Notification.permission === 'granted')) return
|
||||
|
||||
if (isSWSupported()) {
|
||||
swCloseDesktopNotification({ id })
|
||||
}
|
||||
}
|
||||
|
||||
export const closeAllDesktopNotifications = (rootState) => {
|
||||
if (!('Notification' in window && window.Notification.permission === 'granted')) return
|
||||
|
||||
if (isSWSupported()) {
|
||||
swCloseDesktopNotification({})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ export const parseUser = (data) => {
|
|||
|
||||
output.allow_following_move = data.pleroma.allow_following_move
|
||||
|
||||
output.hide_favorites = data.pleroma.hide_favorites
|
||||
output.hide_follows = data.pleroma.hide_follows
|
||||
output.hide_followers = data.pleroma.hide_followers
|
||||
output.hide_follows_count = data.pleroma.hide_follows_count
|
||||
|
|
@ -165,6 +166,7 @@ export const parseUser = (data) => {
|
|||
output.show_role = data.source.pleroma.show_role
|
||||
output.discoverable = data.source.pleroma.discoverable
|
||||
output.show_birthday = data.pleroma.show_birthday
|
||||
output.actor_type = data.source.pleroma.actor_type
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -325,6 +327,12 @@ export const parseStatus = (data) => {
|
|||
output.thread_muted = pleroma.thread_muted
|
||||
output.emoji_reactions = pleroma.emoji_reactions
|
||||
output.parent_visible = pleroma.parent_visible === undefined ? true : pleroma.parent_visible
|
||||
output.quote = pleroma.quote ? parseStatus(pleroma.quote) : undefined
|
||||
output.quote_id = pleroma.quote_id ? pleroma.quote_id : (output.quote ? output.quote.id : undefined)
|
||||
output.quote_url = pleroma.quote_url
|
||||
output.quote_visible = pleroma.quote_visible
|
||||
output.quotes_count = pleroma.quotes_count
|
||||
output.bookmark_folder_id = pleroma.bookmark_folder
|
||||
} else {
|
||||
output.text = data.content
|
||||
output.summary = data.spoiler_text
|
||||
|
|
@ -434,8 +442,9 @@ export const parseNotification = (data) => {
|
|||
if (masto) {
|
||||
output.type = mastoDict[data.type] || data.type
|
||||
output.seen = data.pleroma.is_seen
|
||||
output.status = isStatusNotification(output.type) ? parseStatus(data.status) : null
|
||||
output.action = output.status // TODO: Refactor, this is unneeded
|
||||
// TODO: null check should be a temporary fix, I guess.
|
||||
// Investigate why backend does this.
|
||||
output.status = isStatusNotification(output.type) && data.status !== null ? parseStatus(data.status) : null
|
||||
output.target = output.type !== 'move'
|
||||
? null
|
||||
: parseUser(data.target)
|
||||
|
|
|
|||
|
|
@ -2,15 +2,23 @@ import utf8 from 'utf8'
|
|||
|
||||
export const newExporter = ({
|
||||
filename = 'data',
|
||||
mime = 'application/json',
|
||||
extension = 'json',
|
||||
getExportedObject
|
||||
}) => ({
|
||||
exportData () {
|
||||
const stringified = utf8.encode(JSON.stringify(getExportedObject(), null, 2)) // Pretty-print and indent with 2 spaces
|
||||
let stringified
|
||||
if (mime === 'application/json') {
|
||||
stringified = utf8.encode(JSON.stringify(getExportedObject(), null, 2)) // Pretty-print and indent with 2 spaces
|
||||
} else {
|
||||
stringified = utf8.encode(getExportedObject()) // Pretty-print and indent with 2 spaces
|
||||
}
|
||||
|
||||
// Create an invisible link with a data url and simulate a click
|
||||
const e = document.createElement('a')
|
||||
e.setAttribute('download', `${filename}.json`)
|
||||
e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified))
|
||||
const realFilename = typeof filename === 'function' ? filename() : filename
|
||||
e.setAttribute('download', `${realFilename}.${extension}`)
|
||||
e.setAttribute('href', `data:${mime};base64, ${window.btoa(stringified)}`)
|
||||
e.style.display = 'none'
|
||||
|
||||
document.body.appendChild(e)
|
||||
|
|
@ -20,6 +28,8 @@ export const newExporter = ({
|
|||
})
|
||||
|
||||
export const newImporter = ({
|
||||
accept = '.json',
|
||||
parser = (string) => JSON.parse(string),
|
||||
onImport,
|
||||
onImportFailure,
|
||||
validator = () => true
|
||||
|
|
@ -27,18 +37,19 @@ export const newImporter = ({
|
|||
importData () {
|
||||
const filePicker = document.createElement('input')
|
||||
filePicker.setAttribute('type', 'file')
|
||||
filePicker.setAttribute('accept', '.json')
|
||||
filePicker.setAttribute('accept', accept)
|
||||
|
||||
filePicker.addEventListener('change', event => {
|
||||
if (event.target.files[0]) {
|
||||
const filename = event.target.files[0].name
|
||||
// eslint-disable-next-line no-undef
|
||||
const reader = new FileReader()
|
||||
reader.onload = ({ target }) => {
|
||||
try {
|
||||
const parsed = JSON.parse(target.result)
|
||||
const validationResult = validator(parsed)
|
||||
const parsed = parser(target.result, filename)
|
||||
const validationResult = validator(parsed, filename)
|
||||
if (validationResult === true) {
|
||||
onImport(parsed)
|
||||
onImport(parsed, filename)
|
||||
} else {
|
||||
onImportFailure({ validationResult })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,10 +55,13 @@ const createFaviconService = () => {
|
|||
})
|
||||
}
|
||||
|
||||
const getOriginalFavicons = () => [...favicons]
|
||||
|
||||
return {
|
||||
initFaviconService,
|
||||
clearFaviconBadge,
|
||||
drawFaviconBadge
|
||||
drawFaviconBadge,
|
||||
getOriginalFavicons
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// 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.
|
||||
const fileType = mimetype => {
|
||||
export const fileType = mimetype => {
|
||||
if (mimetype.match(/flash/)) {
|
||||
return 'flash'
|
||||
}
|
||||
|
|
@ -25,11 +25,25 @@ const fileType = mimetype => {
|
|||
return 'unknown'
|
||||
}
|
||||
|
||||
const fileMatchesSomeType = (types, file) =>
|
||||
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)
|
||||
|
||||
const fileTypeService = {
|
||||
fileType,
|
||||
fileTypeExt,
|
||||
fileMatchesSomeType
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
const DIRECTION_LEFT = [-1, 0]
|
||||
const DIRECTION_RIGHT = [1, 0]
|
||||
const DIRECTION_UP = [0, -1]
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* @return {String} - tagname, i.e. "div"
|
||||
*/
|
||||
export const getTagName = (tag) => {
|
||||
const result = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/gi.exec(tag)
|
||||
const result = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/gis.exec(tag)
|
||||
return result && (result[1] || result[2])
|
||||
}
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ export const getAttrs = (tag, filter) => {
|
|||
.replace(new RegExp('^' + getTagName(tag)), '')
|
||||
.replace(/\/?$/, '')
|
||||
.trim()
|
||||
const attrs = Array.from(innertag.matchAll(/([a-z0-9-]+)(?:=("[^"]+?"|'[^']+?'))?/gi))
|
||||
const attrs = Array.from(innertag.matchAll(/([a-z]+[a-z0-9-]*)(?:=("[^"]+?"|'[^']+?'))?/gi))
|
||||
.map(([trash, key, value]) => [key, value])
|
||||
.map(([k, v]) => {
|
||||
if (!v) return [k, true]
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import ISO6391 from 'iso-639-1'
|
|||
import _ from 'lodash'
|
||||
|
||||
const specialLanguageCodes = {
|
||||
pdc: 'en',
|
||||
ja_easy: 'ja',
|
||||
zh_Hant: 'zh-HANT',
|
||||
zh: 'zh-Hans'
|
||||
|
|
@ -18,7 +19,9 @@ const internalToBackendLocaleMulti = codes => {
|
|||
|
||||
const getLanguageName = (code) => {
|
||||
const specialLanguageNames = {
|
||||
pdc: 'Pennsilfaanisch-Deitsch',
|
||||
ja_easy: 'やさしいにほんご',
|
||||
'nan-TW': '臺語(閩南語)',
|
||||
zh: '简体中文',
|
||||
zh_Hant: '繁體中文'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,11 @@ export const mentionMatchesUrl = (attention, url) => {
|
|||
* @param {string} url
|
||||
*/
|
||||
export const extractTagFromUrl = (url) => {
|
||||
const regex = /tag[s]*\/(\w+)$/g
|
||||
const result = regex.exec(url)
|
||||
const decoded = decodeURI(url)
|
||||
// https://git.pleroma.social/pleroma/elixir-libraries/linkify/-/blob/master/lib/linkify/parser.ex
|
||||
// https://www.pcre.org/original/doc/html/pcrepattern.html
|
||||
const regex = /tag[s]*\/([\p{L}\p{N}_]*[\p{Alphabetic}_·\u{200c}][\p{L}\p{N}_·\p{M}\u{200c}]*)$/ug
|
||||
const result = regex.exec(decoded)
|
||||
if (!result) {
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ export const getOrCreateApp = ({ clientId, clientSecret, instance, commit }) =>
|
|||
const url = `${instance}/api/v1/apps`
|
||||
const form = new window.FormData()
|
||||
|
||||
form.append('client_name', `PleromaFE_${window.___pleromafe_commit_hash}_${(new Date()).toISOString()}`)
|
||||
form.append('client_name', 'PleromaFE')
|
||||
form.append('website', 'https://pleroma.social')
|
||||
form.append('redirect_uris', REDIRECT_URI)
|
||||
form.append('scopes', 'read write follow push admin')
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +1,38 @@
|
|||
import { filter, sortBy, includes } from 'lodash'
|
||||
import { muteWordHits } from '../status_parser/status_parser.js'
|
||||
import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
|
||||
import { useI18nStore } from '../../stores/i18n.js'
|
||||
|
||||
export const notificationsFromStore = store => store.state.statuses.notifications.data
|
||||
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
|
||||
|
||||
export const visibleTypes = store => {
|
||||
const rootState = store.rootState || store.state
|
||||
// When called from within a module we need rootGetters to access wider scope
|
||||
// however when called from a component (i.e. this.$store) we already have wider scope
|
||||
const rootGetters = store.rootGetters || store.getters
|
||||
const { notificationVisibility } = rootGetters.mergedConfig
|
||||
|
||||
return ([
|
||||
rootState.config.notificationVisibility.likes && 'like',
|
||||
rootState.config.notificationVisibility.mentions && 'mention',
|
||||
rootState.config.notificationVisibility.repeats && 'repeat',
|
||||
rootState.config.notificationVisibility.follows && 'follow',
|
||||
rootState.config.notificationVisibility.followRequest && 'follow_request',
|
||||
rootState.config.notificationVisibility.moves && 'move',
|
||||
rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction',
|
||||
rootState.config.notificationVisibility.reports && 'pleroma:report',
|
||||
rootState.config.notificationVisibility.polls && 'poll'
|
||||
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(_ => _))
|
||||
}
|
||||
|
||||
const statusNotifications = ['like', 'mention', 'repeat', 'pleroma:emoji_reaction', 'poll']
|
||||
const statusNotifications = new Set(['like', 'mention', 'status', 'repeat', 'pleroma:emoji_reaction', 'poll'])
|
||||
|
||||
export const isStatusNotification = (type) => includes(statusNotifications, type)
|
||||
export const isStatusNotification = (type) => statusNotifications.has(type)
|
||||
|
||||
export const isValidNotification = (notification) => {
|
||||
if (isStatusNotification(notification.type) && !notification.status) {
|
||||
|
|
@ -50,7 +59,8 @@ const sortById = (a, b) => {
|
|||
|
||||
const isMutedNotification = (store, notification) => {
|
||||
if (!notification.status) return
|
||||
return notification.status.muted || muteWordHits(notification.status, store.rootGetters.mergedConfig.muteWords).length > 0
|
||||
const rootGetters = store.rootGetters || store.getters
|
||||
return notification.status.muted || muteWordHits(notification.status, rootGetters.mergedConfig.muteWords).length > 0
|
||||
}
|
||||
|
||||
export const maybeShowNotification = (store, notification) => {
|
||||
|
|
@ -66,19 +76,39 @@ export const maybeShowNotification = (store, notification) => {
|
|||
|
||||
export const filteredNotificationsFromStore = (store, types) => {
|
||||
// map is just to clone the array since sort mutates it and it causes some issues
|
||||
let sortedNotifications = notificationsFromStore(store).map(_ => _).sort(sortById)
|
||||
sortedNotifications = sortBy(sortedNotifications, 'seen')
|
||||
const sortedNotifications = notificationsFromStore(store).map(_ => _).sort(sortById)
|
||||
// TODO implement sorting elsewhere and make it optional
|
||||
return sortedNotifications.filter(
|
||||
(notification) => (types || visibleTypes(store)).includes(notification.type)
|
||||
)
|
||||
}
|
||||
|
||||
export const unseenNotificationsFromStore = store =>
|
||||
filter(filteredNotificationsFromStore(store), ({ seen }) => !seen)
|
||||
export const unseenNotificationsFromStore = store => {
|
||||
const rootGetters = store.rootGetters || store.getters
|
||||
const ignoreInactionableSeen = rootGetters.mergedConfig.ignoreInactionableSeen
|
||||
|
||||
return filteredNotificationsFromStore(store).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
|
||||
tag: notification.id,
|
||||
type: notification.type,
|
||||
badge: cachedBadgeUrl
|
||||
}
|
||||
const status = notification.status
|
||||
const title = notification.from_profile.name
|
||||
|
|
@ -89,6 +119,9 @@ export const prepareNotificationObject = (notification, i18n) => {
|
|||
case 'like':
|
||||
i18nString = 'favorited_you'
|
||||
break
|
||||
case 'status':
|
||||
i18nString = 'subscribed_status'
|
||||
break
|
||||
case 'repeat':
|
||||
i18nString = 'repeated_you'
|
||||
break
|
||||
|
|
@ -125,3 +158,18 @@ export const prepareNotificationObject = (notification, i18n) => {
|
|||
|
||||
return notifObj
|
||||
}
|
||||
|
||||
export const countExtraNotifications = (store) => {
|
||||
const rootGetters = store.rootGetters || store.getters
|
||||
const mergedConfig = rootGetters.mergedConfig
|
||||
|
||||
if (!mergedConfig.showExtraNotifications) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return [
|
||||
mergedConfig.showChatsInExtraNotifications ? rootGetters.unreadChatCount : 0,
|
||||
mergedConfig.showAnnouncementsInExtraNotifications ? rootGetters.unreadAnnouncementCount : 0,
|
||||
mergedConfig.showFollowRequestsInExtraNotifications ? rootGetters.followRequestCount : 0
|
||||
].reduce((a, c) => a + c, 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,15 @@ import { promiseInterval } from '../promise_interval/promise_interval.js'
|
|||
// Note: chat_mention excluded as pleroma-fe polls them separately
|
||||
const mastoApiNotificationTypes = [
|
||||
'mention',
|
||||
'status',
|
||||
'favourite',
|
||||
'reblog',
|
||||
'follow',
|
||||
'follow_request',
|
||||
'move',
|
||||
'poll',
|
||||
'pleroma:emoji_reaction',
|
||||
'pleroma:chat_mention',
|
||||
'pleroma:report'
|
||||
]
|
||||
|
||||
|
|
@ -22,7 +26,7 @@ const fetchAndUpdate = ({ store, credentials, older = false, since }) => {
|
|||
const args = { credentials }
|
||||
const { getters } = store
|
||||
const rootState = store.rootState || store.state
|
||||
const timelineData = rootState.statuses.notifications
|
||||
const timelineData = rootState.notifications
|
||||
const hideMutedPosts = getters.mergedConfig.hideMutedPosts
|
||||
|
||||
args.includeTypes = mastoApiNotificationTypes
|
||||
|
|
@ -50,10 +54,14 @@ const fetchAndUpdate = ({ store, credentials, older = false, since }) => {
|
|||
// The normal maxId-check does not tell if older notifications have changed
|
||||
const notifications = timelineData.data
|
||||
const readNotifsIds = notifications.filter(n => n.seen).map(n => n.id)
|
||||
const numUnseenNotifs = notifications.length - readNotifsIds.length
|
||||
if (numUnseenNotifs > 0 && readNotifsIds.length > 0) {
|
||||
args.since = Math.max(...readNotifsIds)
|
||||
fetchNotifications({ store, args, older })
|
||||
const unreadNotifsIds = notifications.filter(n => !n.seen).map(n => n.id)
|
||||
if (readNotifsIds.length > 0 && readNotifsIds.length > 0) {
|
||||
const minId = Math.min(...unreadNotifsIds) // Oldest known unread notification
|
||||
if (minId !== Infinity) {
|
||||
args.since = false // Don't use since_id since it sorta conflicts with min_id
|
||||
args.minId = minId - 1 // go beyond
|
||||
fetchNotifications({ store, args, older })
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
|
|
|
|||
36
src/services/poll/poll.service.js
Normal file
36
src/services/poll/poll.service.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import * as DateUtils from 'src/services/date_utils/date_utils.js'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
const pollFallbackValues = {
|
||||
pollType: 'single',
|
||||
options: ['', ''],
|
||||
expiryAmount: 10,
|
||||
expiryUnit: 'minutes'
|
||||
}
|
||||
|
||||
const pollFallback = (object, attr) => {
|
||||
return object[attr] !== undefined ? object[attr] : pollFallbackValues[attr]
|
||||
}
|
||||
|
||||
const pollFormToMasto = (poll) => {
|
||||
const expiresIn = DateUtils.unitToSeconds(
|
||||
pollFallback(poll, 'expiryUnit'),
|
||||
pollFallback(poll, 'expiryAmount')
|
||||
)
|
||||
|
||||
const options = uniq(pollFallback(poll, 'options').filter(option => option !== ''))
|
||||
if (options.length < 2) {
|
||||
return { errorKey: 'polls.not_enough_options' }
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
multiple: pollFallback(poll, 'pollType') === 'multiple',
|
||||
expiresIn
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
pollFallback,
|
||||
pollFormToMasto
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
// promiseInterval - replacement for setInterval for promises, starts counting
|
||||
// the interval only after a promise is done instead of immediately.
|
||||
// - promiseCall is a function that returns a promise, it's called the first
|
||||
|
|
|
|||
3
src/services/random_seed/random_seed.service.js
Normal file
3
src/services/random_seed/random_seed.service.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
const genRandomSeed = () => `${Math.random()}`.replace('.', '-')
|
||||
|
||||
export default genRandomSeed
|
||||
|
|
@ -10,6 +10,7 @@ const postStatus = ({
|
|||
poll,
|
||||
media = [],
|
||||
inReplyToStatusId = undefined,
|
||||
quoteId = undefined,
|
||||
contentType = 'text/plain',
|
||||
preview = false,
|
||||
idempotencyKey = ''
|
||||
|
|
@ -24,6 +25,7 @@ const postStatus = ({
|
|||
sensitive,
|
||||
mediaIds,
|
||||
inReplyToStatusId,
|
||||
quoteId,
|
||||
contentType,
|
||||
poll,
|
||||
preview,
|
||||
|
|
|
|||
|
|
@ -1,452 +1,302 @@
|
|||
import { convert } from 'chromatism'
|
||||
import { rgb2hex, hex2rgb, rgba2css, getCssColor, relativeLuminance } from '../color_convert/color_convert.js'
|
||||
import { getColors, computeDynamicColor, getOpacitySlot } from '../theme_data/theme_data.service.js'
|
||||
import { init, getEngineChecksum } from '../theme_data/theme_data_3.service.js'
|
||||
import { getCssRules } from '../theme_data/css_utils.js'
|
||||
import { defaultState } from '../../modules/config.js'
|
||||
import { chunk } from 'lodash'
|
||||
import pako from 'pako'
|
||||
import localforage from 'localforage'
|
||||
|
||||
export const applyTheme = (input) => {
|
||||
const { rules } = generatePreset(input)
|
||||
const head = document.head
|
||||
const body = document.body
|
||||
body.classList.add('hidden')
|
||||
// On platforms where this is not supported, it will return undefined
|
||||
// Otherwise it will return an array
|
||||
const supportsAdoptedStyleSheets = !!document.adoptedStyleSheets
|
||||
|
||||
const styleEl = document.createElement('style')
|
||||
head.appendChild(styleEl)
|
||||
const styleSheet = styleEl.sheet
|
||||
const createStyleSheet = (id) => {
|
||||
if (supportsAdoptedStyleSheets) {
|
||||
return {
|
||||
el: null,
|
||||
sheet: new CSSStyleSheet(),
|
||||
rules: []
|
||||
}
|
||||
}
|
||||
|
||||
styleSheet.toString()
|
||||
styleSheet.insertRule(`:root { ${rules.radii} }`, 'index-max')
|
||||
styleSheet.insertRule(`:root { ${rules.colors} }`, 'index-max')
|
||||
styleSheet.insertRule(`:root { ${rules.shadows} }`, 'index-max')
|
||||
styleSheet.insertRule(`:root { ${rules.fonts} }`, 'index-max')
|
||||
body.classList.remove('hidden')
|
||||
const el = document.getElementById(id)
|
||||
// Clear all rules in it
|
||||
for (let i = el.sheet.cssRules.length - 1; i >= 0; --i) {
|
||||
el.sheet.deleteRule(i)
|
||||
}
|
||||
|
||||
return {
|
||||
el,
|
||||
sheet: el.sheet,
|
||||
rules: []
|
||||
}
|
||||
}
|
||||
|
||||
const configColumns = ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth, emojiReactionsScale }) =>
|
||||
({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth, emojiReactionsScale })
|
||||
const EAGER_STYLE_ID = 'pleroma-eager-styles'
|
||||
const LAZY_STYLE_ID = 'pleroma-lazy-styles'
|
||||
|
||||
const defaultConfigColumns = configColumns(defaultState)
|
||||
const adoptStyleSheets = (styles) => {
|
||||
if (supportsAdoptedStyleSheets) {
|
||||
document.adoptedStyleSheets = styles.map(s => s.sheet)
|
||||
}
|
||||
// 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.
|
||||
}
|
||||
|
||||
export const applyConfig = (config) => {
|
||||
const columns = configColumns(config)
|
||||
export const generateTheme = (inputRuleset, callbacks, debug) => {
|
||||
const {
|
||||
onNewRule = (rule, isLazy) => {},
|
||||
onLazyFinished = () => {},
|
||||
onEagerFinished = () => {}
|
||||
} = callbacks
|
||||
|
||||
if (columns === defaultConfigColumns) {
|
||||
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(x => x), 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 data = await localforage.getItem('pleromafe-theme-cache')
|
||||
if (!data) return null
|
||||
let cache
|
||||
try {
|
||||
const inflated = pako.inflate(data)
|
||||
const decoded = new TextDecoder().decode(inflated)
|
||||
cache = JSON.parse(decoded)
|
||||
console.info(`Loaded theme from cache, compressed=${Math.ceil(data.length / 1024)}kiB size=${Math.ceil(inflated.length / 1024)}kiB`)
|
||||
} catch (e) {
|
||||
console.error('Failed to decode theme cache:', e)
|
||||
return false
|
||||
}
|
||||
if (cache.engineChecksum === getEngineChecksum()) {
|
||||
const eagerStyles = createStyleSheet(EAGER_STYLE_ID)
|
||||
const lazyStyles = createStyleSheet(LAZY_STYLE_ID)
|
||||
|
||||
cache.data[0].forEach(rule => eagerStyles.sheet.insertRule(rule, 'index-max'))
|
||||
cache.data[1].forEach(rule => lazyStyles.sheet.insertRule(rule, 'index-max'))
|
||||
|
||||
adoptStyleSheets([eagerStyles, lazyStyles])
|
||||
|
||||
return true
|
||||
} else {
|
||||
console.warn('Engine checksum doesn\'t match, cache not usable, clearing')
|
||||
localStorage.removeItem('pleroma-fe-theme-cache')
|
||||
}
|
||||
}
|
||||
|
||||
export const applyTheme = (
|
||||
input,
|
||||
onEagerFinish = data => {},
|
||||
onFinish = data => {},
|
||||
debug
|
||||
) => {
|
||||
const eagerStyles = createStyleSheet(EAGER_STYLE_ID)
|
||||
const lazyStyles = createStyleSheet(LAZY_STYLE_ID)
|
||||
|
||||
const insertRule = (styles, rule) => {
|
||||
if (rule.indexOf('webkit') >= 0) {
|
||||
try {
|
||||
styles.sheet.insertRule(rule, 'index-max')
|
||||
styles.rules.push(rule)
|
||||
} catch (e) {
|
||||
console.warn('Can\'t insert rule due to lack of support', e)
|
||||
}
|
||||
} else {
|
||||
styles.sheet.insertRule(rule, 'index-max')
|
||||
styles.rules.push(rule)
|
||||
}
|
||||
}
|
||||
|
||||
const { lazyProcessFunc } = generateTheme(
|
||||
input,
|
||||
{
|
||||
onNewRule (rule, isLazy) {
|
||||
if (isLazy) {
|
||||
insertRule(lazyStyles, rule)
|
||||
} else {
|
||||
insertRule(eagerStyles, rule)
|
||||
}
|
||||
},
|
||||
onEagerFinished () {
|
||||
adoptStyleSheets([eagerStyles])
|
||||
onEagerFinish()
|
||||
},
|
||||
onLazyFinished () {
|
||||
adoptStyleSheets([eagerStyles, lazyStyles])
|
||||
const cache = { engineChecksum: getEngineChecksum(), data: [eagerStyles.rules, lazyStyles.rules] }
|
||||
onFinish(cache)
|
||||
const compress = (js) => {
|
||||
return pako.deflate(JSON.stringify(js))
|
||||
}
|
||||
localforage.setItem('pleromafe-theme-cache', compress(cache))
|
||||
}
|
||||
},
|
||||
debug
|
||||
)
|
||||
|
||||
setTimeout(lazyProcessFunc, 0)
|
||||
}
|
||||
|
||||
const extractStyleConfig = ({
|
||||
sidebarColumnWidth,
|
||||
contentColumnWidth,
|
||||
notifsColumnWidth,
|
||||
emojiReactionsScale,
|
||||
emojiSize,
|
||||
navbarSize,
|
||||
panelHeaderSize,
|
||||
textSize,
|
||||
forcedRoundness
|
||||
}) => {
|
||||
const result = {
|
||||
sidebarColumnWidth,
|
||||
contentColumnWidth,
|
||||
notifsColumnWidth,
|
||||
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(defaultState)
|
||||
|
||||
export const applyConfig = (input, i18n) => {
|
||||
const config = extractStyleConfig(input)
|
||||
|
||||
if (config === defaultStyleConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
const head = document.head
|
||||
const body = document.body
|
||||
body.classList.add('hidden')
|
||||
|
||||
const rules = Object
|
||||
.entries(columns)
|
||||
.entries(config)
|
||||
.filter(([k, v]) => v)
|
||||
.map(([k, v]) => `--${k}: ${v}`).join(';')
|
||||
|
||||
document.getElementById('style-config')?.remove()
|
||||
const styleEl = document.createElement('style')
|
||||
styleEl.id = 'style-config'
|
||||
head.appendChild(styleEl)
|
||||
const styleSheet = styleEl.sheet
|
||||
|
||||
styleSheet.toString()
|
||||
styleSheet.insertRule(`:root { ${rules} }`, 'index-max')
|
||||
body.classList.remove('hidden')
|
||||
}
|
||||
|
||||
export const getCssShadow = (input, usesDropShadow) => {
|
||||
if (input.length === 0) {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
return input
|
||||
.filter(_ => usesDropShadow ? _.inset : _)
|
||||
.map((shad) => [
|
||||
shad.x,
|
||||
shad.y,
|
||||
shad.blur,
|
||||
shad.spread
|
||||
].map(_ => _ + 'px').concat([
|
||||
getCssColor(shad.color, shad.alpha),
|
||||
shad.inset ? 'inset' : ''
|
||||
]).join(' ')).join(', ')
|
||||
}
|
||||
|
||||
const getCssShadowFilter = (input) => {
|
||||
if (input.length === 0) {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
return input
|
||||
// drop-shadow doesn't support inset or spread
|
||||
.filter((shad) => !shad.inset && Number(shad.spread) === 0)
|
||||
.map((shad) => [
|
||||
shad.x,
|
||||
shad.y,
|
||||
// drop-shadow's blur is twice as strong compared to box-shadow
|
||||
shad.blur / 2
|
||||
].map(_ => _ + 'px').concat([
|
||||
getCssColor(shad.color, shad.alpha)
|
||||
]).join(' '))
|
||||
.map(_ => `drop-shadow(${_})`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export const generateColors = (themeData) => {
|
||||
const sourceColors = !themeData.themeEngineVersion
|
||||
? colors2to3(themeData.colors || themeData)
|
||||
: themeData.colors || themeData
|
||||
|
||||
const { colors, opacity } = getColors(sourceColors, themeData.opacity || {})
|
||||
|
||||
const htmlColors = Object.entries(colors)
|
||||
.reduce((acc, [k, v]) => {
|
||||
if (!v) return acc
|
||||
acc.solid[k] = rgb2hex(v)
|
||||
acc.complete[k] = typeof v.a === 'undefined' ? rgb2hex(v) : rgba2css(v)
|
||||
return acc
|
||||
}, { complete: {}, solid: {} })
|
||||
return {
|
||||
rules: {
|
||||
colors: Object.entries(htmlColors.complete)
|
||||
.filter(([k, v]) => v)
|
||||
.map(([k, v]) => `--${k}: ${v}`)
|
||||
.join(';')
|
||||
},
|
||||
theme: {
|
||||
colors: htmlColors.solid,
|
||||
opacity
|
||||
}
|
||||
// TODO find a way to make this not apply to theme previews
|
||||
if (Object.prototype.hasOwnProperty.call(config, 'forcedRoundness')) {
|
||||
styleSheet.insertRule(` *:not(.preview-block) {
|
||||
--roundness: var(--forcedRoundness) !important;
|
||||
}`, 'index-max')
|
||||
}
|
||||
}
|
||||
|
||||
export const generateRadii = (input) => {
|
||||
let inputRadii = input.radii || {}
|
||||
// v1 -> v2
|
||||
if (typeof input.btnRadius !== 'undefined') {
|
||||
inputRadii = Object
|
||||
.entries(input)
|
||||
.filter(([k, v]) => k.endsWith('Radius'))
|
||||
.reduce((acc, e) => { acc[e[0].split('Radius')[0]] = e[1]; return acc }, {})
|
||||
}
|
||||
const radii = Object.entries(inputRadii).filter(([k, v]) => v).reduce((acc, [k, v]) => {
|
||||
acc[k] = v
|
||||
return acc
|
||||
}, {
|
||||
btn: 4,
|
||||
input: 4,
|
||||
checkbox: 2,
|
||||
panel: 10,
|
||||
avatar: 5,
|
||||
avatarAlt: 50,
|
||||
tooltip: 2,
|
||||
attachment: 5,
|
||||
chatMessage: inputRadii.panel
|
||||
})
|
||||
|
||||
return {
|
||||
rules: {
|
||||
radii: Object.entries(radii).filter(([k, v]) => v).map(([k, v]) => `--${k}Radius: ${v}px`).join(';')
|
||||
},
|
||||
theme: {
|
||||
radii
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const generateFonts = (input) => {
|
||||
const fonts = Object.entries(input.fonts || {}).filter(([k, v]) => v).reduce((acc, [k, v]) => {
|
||||
acc[k] = Object.entries(v).filter(([k, v]) => v).reduce((acc, [k, v]) => {
|
||||
acc[k] = v
|
||||
return acc
|
||||
}, acc[k])
|
||||
return acc
|
||||
}, {
|
||||
interface: {
|
||||
family: 'sans-serif'
|
||||
},
|
||||
input: {
|
||||
family: 'inherit'
|
||||
},
|
||||
post: {
|
||||
family: 'inherit'
|
||||
},
|
||||
postCode: {
|
||||
family: 'monospace'
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
rules: {
|
||||
fonts: Object
|
||||
.entries(fonts)
|
||||
.filter(([k, v]) => v)
|
||||
.map(([k, v]) => `--${k}Font: ${v.family}`).join(';')
|
||||
},
|
||||
theme: {
|
||||
fonts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const border = (top, shadow) => ({
|
||||
x: 0,
|
||||
y: top ? 1 : -1,
|
||||
blur: 0,
|
||||
spread: 0,
|
||||
color: shadow ? '#000000' : '#FFFFFF',
|
||||
alpha: 0.2,
|
||||
inset: true
|
||||
})
|
||||
const buttonInsetFakeBorders = [border(true, false), border(false, true)]
|
||||
const inputInsetFakeBorders = [border(true, true), border(false, false)]
|
||||
const hoverGlow = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
blur: 4,
|
||||
spread: 0,
|
||||
color: '--faint',
|
||||
alpha: 1
|
||||
}
|
||||
|
||||
export const DEFAULT_SHADOWS = {
|
||||
panel: [{
|
||||
x: 1,
|
||||
y: 1,
|
||||
blur: 4,
|
||||
spread: 0,
|
||||
color: '#000000',
|
||||
alpha: 0.6
|
||||
}],
|
||||
topBar: [{
|
||||
x: 0,
|
||||
y: 0,
|
||||
blur: 4,
|
||||
spread: 0,
|
||||
color: '#000000',
|
||||
alpha: 0.6
|
||||
}],
|
||||
popup: [{
|
||||
x: 2,
|
||||
y: 2,
|
||||
blur: 3,
|
||||
spread: 0,
|
||||
color: '#000000',
|
||||
alpha: 0.5
|
||||
}],
|
||||
avatar: [{
|
||||
x: 0,
|
||||
y: 1,
|
||||
blur: 8,
|
||||
spread: 0,
|
||||
color: '#000000',
|
||||
alpha: 0.7
|
||||
}],
|
||||
avatarStatus: [],
|
||||
panelHeader: [],
|
||||
button: [{
|
||||
x: 0,
|
||||
y: 0,
|
||||
blur: 2,
|
||||
spread: 0,
|
||||
color: '#000000',
|
||||
alpha: 1
|
||||
}, ...buttonInsetFakeBorders],
|
||||
buttonHover: [hoverGlow, ...buttonInsetFakeBorders],
|
||||
buttonPressed: [hoverGlow, ...inputInsetFakeBorders],
|
||||
input: [...inputInsetFakeBorders, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
blur: 2,
|
||||
inset: true,
|
||||
spread: 0,
|
||||
color: '#000000',
|
||||
alpha: 1
|
||||
}]
|
||||
}
|
||||
export const generateShadows = (input, colors) => {
|
||||
// TODO this is a small hack for `mod` to work with shadows
|
||||
// this is used to get the "context" of shadow, i.e. for `mod` properly depend on background color of element
|
||||
const hackContextDict = {
|
||||
button: 'btn',
|
||||
panel: 'bg',
|
||||
top: 'topBar',
|
||||
popup: 'popover',
|
||||
avatar: 'bg',
|
||||
panelHeader: 'panel',
|
||||
input: 'input'
|
||||
}
|
||||
|
||||
const cleanInputShadows = Object.fromEntries(
|
||||
Object.entries(input.shadows || {})
|
||||
.map(([name, shadowSlot]) => [
|
||||
name,
|
||||
// defaulting color to black to avoid potential problems
|
||||
shadowSlot.map(shadowDef => ({ color: '#000000', ...shadowDef }))
|
||||
])
|
||||
)
|
||||
const inputShadows = cleanInputShadows && !input.themeEngineVersion
|
||||
? shadows2to3(cleanInputShadows, input.opacity)
|
||||
: cleanInputShadows || {}
|
||||
const shadows = Object.entries({
|
||||
...DEFAULT_SHADOWS,
|
||||
...inputShadows
|
||||
}).reduce((shadowsAcc, [slotName, shadowDefs]) => {
|
||||
const slotFirstWord = slotName.replace(/[A-Z].*$/, '')
|
||||
const colorSlotName = hackContextDict[slotFirstWord]
|
||||
const isLightOnDark = relativeLuminance(convert(colors[colorSlotName]).rgb) < 0.5
|
||||
const mod = isLightOnDark ? 1 : -1
|
||||
const newShadow = shadowDefs.reduce((shadowAcc, def) => [
|
||||
...shadowAcc,
|
||||
{
|
||||
...def,
|
||||
color: rgb2hex(computeDynamicColor(
|
||||
def.color,
|
||||
(variableSlot) => convert(colors[variableSlot]).rgb,
|
||||
mod
|
||||
))
|
||||
}
|
||||
], [])
|
||||
return { ...shadowsAcc, [slotName]: newShadow }
|
||||
}, {})
|
||||
|
||||
return {
|
||||
rules: {
|
||||
shadows: Object
|
||||
.entries(shadows)
|
||||
// TODO for v2.2: if shadow doesn't have non-inset shadows with spread > 0 - optionally
|
||||
// convert all non-inset shadows into filter: drop-shadow() to boost performance
|
||||
.map(([k, v]) => [
|
||||
`--${k}Shadow: ${getCssShadow(v)}`,
|
||||
`--${k}ShadowFilter: ${getCssShadowFilter(v)}`,
|
||||
`--${k}ShadowInset: ${getCssShadow(v, true)}`
|
||||
].join(';'))
|
||||
.join(';')
|
||||
},
|
||||
theme: {
|
||||
shadows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const composePreset = (colors, radii, shadows, fonts) => {
|
||||
return {
|
||||
rules: {
|
||||
...shadows.rules,
|
||||
...colors.rules,
|
||||
...radii.rules,
|
||||
...fonts.rules
|
||||
},
|
||||
theme: {
|
||||
...shadows.theme,
|
||||
...colors.theme,
|
||||
...radii.theme,
|
||||
...fonts.theme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const generatePreset = (input) => {
|
||||
const colors = generateColors(input)
|
||||
return composePreset(
|
||||
colors,
|
||||
generateRadii(input),
|
||||
generateShadows(input, colors.theme.colors, colors.mod),
|
||||
generateFonts(input)
|
||||
)
|
||||
}
|
||||
|
||||
export const getThemes = () => {
|
||||
export const getResourcesIndex = async (url, parser = JSON.parse) => {
|
||||
const cache = 'no-store'
|
||||
const customUrl = url.replace(/\.(\w+)$/, '.custom.$1')
|
||||
let builtin
|
||||
let custom
|
||||
|
||||
return window.fetch('/static/styles.json', { cache })
|
||||
.then((data) => data.json())
|
||||
.then((themes) => {
|
||||
return Object.entries(themes).map(([k, v]) => {
|
||||
let promise = null
|
||||
const resourceTransform = (resources) => {
|
||||
return Object
|
||||
.entries(resources)
|
||||
.map(([k, v]) => {
|
||||
if (typeof v === 'object') {
|
||||
promise = Promise.resolve(v)
|
||||
return [k, () => Promise.resolve(v)]
|
||||
} else if (typeof v === 'string') {
|
||||
promise = window.fetch(v, { cache })
|
||||
.then((data) => data.json())
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
return null
|
||||
})
|
||||
return [
|
||||
k,
|
||||
() => window
|
||||
.fetch(v, { cache })
|
||||
.then(data => data.text())
|
||||
.then(text => parser(text))
|
||||
.catch(e => {
|
||||
console.error(e)
|
||||
return null
|
||||
})
|
||||
]
|
||||
} else {
|
||||
console.error(`Unknown resource format - ${k} is a ${typeof v}`)
|
||||
return [k, null]
|
||||
}
|
||||
return [k, promise]
|
||||
})
|
||||
})
|
||||
.then((promises) => {
|
||||
return promises
|
||||
.reduce((acc, [k, v]) => {
|
||||
acc[k] = v
|
||||
return acc
|
||||
}, {})
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const builtinData = await window.fetch(url, { cache })
|
||||
const builtinResources = await builtinData.json()
|
||||
builtin = resourceTransform(builtinResources)
|
||||
} catch (e) {
|
||||
builtin = []
|
||||
console.warn(`Builtin resources at ${url} unavailable`)
|
||||
}
|
||||
|
||||
try {
|
||||
const customData = await window.fetch(customUrl, { cache })
|
||||
const customResources = await customData.json()
|
||||
custom = resourceTransform(customResources)
|
||||
} catch (e) {
|
||||
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`))
|
||||
}
|
||||
return Promise.resolve(Object.fromEntries(total))
|
||||
}
|
||||
export const colors2to3 = (colors) => {
|
||||
return Object.entries(colors).reduce((acc, [slotName, color]) => {
|
||||
const btnPositions = ['', 'Panel', 'TopBar']
|
||||
switch (slotName) {
|
||||
case 'lightBg':
|
||||
return { ...acc, highlight: color }
|
||||
case 'btnText':
|
||||
return {
|
||||
...acc,
|
||||
...btnPositions
|
||||
.reduce(
|
||||
(statePositionAcc, position) =>
|
||||
({ ...statePositionAcc, ['btn' + position + 'Text']: color })
|
||||
, {}
|
||||
)
|
||||
}
|
||||
default:
|
||||
return { ...acc, [slotName]: color }
|
||||
}
|
||||
}, {})
|
||||
}
|
||||
|
||||
/**
|
||||
* This handles compatibility issues when importing v2 theme's shadows to current format
|
||||
*
|
||||
* Back in v2 shadows allowed you to use dynamic colors however those used pure CSS3 variables
|
||||
*/
|
||||
export const shadows2to3 = (shadows, opacity) => {
|
||||
return Object.entries(shadows).reduce((shadowsAcc, [slotName, shadowDefs]) => {
|
||||
const isDynamic = ({ color = '#000000' }) => color.startsWith('--')
|
||||
const getOpacity = ({ color }) => opacity[getOpacitySlot(color.substring(2).split(',')[0])]
|
||||
const newShadow = shadowDefs.reduce((shadowAcc, def) => [
|
||||
...shadowAcc,
|
||||
{
|
||||
...def,
|
||||
alpha: isDynamic(def) ? getOpacity(def) || 1 : def.alpha
|
||||
}
|
||||
], [])
|
||||
return { ...shadowsAcc, [slotName]: newShadow }
|
||||
}, {})
|
||||
}
|
||||
|
||||
export const getPreset = (val) => {
|
||||
return getThemes()
|
||||
.then((themes) => themes[val] ? themes[val] : themes['pleroma-dark'])
|
||||
.then((theme) => {
|
||||
const isV1 = Array.isArray(theme)
|
||||
const data = isV1 ? {} : theme.theme
|
||||
|
||||
if (isV1) {
|
||||
const bg = hex2rgb(theme[1])
|
||||
const fg = hex2rgb(theme[2])
|
||||
const text = hex2rgb(theme[3])
|
||||
const link = hex2rgb(theme[4])
|
||||
|
||||
const cRed = hex2rgb(theme[5] || '#FF0000')
|
||||
const cGreen = hex2rgb(theme[6] || '#00FF00')
|
||||
const cBlue = hex2rgb(theme[7] || '#0000FF')
|
||||
const cOrange = hex2rgb(theme[8] || '#E3FF00')
|
||||
|
||||
data.colors = { bg, fg, text, link, cRed, cBlue, cGreen, cOrange }
|
||||
}
|
||||
|
||||
return { theme: data, source: theme.source }
|
||||
})
|
||||
}
|
||||
|
||||
export const setPreset = (val) => getPreset(val).then(data => applyTheme(data.theme))
|
||||
|
|
|
|||
|
|
@ -10,8 +10,12 @@ function urlBase64ToUint8Array (base64String) {
|
|||
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
|
||||
}
|
||||
|
||||
export function isSWSupported () {
|
||||
return 'serviceWorker' in navigator
|
||||
}
|
||||
|
||||
function isPushSupported () {
|
||||
return 'serviceWorker' in navigator && 'PushManager' in window
|
||||
return 'PushManager' in window
|
||||
}
|
||||
|
||||
function getOrCreateServiceWorker () {
|
||||
|
|
@ -24,7 +28,7 @@ function subscribePush (registration, isEnabled, vapidPublicKey) {
|
|||
if (!vapidPublicKey) return Promise.reject(new Error('VAPID public key is not found'))
|
||||
|
||||
const subscribeOptions = {
|
||||
userVisibleOnly: true,
|
||||
userVisibleOnly: false,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
|
||||
}
|
||||
return registration.pushManager.subscribe(subscribeOptions)
|
||||
|
|
@ -32,14 +36,14 @@ function subscribePush (registration, isEnabled, vapidPublicKey) {
|
|||
|
||||
function unsubscribePush (registration) {
|
||||
return registration.pushManager.getSubscription()
|
||||
.then((subscribtion) => {
|
||||
if (subscribtion === null) { return }
|
||||
return subscribtion.unsubscribe()
|
||||
.then((subscription) => {
|
||||
if (subscription === null) { return }
|
||||
return subscription.unsubscribe()
|
||||
})
|
||||
}
|
||||
|
||||
function deleteSubscriptionFromBackEnd (token) {
|
||||
return window.fetch('/api/v1/push/subscription/', {
|
||||
return fetch('/api/v1/push/subscription/', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -78,6 +82,44 @@ function sendSubscriptionToBackEnd (subscription, token, notificationVisibility)
|
|||
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()) {
|
||||
|
|
@ -98,13 +140,8 @@ export function unregisterPushNotifications (token) {
|
|||
})
|
||||
.then(([registration, unsubResult]) => {
|
||||
if (!unsubResult) {
|
||||
console.warn('Push subscription cancellation wasn\'t successful, killing SW anyway...')
|
||||
console.warn('Push subscription cancellation wasn\'t successful')
|
||||
}
|
||||
return registration.unregister().then((result) => {
|
||||
if (!result) {
|
||||
console.warn('Failed to kill SW')
|
||||
}
|
||||
})
|
||||
})
|
||||
]).catch((e) => console.warn(`Failed to disable Web Push Notifications: ${e.message}`))
|
||||
}
|
||||
156
src/services/theme_data/css_utils.js
Normal file
156
src/services/theme_data/css_utils.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
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(x => x).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(x => x).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(x => x).map(x => ' ' + x + ';').join('\n')
|
||||
|
||||
return [
|
||||
header,
|
||||
directives,
|
||||
(rule.component === 'Text' && rule.state.indexOf('faint') < 0 && rule.directives.textNoCssColor !== 'yes') ? ' color: var(--text);' : '',
|
||||
virtualDirectives,
|
||||
footer
|
||||
].filter(x => x).join('\n')
|
||||
}).filter(x => x)
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
170
src/services/theme_data/iss_deserializer.js
Normal file
170
src/services/theme_data/iss_deserializer.js
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { flattenDeep } from 'lodash'
|
||||
|
||||
export const deserializeShadow = string => {
|
||||
const modes = ['_full', 'inset', 'x', 'y', 'blur', 'spread', 'color', 'alpha', 'name']
|
||||
const regexPrep = [
|
||||
// inset keyword (optional)
|
||||
'^',
|
||||
'(?:(inset)\\s+)?',
|
||||
// x
|
||||
'(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)',
|
||||
// y
|
||||
'(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)',
|
||||
// blur (optional)
|
||||
'(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)?',
|
||||
// spread (optional)
|
||||
'(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)?',
|
||||
// either hex, variable or function
|
||||
'(#[0-9a-f]{6}|--[a-z0-9\\-_]+|\\$[a-z0-9\\-()_ ]+)',
|
||||
// opacity (optional)
|
||||
'(?:\\s+\\/\\s+([0-9]+(?:\\.[0-9]+)?)\\s*)?',
|
||||
// name
|
||||
'(?:\\s+#(\\w+)\\s*)?',
|
||||
'$'
|
||||
].join('')
|
||||
const regex = new RegExp(regexPrep, 'gis') // global, (stable) indices, single-string
|
||||
const result = regex.exec(string)
|
||||
if (result == null) {
|
||||
if (string.startsWith('$') || string.startsWith('--')) {
|
||||
return string
|
||||
} else {
|
||||
throw new Error(`Invalid shadow definition: '${string}'`)
|
||||
}
|
||||
} else {
|
||||
const numeric = new Set(['x', 'y', 'blur', 'spread', 'alpha'])
|
||||
const { x, y, blur, spread, alpha, inset, color, name } = Object.fromEntries(modes.map((mode, i) => {
|
||||
if (numeric.has(mode)) {
|
||||
const number = Number(result[i])
|
||||
if (Number.isNaN(number)) {
|
||||
if (mode === 'alpha') return [mode, 1]
|
||||
return [mode, 0]
|
||||
}
|
||||
return [mode, number]
|
||||
} else if (mode === 'inset') {
|
||||
return [mode, !!result[i]]
|
||||
} else {
|
||||
return [mode, result[i]]
|
||||
}
|
||||
}).filter(([k, v]) => v !== false).slice(1))
|
||||
|
||||
return { x, y, blur, spread, color, alpha, inset, name }
|
||||
}
|
||||
}
|
||||
// this works nearly the same as HTML tree converter
|
||||
const parseIss = (input) => {
|
||||
const buffer = [{ selector: null, content: [] }]
|
||||
let textBuffer = ''
|
||||
|
||||
const getCurrentBuffer = () => {
|
||||
let current = buffer[buffer.length - 1]
|
||||
if (current == null) {
|
||||
current = { selector: null, content: [] }
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// Processes current line buffer, adds it to output buffer and clears line buffer
|
||||
const flushText = (kind) => {
|
||||
if (textBuffer === '') return
|
||||
if (kind === 'content') {
|
||||
getCurrentBuffer().content.push(textBuffer.trim())
|
||||
} else {
|
||||
getCurrentBuffer().selector = textBuffer.trim()
|
||||
}
|
||||
textBuffer = ''
|
||||
}
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input[i]
|
||||
|
||||
if (char === ';') {
|
||||
flushText('content')
|
||||
} else if (char === '{') {
|
||||
flushText('header')
|
||||
} else if (char === '}') {
|
||||
flushText('content')
|
||||
buffer.push({ selector: null, content: [] })
|
||||
textBuffer = ''
|
||||
} else {
|
||||
textBuffer += char
|
||||
}
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
export const deserialize = (input) => {
|
||||
const ast = parseIss(input)
|
||||
const finalResult = ast.filter(i => i.selector != null).map(item => {
|
||||
const { selector, content } = item
|
||||
let stateCount = 0
|
||||
const selectors = selector.split(/,/g)
|
||||
const result = selectors.map(selector => {
|
||||
const output = { component: '' }
|
||||
let currentDepth = null
|
||||
|
||||
selector.split(/ /g).reverse().forEach((fragment, index, arr) => {
|
||||
const fragmentObject = { component: '' }
|
||||
|
||||
let mode = 'component'
|
||||
for (let i = 0; i < fragment.length; i++) {
|
||||
const char = fragment[i]
|
||||
switch (char) {
|
||||
case '.': {
|
||||
mode = 'variant'
|
||||
fragmentObject.variant = ''
|
||||
break
|
||||
}
|
||||
case ':': {
|
||||
mode = 'state'
|
||||
fragmentObject.state = fragmentObject.state || []
|
||||
stateCount++
|
||||
break
|
||||
}
|
||||
default: {
|
||||
if (mode === 'state') {
|
||||
const currentState = fragmentObject.state[stateCount - 1]
|
||||
if (currentState == null) {
|
||||
fragmentObject.state.push('')
|
||||
}
|
||||
fragmentObject.state[stateCount - 1] += char
|
||||
} else {
|
||||
fragmentObject[mode] += char
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentDepth !== null) {
|
||||
currentDepth.parent = { ...fragmentObject }
|
||||
currentDepth = currentDepth.parent
|
||||
} else {
|
||||
Object.keys(fragmentObject).forEach(key => {
|
||||
output[key] = fragmentObject[key]
|
||||
})
|
||||
if (index !== (arr.length - 1)) {
|
||||
output.parent = { component: '' }
|
||||
}
|
||||
currentDepth = output
|
||||
}
|
||||
})
|
||||
|
||||
output.directives = Object.fromEntries(content.map(d => {
|
||||
const [property, value] = d.split(':')
|
||||
let realValue = (value || '').trim()
|
||||
if (property === 'shadow') {
|
||||
if (realValue === 'none') {
|
||||
realValue = []
|
||||
} else {
|
||||
realValue = value.split(',').map(v => deserializeShadow(v.trim()))
|
||||
}
|
||||
} if (!Number.isNaN(Number(value))) {
|
||||
realValue = Number(value)
|
||||
}
|
||||
return [property, realValue]
|
||||
}))
|
||||
|
||||
return output
|
||||
})
|
||||
return result
|
||||
})
|
||||
return flattenDeep(finalResult)
|
||||
}
|
||||
53
src/services/theme_data/iss_serializer.js
Normal file
53
src/services/theme_data/iss_serializer.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { unroll } from './iss_utils.js'
|
||||
import { deserializeShadow } from './iss_deserializer.js'
|
||||
|
||||
export const serializeShadow = (s, throwOnInvalid) => {
|
||||
if (typeof s === 'object') {
|
||||
const inset = s.inset ? 'inset ' : ''
|
||||
const name = s.name ? ` #${s.name} ` : ''
|
||||
const result = `${inset}${s.x} ${s.y} ${s.blur} ${s.spread} ${s.color} / ${s.alpha}${name}`
|
||||
deserializeShadow(result) // Verify that output is valid and parseable
|
||||
return result
|
||||
} else {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
export const serialize = (ruleset) => {
|
||||
return ruleset.map((rule) => {
|
||||
if (Object.keys(rule.directives || {}).length === 0) return false
|
||||
|
||||
const header = unroll(rule).reverse().map(rule => {
|
||||
const { component } = rule
|
||||
const newVariant = (rule.variant == null || rule.variant === 'normal') ? '' : ('.' + rule.variant)
|
||||
const newState = (rule.state || []).filter(st => st !== 'normal')
|
||||
|
||||
return `${component}${newVariant}${newState.map(st => ':' + st).join('')}`
|
||||
}).join(' ')
|
||||
|
||||
const content = Object.entries(rule.directives).map(([directive, value]) => {
|
||||
if (directive.startsWith('--')) {
|
||||
const [valType, newValue] = value.split('|') // only first one! intentional!
|
||||
switch (valType) {
|
||||
case 'shadow':
|
||||
return ` ${directive}: ${valType.trim()} | ${newValue.map(serializeShadow).map(s => s.trim()).join(', ')}`
|
||||
default:
|
||||
return ` ${directive}: ${valType.trim()} | ${newValue.trim()}`
|
||||
}
|
||||
} else {
|
||||
switch (directive) {
|
||||
case 'shadow':
|
||||
if (value.length > 0) {
|
||||
return ` ${directive}: ${value.map(serializeShadow).join(', ')}`
|
||||
} else {
|
||||
return ` ${directive}: none`
|
||||
}
|
||||
default:
|
||||
return ` ${directive}: ${value}`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return `${header} {\n${content.join(';\n')}\n}`
|
||||
}).filter(x => x).join('\n\n')
|
||||
}
|
||||
199
src/services/theme_data/iss_utils.js
Normal file
199
src/services/theme_data/iss_utils.js
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
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 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], [])
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]]
|
||||
arraySelector
|
||||
.sort((a, b) => {
|
||||
if (a.startsWith(':')) return 1
|
||||
if (/^[a-z]/.exec(a)) return -1
|
||||
else return 0
|
||||
})
|
||||
.join('')
|
||||
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 || [])])]
|
||||
}
|
||||
2
src/services/theme_data/pleromafe.t3.js
Normal file
2
src/services/theme_data/pleromafe.t3.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const sampleRules = [
|
||||
]
|
||||
177
src/services/theme_data/theme2_keys.js
Normal file
177
src/services/theme_data/theme2_keys.js
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
export default [
|
||||
'bg',
|
||||
'wallpaper',
|
||||
'fg',
|
||||
'text',
|
||||
'underlay',
|
||||
'link',
|
||||
'accent',
|
||||
'faint',
|
||||
'faintLink',
|
||||
'postFaintLink',
|
||||
|
||||
'cBlue',
|
||||
'cRed',
|
||||
'cGreen',
|
||||
'cOrange',
|
||||
|
||||
'profileBg',
|
||||
'profileTint',
|
||||
|
||||
'highlight',
|
||||
'highlightLightText',
|
||||
'highlightPostLink',
|
||||
'highlightFaintText',
|
||||
'highlightFaintLink',
|
||||
'highlightPostFaintLink',
|
||||
'highlightText',
|
||||
'highlightLink',
|
||||
'highlightIcon',
|
||||
|
||||
'popover',
|
||||
'popoverLightText',
|
||||
'popoverPostLink',
|
||||
'popoverFaintText',
|
||||
'popoverFaintLink',
|
||||
'popoverPostFaintLink',
|
||||
'popoverText',
|
||||
'popoverLink',
|
||||
'popoverIcon',
|
||||
|
||||
'selectedPost',
|
||||
'selectedPostFaintText',
|
||||
'selectedPostLightText',
|
||||
'selectedPostPostLink',
|
||||
'selectedPostFaintLink',
|
||||
'selectedPostText',
|
||||
'selectedPostLink',
|
||||
'selectedPostIcon',
|
||||
|
||||
'selectedMenu',
|
||||
'selectedMenuLightText',
|
||||
'selectedMenuFaintText',
|
||||
'selectedMenuFaintLink',
|
||||
'selectedMenuText',
|
||||
'selectedMenuLink',
|
||||
'selectedMenuIcon',
|
||||
|
||||
'selectedMenuPopover',
|
||||
'selectedMenuPopoverLightText',
|
||||
'selectedMenuPopoverFaintText',
|
||||
'selectedMenuPopoverFaintLink',
|
||||
'selectedMenuPopoverText',
|
||||
'selectedMenuPopoverLink',
|
||||
'selectedMenuPopoverIcon',
|
||||
|
||||
'lightText',
|
||||
|
||||
'postLink',
|
||||
|
||||
'postGreentext',
|
||||
|
||||
'postCyantext',
|
||||
|
||||
'border',
|
||||
|
||||
'poll',
|
||||
'pollText',
|
||||
|
||||
'icon',
|
||||
|
||||
// Foreground,
|
||||
'fgText',
|
||||
'fgLink',
|
||||
|
||||
// Panel header,
|
||||
'panel',
|
||||
'panelText',
|
||||
'panelFaint',
|
||||
'panelLink',
|
||||
|
||||
// Top bar,
|
||||
'topBar',
|
||||
'topBarText',
|
||||
'topBarLink',
|
||||
|
||||
// Tabs,
|
||||
'tab',
|
||||
'tabText',
|
||||
'tabActiveText',
|
||||
|
||||
// Buttons,
|
||||
'btn',
|
||||
'btnText',
|
||||
'btnPanelText',
|
||||
'btnTopBarText',
|
||||
|
||||
// Buttons: pressed,
|
||||
'btnPressed',
|
||||
'btnPressedText',
|
||||
'btnPressedPanel',
|
||||
'btnPressedPanelText',
|
||||
'btnPressedTopBar',
|
||||
'btnPressedTopBarText',
|
||||
|
||||
// Buttons: toggled,
|
||||
'btnToggled',
|
||||
'btnToggledText',
|
||||
'btnToggledPanelText',
|
||||
'btnToggledTopBarText',
|
||||
|
||||
// Buttons: disabled,
|
||||
'btnDisabled',
|
||||
'btnDisabledText',
|
||||
'btnDisabledPanelText',
|
||||
'btnDisabledTopBarText',
|
||||
|
||||
// Input fields,
|
||||
'input',
|
||||
'inputText',
|
||||
'inputPanelText',
|
||||
'inputTopbarText',
|
||||
|
||||
'alertError',
|
||||
'alertErrorText',
|
||||
'alertErrorPanelText',
|
||||
|
||||
'alertWarning',
|
||||
'alertWarningText',
|
||||
'alertWarningPanelText',
|
||||
|
||||
'alertSuccess',
|
||||
'alertSuccessText',
|
||||
'alertSuccessPanelText',
|
||||
|
||||
'alertNeutral',
|
||||
'alertNeutralText',
|
||||
'alertNeutralPanelText',
|
||||
|
||||
'alertPopupError',
|
||||
'alertPopupErrorText',
|
||||
|
||||
'alertPopupWarning',
|
||||
'alertPopupWarningText',
|
||||
|
||||
'alertPopupSuccess',
|
||||
'alertPopupSuccessText',
|
||||
|
||||
'alertPopupNeutral',
|
||||
'alertPopupNeutralText',
|
||||
|
||||
'badgeNeutral',
|
||||
'badgeNeutralText',
|
||||
|
||||
'badgeNotification',
|
||||
'badgeNotificationText',
|
||||
|
||||
'chatBg',
|
||||
|
||||
'chatMessageIncomingBg',
|
||||
'chatMessageIncomingText',
|
||||
'chatMessageIncomingLink',
|
||||
'chatMessageIncomingBorder',
|
||||
'chatMessageOutgoingBg',
|
||||
'chatMessageOutgoingText',
|
||||
'chatMessageOutgoingLink',
|
||||
'chatMessageOutgoingBorder'
|
||||
]
|
||||
534
src/services/theme_data/theme2_to_theme3.js
Normal file
534
src/services/theme_data/theme2_to_theme3.js
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
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 } })
|
||||
}
|
||||
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 'attachment':
|
||||
rule.component = 'Attachment'
|
||||
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' })
|
||||
}
|
||||
})
|
||||
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].family
|
||||
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'] })
|
||||
}
|
||||
|
||||
if (rule.component === 'Button') {
|
||||
newRules.push({ ...rule, component: 'ScrollbarElement' })
|
||||
newRules.push({ ...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?.indexOf('hover') >= 0) {
|
||||
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(x => x).reduce((acc, x) => [...acc, ...x], []).filter(x => x).reduce((acc, x) => [...acc, ...x], [])
|
||||
|
||||
return [generateRoot(), ...convertShadows(), ...convertRadii(), ...convertOpacity(), ...convertFonts(), ...flatExtRules]
|
||||
}
|
||||
169
src/services/theme_data/theme3_slot_functions.js
Normal file
169
src/services/theme_data/theme3_slot_functions.js
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { convert, brightness } from 'chromatism'
|
||||
import { alphaBlend, getTextColor, relativeLuminance } from '../color_convert/color_convert.js'
|
||||
|
||||
export const process = (text, functions, { findColor, findShadow }, { dynamicVars, staticVars }) => {
|
||||
const { funcName, argsString } = /\$(?<funcName>\w+)\((?<argsString>[#a-zA-Z0-9-,.'"\s]*)\)/.exec(text).groups
|
||||
const args = argsString.split(/ /g).map(a => a.trim())
|
||||
|
||||
const func = functions[funcName]
|
||||
if (args.length < func.argsNeeded) {
|
||||
throw new Error(`$${funcName} requires at least ${func.argsNeeded} arguments, but ${args.length} were provided`)
|
||||
}
|
||||
return func.exec(args, { findColor, findShadow }, { dynamicVars, staticVars })
|
||||
}
|
||||
|
||||
export const colorFunctions = {
|
||||
alpha: {
|
||||
argsNeeded: 2,
|
||||
documentation: 'Changes alpha value of the color only to be used for CSS variables',
|
||||
args: [
|
||||
'color: source color used',
|
||||
'amount: alpha value'
|
||||
],
|
||||
exec: (args, { findColor }, { dynamicVars, staticVars }) => {
|
||||
const [color, amountArg] = args
|
||||
|
||||
const colorArg = convert(findColor(color, { dynamicVars, staticVars })).rgb
|
||||
const amount = Number(amountArg)
|
||||
return { ...colorArg, a: amount }
|
||||
}
|
||||
},
|
||||
brightness: {
|
||||
argsNeeded: 2,
|
||||
document: 'Changes brightness/lightness of color in HSL colorspace',
|
||||
args: [
|
||||
'color: source color used',
|
||||
'amount: lightness value'
|
||||
],
|
||||
exec: (args, { findColor }, { dynamicVars, staticVars }) => {
|
||||
const [color, amountArg] = args
|
||||
|
||||
const colorArg = convert(findColor(color, { dynamicVars, staticVars })).hsl
|
||||
colorArg.l += Number(amountArg)
|
||||
return { ...convert(colorArg).rgb }
|
||||
}
|
||||
},
|
||||
textColor: {
|
||||
argsNeeded: 2,
|
||||
documentation: 'Get text color with adequate contrast for given background and intended text color. Same function is used internally',
|
||||
args: [
|
||||
'background: color of backdrop where text will be shown',
|
||||
'foreground: intended text color',
|
||||
`[preserve]: (optional) intended color preservation:
|
||||
'preserve' - try to preserve the color
|
||||
'no-preserve' - if can't get adequate color - fall back to black or white
|
||||
'no-auto' - don't do anything (useless as a color function)`
|
||||
],
|
||||
exec: (args, { findColor }, { dynamicVars, staticVars }) => {
|
||||
const [backgroundArg, foregroundArg, preserve = 'preserve'] = args
|
||||
|
||||
const background = convert(findColor(backgroundArg, { dynamicVars, staticVars })).rgb
|
||||
const foreground = convert(findColor(foregroundArg, { dynamicVars, staticVars })).rgb
|
||||
|
||||
return getTextColor(background, foreground, preserve === 'preserve')
|
||||
}
|
||||
},
|
||||
blend: {
|
||||
argsNeeded: 3,
|
||||
documentation: 'Alpha blending between two colors',
|
||||
args: [
|
||||
'background: bottom layer color',
|
||||
'amount: opacity of top layer',
|
||||
'foreground: upper layer color'
|
||||
],
|
||||
exec: (args, { findColor }, { dynamicVars, staticVars }) => {
|
||||
const [backgroundArg, amountArg, foregroundArg] = args
|
||||
|
||||
const background = convert(findColor(backgroundArg, { dynamicVars, staticVars })).rgb
|
||||
const foreground = convert(findColor(foregroundArg, { dynamicVars, staticVars })).rgb
|
||||
const amount = Number(amountArg)
|
||||
|
||||
return alphaBlend(background, amount, foreground)
|
||||
}
|
||||
},
|
||||
boost: {
|
||||
argsNeeded: 2,
|
||||
documentation: 'If given color is dark makes it darker, if color is light - makes it lighter',
|
||||
args: [
|
||||
'color: source color',
|
||||
'amount: how much darken/brighten the color'
|
||||
],
|
||||
exec: (args, { findColor }, { dynamicVars, staticVars }) => {
|
||||
const [colorArg, amountArg] = args
|
||||
|
||||
const color = convert(findColor(colorArg, { dynamicVars, staticVars })).rgb
|
||||
const amount = Number(amountArg)
|
||||
|
||||
const isLight = relativeLuminance(color) < 0.5
|
||||
const mod = isLight ? -1 : 1
|
||||
return brightness(amount * mod, color).rgb
|
||||
}
|
||||
},
|
||||
mod: {
|
||||
argsNeeded: 2,
|
||||
documentation: 'Old function that increases or decreases brightness depending if background color is dark or light. Advised against using it as it might give unexpected results.',
|
||||
args: [
|
||||
'color: source color',
|
||||
'amount: how much darken/brighten the color'
|
||||
],
|
||||
exec: (args, { findColor }, { dynamicVars, staticVars }) => {
|
||||
const [colorArg, amountArg] = args
|
||||
|
||||
const color = convert(findColor(colorArg, { dynamicVars, staticVars })).rgb
|
||||
const amount = Number(amountArg)
|
||||
|
||||
const effectiveBackground = dynamicVars.lowerLevelBackground
|
||||
const isLightOnDark = relativeLuminance(convert(effectiveBackground).rgb) < 0.5
|
||||
const mod = isLightOnDark ? 1 : -1
|
||||
return brightness(amount * mod, color).rgb
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const shadowFunctions = {
|
||||
borderSide: {
|
||||
argsNeeded: 3,
|
||||
documentation: 'Simulate a border on a side with a shadow, best works on inset border',
|
||||
args: [
|
||||
'color: border color',
|
||||
'side: string indicating on which side border should be, takes either one word or two words joined by dash (i.e. "left" or "bottom-right")',
|
||||
'width: border width (thickness)',
|
||||
'[alpha]: (Optional) border opacity, defaults to 1 (fully opaque)',
|
||||
'[inset]: (Optional) whether border should be on the inside or outside, defaults to inside'
|
||||
],
|
||||
exec: (args, { findColor }) => {
|
||||
const [color, side, alpha = '1', widthArg = '1', inset = 'inset'] = args
|
||||
|
||||
const width = Number(widthArg)
|
||||
const isInset = inset === 'inset'
|
||||
|
||||
const targetShadow = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
blur: 0,
|
||||
spread: 0,
|
||||
color,
|
||||
alpha: Number(alpha),
|
||||
inset: isInset
|
||||
}
|
||||
|
||||
side.split('-').forEach((position) => {
|
||||
switch (position) {
|
||||
case 'left':
|
||||
targetShadow.x = width * (inset ? 1 : -1)
|
||||
break
|
||||
case 'right':
|
||||
targetShadow.x = -1 * width * (inset ? 1 : -1)
|
||||
break
|
||||
case 'top':
|
||||
targetShadow.y = width * (inset ? 1 : -1)
|
||||
break
|
||||
case 'bottom':
|
||||
targetShadow.y = -1 * width * (inset ? 1 : -1)
|
||||
break
|
||||
}
|
||||
})
|
||||
return [targetShadow]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { convert, brightness, contrastRatio } from 'chromatism'
|
||||
import { alphaBlendLayers, getTextColor, relativeLuminance } from '../color_convert/color_convert.js'
|
||||
import { rgb2hex, rgba2css, alphaBlendLayers, getTextColor, relativeLuminance, getCssColor } from '../color_convert/color_convert.js'
|
||||
import { LAYERS, DEFAULT_OPACITY, SLOT_INHERITANCE } from './pleromafe.js'
|
||||
|
||||
/*
|
||||
|
|
@ -117,7 +117,6 @@ export const topoSort = (
|
|||
// Put it into the output list
|
||||
output.push(node)
|
||||
} else if (grays.has(node)) {
|
||||
console.debug('Cyclic depenency in topoSort, ignoring')
|
||||
output.push(node)
|
||||
} else if (blacks.has(node)) {
|
||||
// do nothing
|
||||
|
|
@ -407,3 +406,347 @@ export const getColors = (sourceColors, sourceOpacity) => SLOT_ORDERED.reduce(({
|
|||
}
|
||||
}
|
||||
}, { colors: {}, opacity: {} })
|
||||
|
||||
export const composePreset = (colors, radii, shadows, fonts) => {
|
||||
return {
|
||||
rules: {
|
||||
...shadows.rules,
|
||||
...colors.rules,
|
||||
...radii.rules,
|
||||
...fonts.rules
|
||||
},
|
||||
theme: {
|
||||
...shadows.theme,
|
||||
...colors.theme,
|
||||
...radii.theme,
|
||||
...fonts.theme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const generatePreset = (input) => {
|
||||
const colors = generateColors(input)
|
||||
return composePreset(
|
||||
colors,
|
||||
generateRadii(input),
|
||||
generateShadows(input, colors.theme.colors, colors.mod),
|
||||
generateFonts(input)
|
||||
)
|
||||
}
|
||||
|
||||
export const getCssShadow = (input, usesDropShadow) => {
|
||||
if (input.length === 0) {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
return input
|
||||
.filter(_ => usesDropShadow ? _.inset : _)
|
||||
.map((shad) => [
|
||||
shad.x,
|
||||
shad.y,
|
||||
shad.blur,
|
||||
shad.spread
|
||||
].map(_ => _ + 'px').concat([
|
||||
getCssColor(shad.color, shad.alpha),
|
||||
shad.inset ? 'inset' : ''
|
||||
]).join(' ')).join(', ')
|
||||
}
|
||||
|
||||
export const getCssShadowFilter = (input) => {
|
||||
if (input.length === 0) {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
return input
|
||||
// drop-shadow doesn't support inset or spread
|
||||
.filter((shad) => !shad.inset && Number(shad.spread) === 0)
|
||||
.map((shad) => [
|
||||
shad.x,
|
||||
shad.y,
|
||||
// drop-shadow's blur is twice as strong compared to box-shadow
|
||||
shad.blur / 2
|
||||
].map(_ => _ + 'px').concat([
|
||||
getCssColor(shad.color, shad.alpha)
|
||||
]).join(' '))
|
||||
.map(_ => `drop-shadow(${_})`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export const generateColors = (themeData) => {
|
||||
const sourceColors = !themeData.themeEngineVersion
|
||||
? colors2to3(themeData.colors || themeData)
|
||||
: themeData.colors || themeData
|
||||
|
||||
const { colors, opacity } = getColors(sourceColors, themeData.opacity || {})
|
||||
|
||||
const htmlColors = Object.entries(colors)
|
||||
.reduce((acc, [k, v]) => {
|
||||
if (!v) return acc
|
||||
acc.solid[k] = rgb2hex(v)
|
||||
acc.complete[k] = typeof v.a === 'undefined' ? rgb2hex(v) : rgba2css(v)
|
||||
return acc
|
||||
}, { complete: {}, solid: {} })
|
||||
return {
|
||||
rules: {
|
||||
colors: Object.entries(htmlColors.complete)
|
||||
.filter(([k, v]) => v)
|
||||
.map(([k, v]) => `--${k}: ${v}`)
|
||||
.join(';')
|
||||
},
|
||||
theme: {
|
||||
colors: htmlColors.solid,
|
||||
opacity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const generateRadii = (input) => {
|
||||
let inputRadii = input.radii || {}
|
||||
// v1 -> v2
|
||||
if (typeof input.btnRadius !== 'undefined') {
|
||||
inputRadii = Object
|
||||
.entries(input)
|
||||
.filter(([k, v]) => k.endsWith('Radius'))
|
||||
.reduce((acc, e) => { acc[e[0].split('Radius')[0]] = e[1]; return acc }, {})
|
||||
}
|
||||
const radii = Object.entries(inputRadii).filter(([k, v]) => v).reduce((acc, [k, v]) => {
|
||||
acc[k] = v
|
||||
return acc
|
||||
}, {
|
||||
btn: 4,
|
||||
input: 4,
|
||||
checkbox: 2,
|
||||
panel: 10,
|
||||
avatar: 5,
|
||||
avatarAlt: 50,
|
||||
tooltip: 2,
|
||||
attachment: 5,
|
||||
chatMessage: inputRadii.panel
|
||||
})
|
||||
|
||||
return {
|
||||
rules: {
|
||||
radii: Object.entries(radii).filter(([k, v]) => v).map(([k, v]) => `--${k}Radius: ${v}px`).join(';')
|
||||
},
|
||||
theme: {
|
||||
radii
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const generateFonts = (input) => {
|
||||
const fonts = Object.entries(input.fonts || {}).filter(([k, v]) => v).reduce((acc, [k, v]) => {
|
||||
acc[k] = Object.entries(v).filter(([k, v]) => v).reduce((acc, [k, v]) => {
|
||||
acc[k] = v
|
||||
return acc
|
||||
}, acc[k])
|
||||
return acc
|
||||
}, {
|
||||
interface: {
|
||||
family: 'sans-serif'
|
||||
},
|
||||
input: {
|
||||
family: 'inherit'
|
||||
},
|
||||
post: {
|
||||
family: 'inherit'
|
||||
},
|
||||
postCode: {
|
||||
family: 'monospace'
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
rules: {
|
||||
fonts: Object
|
||||
.entries(fonts)
|
||||
.filter(([k, v]) => v)
|
||||
.map(([k, v]) => `--${k}Font: ${v.family}`).join(';')
|
||||
},
|
||||
theme: {
|
||||
fonts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const border = (top, shadow) => ({
|
||||
x: 0,
|
||||
y: top ? 1 : -1,
|
||||
blur: 0,
|
||||
spread: 0,
|
||||
color: shadow ? '#000000' : '#FFFFFF',
|
||||
alpha: 0.2,
|
||||
inset: true
|
||||
})
|
||||
const buttonInsetFakeBorders = [border(true, false), border(false, true)]
|
||||
const inputInsetFakeBorders = [border(true, true), border(false, false)]
|
||||
const hoverGlow = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
blur: 4,
|
||||
spread: 0,
|
||||
color: '--faint',
|
||||
alpha: 1
|
||||
}
|
||||
|
||||
export const DEFAULT_SHADOWS = {
|
||||
panel: [{
|
||||
x: 1,
|
||||
y: 1,
|
||||
blur: 4,
|
||||
spread: 0,
|
||||
color: '#000000',
|
||||
alpha: 0.6
|
||||
}],
|
||||
topBar: [{
|
||||
x: 0,
|
||||
y: 0,
|
||||
blur: 4,
|
||||
spread: 0,
|
||||
color: '#000000',
|
||||
alpha: 0.6
|
||||
}],
|
||||
popup: [{
|
||||
x: 2,
|
||||
y: 2,
|
||||
blur: 3,
|
||||
spread: 0,
|
||||
color: '#000000',
|
||||
alpha: 0.5
|
||||
}],
|
||||
avatar: [{
|
||||
x: 0,
|
||||
y: 1,
|
||||
blur: 8,
|
||||
spread: 0,
|
||||
color: '#000000',
|
||||
alpha: 0.7
|
||||
}],
|
||||
avatarStatus: [],
|
||||
panelHeader: [],
|
||||
button: [{
|
||||
x: 0,
|
||||
y: 0,
|
||||
blur: 2,
|
||||
spread: 0,
|
||||
color: '#000000',
|
||||
alpha: 1
|
||||
}, ...buttonInsetFakeBorders],
|
||||
buttonHover: [hoverGlow, ...buttonInsetFakeBorders],
|
||||
buttonPressed: [hoverGlow, ...inputInsetFakeBorders],
|
||||
input: [...inputInsetFakeBorders, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
blur: 2,
|
||||
inset: true,
|
||||
spread: 0,
|
||||
color: '#000000',
|
||||
alpha: 1
|
||||
}]
|
||||
}
|
||||
export const generateShadows = (input, colors) => {
|
||||
// TODO this is a small hack for `mod` to work with shadows
|
||||
// this is used to get the "context" of shadow, i.e. for `mod` properly depend on background color of element
|
||||
const hackContextDict = {
|
||||
button: 'btn',
|
||||
panel: 'bg',
|
||||
top: 'topBar',
|
||||
popup: 'popover',
|
||||
avatar: 'bg',
|
||||
panelHeader: 'panel',
|
||||
input: 'input'
|
||||
}
|
||||
|
||||
const cleanInputShadows = Object.fromEntries(
|
||||
Object.entries(input.shadows || {})
|
||||
.map(([name, shadowSlot]) => [
|
||||
name,
|
||||
// defaulting color to black to avoid potential problems
|
||||
shadowSlot.map(shadowDef => ({ color: '#000000', ...shadowDef }))
|
||||
])
|
||||
)
|
||||
const inputShadows = cleanInputShadows && !input.themeEngineVersion
|
||||
? shadows2to3(cleanInputShadows, input.opacity)
|
||||
: cleanInputShadows || {}
|
||||
const shadows = Object.entries({
|
||||
...DEFAULT_SHADOWS,
|
||||
...inputShadows
|
||||
}).reduce((shadowsAcc, [slotName, shadowDefs]) => {
|
||||
const slotFirstWord = slotName.replace(/[A-Z].*$/, '')
|
||||
const colorSlotName = hackContextDict[slotFirstWord]
|
||||
const isLightOnDark = relativeLuminance(convert(colors[colorSlotName]).rgb) < 0.5
|
||||
const mod = isLightOnDark ? 1 : -1
|
||||
const newShadow = shadowDefs.reduce((shadowAcc, def) => [
|
||||
...shadowAcc,
|
||||
{
|
||||
...def,
|
||||
color: rgb2hex(computeDynamicColor(
|
||||
def.color,
|
||||
(variableSlot) => convert(colors[variableSlot]).rgb,
|
||||
mod
|
||||
))
|
||||
}
|
||||
], [])
|
||||
return { ...shadowsAcc, [slotName]: newShadow }
|
||||
}, {})
|
||||
|
||||
return {
|
||||
rules: {
|
||||
shadows: Object
|
||||
.entries(shadows)
|
||||
// TODO for v2.2: if shadow doesn't have non-inset shadows with spread > 0 - optionally
|
||||
// convert all non-inset shadows into filter: drop-shadow() to boost performance
|
||||
.map(([k, v]) => [
|
||||
`--${k}Shadow: ${getCssShadow(v)}`,
|
||||
`--${k}ShadowFilter: ${getCssShadowFilter(v)}`,
|
||||
`--${k}ShadowInset: ${getCssShadow(v, true)}`
|
||||
].join(';'))
|
||||
.join(';')
|
||||
},
|
||||
theme: {
|
||||
shadows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This handles compatibility issues when importing v2 theme's shadows to current format
|
||||
*
|
||||
* Back in v2 shadows allowed you to use dynamic colors however those used pure CSS3 variables
|
||||
*/
|
||||
export const shadows2to3 = (shadows, opacity) => {
|
||||
return Object.entries(shadows).reduce((shadowsAcc, [slotName, shadowDefs]) => {
|
||||
const isDynamic = ({ color = '#000000' }) => color.startsWith('--')
|
||||
const getOpacity = ({ color }) => opacity[getOpacitySlot(color.substring(2).split(',')[0])]
|
||||
const newShadow = shadowDefs.reduce((shadowAcc, def) => [
|
||||
...shadowAcc,
|
||||
{
|
||||
...def,
|
||||
alpha: isDynamic(def) ? getOpacity(def) || 1 : def.alpha
|
||||
}
|
||||
], [])
|
||||
return { ...shadowsAcc, [slotName]: newShadow }
|
||||
}, {})
|
||||
}
|
||||
|
||||
export const colors2to3 = (colors) => {
|
||||
return Object.entries(colors).reduce((acc, [slotName, color]) => {
|
||||
const btnPositions = ['', 'Panel', 'TopBar']
|
||||
switch (slotName) {
|
||||
case 'lightBg':
|
||||
return { ...acc, highlight: color }
|
||||
case 'btnText':
|
||||
return {
|
||||
...acc,
|
||||
...btnPositions
|
||||
.reduce(
|
||||
(statePositionAcc, position) =>
|
||||
({ ...statePositionAcc, ['btn' + position + 'Text']: color })
|
||||
, {}
|
||||
)
|
||||
}
|
||||
default:
|
||||
return { ...acc, [slotName]: color }
|
||||
}
|
||||
}, {})
|
||||
}
|
||||
|
|
|
|||
575
src/services/theme_data/theme_data_3.service.js
Normal file
575
src/services/theme_data/theme_data_3.service.js
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
import { convert, brightness } from 'chromatism'
|
||||
import sum from 'hash-sum'
|
||||
import { flattenDeep, sortBy } from 'lodash'
|
||||
import {
|
||||
alphaBlend,
|
||||
getTextColor,
|
||||
rgba2css,
|
||||
mixrgb,
|
||||
relativeLuminance
|
||||
} from '../color_convert/color_convert.js'
|
||||
|
||||
import {
|
||||
colorFunctions,
|
||||
shadowFunctions,
|
||||
process
|
||||
} from './theme3_slot_functions.js'
|
||||
|
||||
import {
|
||||
unroll,
|
||||
getAllPossibleCombinations,
|
||||
genericRuleToSelector,
|
||||
normalizeCombination,
|
||||
findRules
|
||||
} from './iss_utils.js'
|
||||
import { deserializeShadow } from './iss_deserializer.js'
|
||||
|
||||
// Ensuring the order of components
|
||||
const components = {
|
||||
Root: null,
|
||||
Text: null,
|
||||
FunText: null,
|
||||
Link: null,
|
||||
Icon: null,
|
||||
Border: null,
|
||||
Panel: null,
|
||||
Chat: null,
|
||||
ChatMessage: 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 {
|
||||
switch (variableSlot) {
|
||||
case 'inheritedBackground':
|
||||
targetColor = convert(dynamicVars.inheritedBackground).rgb
|
||||
break
|
||||
case 'background':
|
||||
targetColor = convert(dynamicVars.background).rgb
|
||||
break
|
||||
default:
|
||||
targetColor = convert(staticVars[variableSlot]).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)
|
||||
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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
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 = require.context('src', true, /\.style.js(on)?$/)
|
||||
componentsContext.keys().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
|
||||
})
|
||||
|
||||
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], []),
|
||||
...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 nonEditableComponents = new Set(Object.values(components).filter(c => c.notEditable).map(c => c.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]
|
||||
if (lastVariantRule) {
|
||||
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, v]) => 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(x => x)
|
||||
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 {
|
||||
validInnerComponents = component.validInnerComponents || []
|
||||
}
|
||||
|
||||
// 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
|
||||
? [
|
||||
['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) {
|
||||
return !(
|
||||
combo.indexOf('hover') >= 0 ||
|
||||
combo.indexOf('focused') >= 0 ||
|
||||
combo.indexOf('pressed') >= 0
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
]
|
||||
|
||||
const stateVariantCombination = Object.keys(variants).map(variant => {
|
||||
return stateCombinations.map(state => ({ variant, state }))
|
||||
}).reduce((acc, x) => [...acc, ...x], [])
|
||||
|
||||
stateVariantCombination.forEach(combination => {
|
||||
combination.component = component.name
|
||||
combination.lazy = component.lazy || parent?.lazy
|
||||
combination.parent = parent
|
||||
if (!liteMode && combination.state.indexOf('hover') >= 0) {
|
||||
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(x => x)
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,8 @@ const fetchAndUpdate = ({
|
|||
showImmediately = false,
|
||||
userId = false,
|
||||
listId = false,
|
||||
statusId = false,
|
||||
bookmarkFolderId = false,
|
||||
tag = false,
|
||||
until,
|
||||
since
|
||||
|
|
@ -48,6 +50,8 @@ const fetchAndUpdate = ({
|
|||
|
||||
args.userId = userId
|
||||
args.listId = listId
|
||||
args.statusId = statusId
|
||||
args.bookmarkFolderId = bookmarkFolderId
|
||||
args.tag = tag
|
||||
args.withMuted = !hideMutedPosts
|
||||
if (loggedIn && ['friends', 'public', 'publicAndExternal'].includes(timeline)) {
|
||||
|
|
@ -79,15 +83,16 @@ const fetchAndUpdate = ({
|
|||
})
|
||||
}
|
||||
|
||||
const startFetching = ({ timeline = 'friends', credentials, store, userId = false, listId = false, tag = false }) => {
|
||||
const startFetching = ({ timeline = 'friends', credentials, store, userId = false, listId = false, statusId = false, bookmarkFolderId = false, tag = false }) => {
|
||||
const rootState = store.rootState || store.state
|
||||
const timelineData = rootState.statuses.timelines[camelCase(timeline)]
|
||||
const showImmediately = timelineData.visibleStatuses.length === 0
|
||||
timelineData.userId = userId
|
||||
timelineData.listId = listId
|
||||
fetchAndUpdate({ timeline, credentials, store, showImmediately, userId, listId, tag })
|
||||
timelineData.bookmarkFolderId = bookmarkFolderId
|
||||
fetchAndUpdate({ timeline, credentials, store, showImmediately, userId, listId, statusId, bookmarkFolderId, tag })
|
||||
const boundFetchAndUpdate = () =>
|
||||
fetchAndUpdate({ timeline, credentials, store, userId, listId, tag })
|
||||
fetchAndUpdate({ timeline, credentials, store, userId, listId, statusId, bookmarkFolderId, tag })
|
||||
return promiseInterval(boundFetchAndUpdate, 10000)
|
||||
}
|
||||
const timelineFetcher = {
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
|
||||
export const extractCommit = versionString => {
|
||||
const regex = /-g(\w+)/i
|
||||
const matches = versionString.match(regex)
|
||||
return matches ? matches[1] : ''
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
export const windowWidth = () =>
|
||||
window.innerWidth ||
|
||||
document.documentElement.clientWidth ||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue