Merge branch 'release/2.9.x' into 'master'

Release/2.9.x

See merge request pleroma/pleroma-fe!2248
This commit is contained in:
HJ 2025-08-31 13:02:47 +00:00
commit 0ecbae9675
206 changed files with 8195 additions and 5745 deletions

View file

@ -0,0 +1,8 @@
### Release checklist
* [ ] Bump version in `package.json`
* [ ] Compile a changelog with the `tools/collect-changelog` script
* [ ] Create an MR with an announcement to pleroma.social
#### post-merge
* [ ] Tag the release on the merge commit
* [ ] Make the tag into a Gitlab Release™
* [ ] Merge `master` into `develop` (in case the fixes are already in develop, use `git merge -s ours --no-commit` and manually merge the changelogs)

View file

@ -1,6 +1,5 @@
{
"extends": [
"stylelint-rscss/config",
"stylelint-config-standard",
"stylelint-config-recommended-scss",
"stylelint-config-html",
@ -8,20 +7,13 @@
],
"rules": {
"declaration-no-important": true,
"rscss/no-descendant-combinator": false,
"rscss/class-format": [
false,
{
"component": "pascal-case",
"variant": "^-[a-z]\\w+",
"element": "^[a-z]\\w+"
}
],
"selector-class-pattern": null,
"import-notation": null,
"custom-property-pattern": null,
"keyframes-name-pattern": null,
"scss/operator-no-newline-after": null,
"declaration-property-value-no-unknown": true,
"scss/declaration-property-value-no-unknown": true,
"declaration-block-no-redundant-longhand-properties": [
true,
{

View file

@ -2,6 +2,42 @@
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## 2.9.2
### Changed
- BREAKING: due to some internal technical changes logging into AdminFE through PleromaFE is no longer possible
- User card/profile got an overhaul
- Profile editing overhaul
- Visually combined subject and content fields in post form
- Moved post form's emoji button into input field
- Minor visual changes and fixes
- Clicking on fav/rt/emoji notifications' contents expands/collapses it
- Reduced time taken processing theme by half
- Splash screen only appears if loading takes more than 2 seconds
### Added
- Mutes received an update, adding support for regex, muting based on username and expiration time.
- Mutes are now synchronized across sessions
- Support for expiring mutes and blocks (if available)
- Clicking on emoji shows bigger version of it alongside with its shortcode
- Admins also are able to copy it into a local pack
- Added support for Akkoma and IceShrimp.NET backends
- Compatibility with stricter CSP (Akkoma backend)
- Added a way to upload new packs from a URL or ZIP file via the Admin Dashboard
- Unify show/hide content buttons
- Add support for detachable scrollTop button
- Option to left-align user bio
- Cache assets and emojis with service worker
- Indicate currently active V3 theme as a body element class
- Add arithmetic blend ISS function
### Fixed
- Display counter for status action buttons when they are in the menu
- Fix bookmark button alignment in the extra actions menu
- Instance favicons are no longer stretched
- A lot more scalable UI fixes
- Emoji picker now should work fine when emoji size is increased
## 2.8.0
### Changed
- BREAKING: static/img/nsfw.2958239.png is now static/img/nsfw.DepQPhG0.png, which may affect people who specify exactly this path as the cover image

View file

@ -11,6 +11,11 @@ const getSWMessagesAsText = async () => {
}
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)))
const swEnvName = 'virtual:pleroma-fe/service_worker_env'
const swEnvNameResolved = '\0' + swEnvName
const getDevSwEnv = () => `self.serviceWorkerOption = { assets: [] };`
const getProdSwEnv = ({ assets }) => `self.serviceWorkerOption = { assets: ${JSON.stringify(assets)} };`
export const devSwPlugin = ({
swSrc,
swDest,
@ -32,12 +37,16 @@ export const devSwPlugin = ({
const name = id.startsWith('/') ? id.slice(1) : id
if (name === swDest) {
return swFullSrc
} else if (name === swEnvName) {
return swEnvNameResolved
}
return null
},
async load (id) {
if (id === swFullSrc) {
return readFile(swFullSrc, 'utf-8')
} else if (id === swEnvNameResolved) {
return getDevSwEnv()
}
return null
},
@ -79,6 +88,21 @@ export const devSwPlugin = ({
contents: await getSWMessagesAsText()
}))
}
}, {
name: 'sw-env',
setup (b) {
b.onResolve(
{ filter: new RegExp('^' + swEnvName + '$') },
args => ({
path: args.path,
namespace: 'sw-env'
}))
b.onLoad(
{ filter: /.*/, namespace: 'sw-env' },
() => ({
contents: getDevSwEnv()
}))
}
}]
})
const text = res.outputFiles[0].text
@ -126,6 +150,30 @@ export const buildSwPlugin = ({
configFile: false
}
},
generateBundle: {
order: 'post',
sequential: true,
async handler (_, bundle) {
const assets = Object.keys(bundle)
.filter(name => !/\.map$/.test(name))
.map(name => '/' + name)
config.plugins.push({
name: 'build-sw-env-plugin',
resolveId (id) {
if (id === swEnvName) {
return swEnvNameResolved
}
return null
},
load (id) {
if (id === swEnvNameResolved) {
return getProdSwEnv({ assets })
}
return null
}
})
}
},
closeBundle: {
order: 'post',
sequential: true,

View file

@ -5,140 +5,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no">
<link rel="preload" href="/static/config.json" as="fetch" crossorigin />
<link rel="preload" href="/api/pleroma/frontend_configurations" as="fetch" crossorigin />
<link rel="preload" href="/nodeinfo/2.0.json" as="fetch" crossorigin />
<link rel="preload" href="/nodeinfo/2.1.json" as="fetch" crossorigin />
<link rel="preload" href="/api/v1/instance" as="fetch" crossorigin />
<link rel="preload" href="/static/pleromatan_apology_fox_small.webp" as="image" />
<!-- putting styles here to avoid having to wait for styles to load up -->
<style id="splashscreen">
#splash {
--scale: 1;
width: 100vw;
height: 100vh;
display: grid;
grid-template-rows: auto;
grid-template-columns: auto;
align-content: center;
align-items: center;
justify-content: center;
justify-items: center;
flex-direction: column;
background: #0f161e;
font-family: sans-serif;
color: #b9b9ba;
position: absolute;
z-index: 9999;
font-size: calc(1vw + 1vh + 1vmin);
}
#splash-credit {
position: absolute;
font-size: 14px;
bottom: 16px;
right: 16px;
}
#splash-container {
align-items: center;
}
#mascot-container {
display: flex;
align-items: flex-end;
justify-content: center;
perspective: 60em;
perspective-origin: 0 -15em;
transform-style: preserve-3d;
}
#mascot {
width: calc(10em * var(--scale));
height: calc(10em * var(--scale));
object-fit: contain;
object-position: bottom;
transform: translateZ(-2em);
}
#throbber {
display: grid;
width: calc(5em * 0.5 * var(--scale));
height: calc(8em * 0.5 * var(--scale));
margin-left: 4.1em;
z-index: 2;
grid-template-rows: repeat(8, 1fr);
grid-template-columns: repeat(5, 1fr);
grid-template-areas: "P P . L L"
"P P . L L"
"P P . L L"
"P P . L L"
"P P . . ."
"P P . . ."
"P P . E E"
"P P . E E";
--logoChunkSize: calc(2em * 0.5 * var(--scale))
}
.chunk {
background-color: #e2b188;
box-shadow: 0.01em 0.01em 0.1em 0 #e2b188;
}
#chunk-P {
grid-area: P;
border-top-left-radius: calc(var(--logoChunkSize) / 2);
}
#chunk-L {
grid-area: L;
border-bottom-right-radius: calc(var(--logoChunkSize) / 2);
}
#chunk-E {
grid-area: E;
border-bottom-right-radius: calc(var(--logoChunkSize) / 2);
}
#status {
margin-top: 1em;
line-height: 2;
width: 100%;
text-align: center;
}
#statusError {
display: none;
margin-top: 1em;
font-size: calc(1vw + 1vh + 1vmin);
line-height: 2;
width: 100%;
text-align: center;
}
#statusStack {
display: none;
margin-top: 1em;
font-size: calc((1vw + 1vh + 1vmin) / 2.5);
width: calc(100vw - 5em);
padding: 1em;
text-overflow: ellipsis;
overflow-x: hidden;
text-align: left;
line-height: 2;
}
@media (prefers-reduced-motion) {
#throbber {
animation: none !important;
}
}
</style>
<style id="pleroma-eager-styles" type="text/css"></style>
<style id="pleroma-lazy-styles" type="text/css"></style>
<link rel="stylesheet" id="splashscreen" href="/static/splash.css" />
<link rel="stylesheet" id="custom-styles-holder" type="text/css" href="/static/empty.css" />
<!--server-generated-meta-->
</head>
<body style="margin: 0; padding: 0">
<body>
<noscript>To use Pleroma, please enable JavaScript.</noscript>
<div id="splash">
<div id="splash" class="initial-hidden">
<!-- we are hiding entire graphic so no point showing credit -->
<div aria-hidden="true" id="splash-credit">
Art by pipivovott

View file

@ -1,6 +1,6 @@
{
"name": "pleroma_fe",
"version": "2.7.1",
"version": "2.9.2",
"description": "Pleroma frontend, the default frontend of Pleroma social network server",
"author": "Pleroma contributors <https://git.pleroma.social/pleroma/pleroma-fe/-/blob/develop/CONTRIBUTORS.md>",
"private": false,
@ -17,96 +17,98 @@
"lint-fix": "eslint --fix src test/unit/specs test/e2e/specs"
},
"dependencies": {
"@babel/runtime": "7.27.0",
"@babel/runtime": "7.28.3",
"@chenfengyuan/vue-qrcode": "2.0.0",
"@fortawesome/fontawesome-svg-core": "6.7.2",
"@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/vue-fontawesome": "3.0.8",
"@kazvmoe-infra/pinch-zoom-element": "1.2.0",
"@fortawesome/vue-fontawesome": "3.1.1",
"@kazvmoe-infra/pinch-zoom-element": "1.3.0",
"@kazvmoe-infra/unicode-emoji-json": "0.4.0",
"@ruffle-rs/ruffle": "0.1.0-nightly.2025.1.13",
"@ruffle-rs/ruffle": "0.1.0-nightly.2025.6.22",
"@vuelidate/core": "2.0.3",
"@vuelidate/validators": "2.0.4",
"@web3-storage/parse-link-header": "^3.1.0",
"body-scroll-lock": "3.1.5",
"chromatism": "3.0.0",
"click-outside-vue3": "4.0.1",
"cropperjs": "1.6.2",
"cropperjs": "2.0.1",
"escape-html": "1.0.3",
"globals": "^16.0.0",
"hash-sum": "^2.0.0",
"js-cookie": "3.0.5",
"localforage": "1.10.0",
"parse-link-header": "2.0.0",
"phoenix": "1.7.21",
"phoenix": "1.8.0",
"pinia": "^3.0.0",
"punycode.js": "2.3.1",
"qrcode": "1.5.4",
"querystring-es3": "0.2.1",
"url": "0.11.4",
"utf8": "3.0.0",
"vue": "3.5.13",
"vue-i18n": "10",
"vue-router": "4.5.0",
"uuid": "11.1.0",
"vue": "3.5.19",
"vue-i18n": "11",
"vue-router": "4.5.1",
"vue-virtual-scroller": "^2.0.0-beta.7",
"vuex": "4.1.0"
},
"devDependencies": {
"@babel/core": "7.26.10",
"@babel/eslint-parser": "7.27.0",
"@babel/plugin-transform-runtime": "7.26.10",
"@babel/preset-env": "7.26.9",
"@babel/register": "7.25.9",
"@babel/core": "7.28.3",
"@babel/eslint-parser": "7.28.0",
"@babel/plugin-transform-runtime": "7.28.3",
"@babel/preset-env": "7.28.3",
"@babel/register": "7.28.3",
"@ungap/event-target": "0.2.4",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@vitest/browser": "^3.0.7",
"@vitest/ui": "^3.0.7",
"@vue/babel-helper-vue-jsx-merge-props": "1.4.0",
"@vue/babel-plugin-jsx": "1.4.0",
"@vue/compiler-sfc": "3.5.13",
"@vue/babel-plugin-jsx": "1.5.0",
"@vue/compiler-sfc": "3.5.19",
"@vue/test-utils": "2.4.6",
"autoprefixer": "10.4.21",
"babel-plugin-lodash": "3.3.4",
"chai": "5.2.0",
"chalk": "5.4.1",
"chromedriver": "134.0.5",
"chai": "5.3.2",
"chalk": "5.6.0",
"chromedriver": "135.0.4",
"connect-history-api-fallback": "2.0.0",
"cross-spawn": "7.0.6",
"custom-event-polyfill": "1.0.7",
"eslint": "9.23.0",
"eslint": "9.33.0",
"vue-eslint-parser": "10.2.0",
"eslint-config-standard": "17.1.0",
"eslint-formatter-friendly": "7.0.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-n": "17.17.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-n": "17.21.3",
"eslint-plugin-promise": "7.2.1",
"eslint-plugin-vue": "9.33.0",
"eslint-plugin-vue": "10.4.0",
"eventsource-polyfill": "0.9.6",
"express": "4.21.2",
"express": "5.1.0",
"function-bind": "1.1.2",
"http-proxy-middleware": "3.0.3",
"http-proxy-middleware": "3.0.5",
"iso-639-1": "3.1.5",
"lodash": "4.17.21",
"msw": "2.7.3",
"nightwatch": "3.12.1",
"playwright": "1.49.1",
"postcss": "8.5.3",
"msw": "2.10.5",
"nightwatch": "3.12.2",
"playwright": "1.55.0",
"postcss": "8.5.6",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6",
"sass": "1.86.0",
"sass": "1.89.2",
"selenium-server": "3.141.59",
"semver": "7.7.1",
"semver": "7.7.2",
"serve-static": "2.2.0",
"shelljs": "0.9.2",
"shelljs": "0.10.0",
"sinon": "20.0.0",
"sinon-chai": "4.0.0",
"stylelint": "14.16.1",
"stylelint": "16.19.1",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recommended-scss": "^8.0.0",
"stylelint-config-recommended-vue": "^1.4.0",
"stylelint-config-standard": "29.0.0",
"stylelint-rscss": "0.4.0",
"stylelint-config-recommended": "^16.0.0",
"stylelint-config-recommended-scss": "^14.0.0",
"stylelint-config-recommended-vue": "^1.6.0",
"stylelint-config-standard": "38.0.0",
"vite": "^6.1.0",
"vite-plugin-eslint2": "^5.0.3",
"vite-plugin-stylelint": "^6.0.0",

132
public/static/splash.css Normal file
View file

@ -0,0 +1,132 @@
body {
margin: 0;
padding: 0;
}
#splash {
--scale: 1;
width: 100vw;
height: 100vh;
display: grid;
grid-template-rows: auto;
grid-template-columns: auto;
align-content: center;
place-items: center;
flex-direction: column;
background: #0f161e;
font-family: sans-serif;
color: #b9b9ba;
position: absolute;
z-index: 9999;
font-size: calc(1vw + 1vh + 1vmin);
opacity: 1;
transition: opacity 500ms ease-out 2s;
}
#splash.hidden,
#splash.initial-hidden {
opacity: 0;
}
#splash-credit {
position: absolute;
font-size: 1em;
bottom: 1em;
right: 1em;
}
#splash-container {
align-items: center;
}
#mascot-container {
display: flex;
align-items: flex-end;
justify-content: center;
perspective: 60em;
perspective-origin: 0 -15em;
transform-style: preserve-3d;
}
#mascot {
width: calc(10em * var(--scale));
height: calc(10em * var(--scale));
object-fit: contain;
object-position: bottom;
transform: translateZ(-2em);
}
#throbber {
display: grid;
width: calc(5em * 0.5 * var(--scale));
height: calc(8em * 0.5 * var(--scale));
margin-left: 4.1em;
z-index: 2;
grid-template-rows: repeat(8, 1fr);
grid-template-columns: repeat(5, 1fr);
grid-template-areas: "P P . L L"
"P P . L L"
"P P . L L"
"P P . L L"
"P P . . ."
"P P . . ."
"P P . E E"
"P P . E E";
--logoChunkSize: calc(2em * 0.5 * var(--scale))
}
.chunk {
background-color: #e2b188;
box-shadow: 0.01em 0.01em 0.1em 0 #e2b188;
}
#chunk-P {
grid-area: P;
border-top-left-radius: calc(var(--logoChunkSize) / 2);
}
#chunk-L {
grid-area: L;
border-bottom-right-radius: calc(var(--logoChunkSize) / 2);
}
#chunk-E {
grid-area: E;
border-bottom-right-radius: calc(var(--logoChunkSize) / 2);
}
#status {
margin-top: 1em;
line-height: 2;
width: 100%;
text-align: center;
}
#statusError {
display: none;
margin-top: 1em;
font-size: calc(1vw + 1vh + 1vmin);
line-height: 2;
width: 100%;
text-align: center;
}
#statusStack {
display: none;
margin-top: 1em;
font-size: calc((1vw + 1vh + 1vmin) / 2.5);
width: calc(100vw - 5em);
padding: 1em;
text-overflow: ellipsis;
overflow-x: hidden;
text-align: left;
line-height: 2;
}
@media (prefers-reduced-motion) {
#throbber {
animation: none !important;
}
}

View file

@ -14,12 +14,15 @@ import EditStatusModal from './components/edit_status_modal/edit_status_modal.vu
import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
import StatusHistoryModal from './components/status_history_modal/status_history_modal.vue'
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
import { getOrCreateServiceWorker } from './services/sw/sw'
import { windowWidth, windowHeight } from './services/window_utils/window_utils'
import { mapGetters } from 'vuex'
import { defineAsyncComponent } from 'vue'
import { useShoutStore } from './stores/shout'
import { useInterfaceStore } from './stores/interface'
import { throttle } from 'lodash'
export default {
name: 'app',
components: {
@ -50,6 +53,9 @@ export default {
themeApplied () {
this.removeSplash()
},
currentTheme () {
this.setThemeBodyClass()
},
layoutType () {
document.getElementById('modal').classList = ['-' + this.layoutType]
}
@ -58,21 +64,41 @@ export default {
// Load the locale from the storage
const val = this.$store.getters.mergedConfig.interfaceLanguage
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
window.addEventListener('resize', this.updateMobileState)
document.getElementById('modal').classList = ['-' + this.layoutType]
// Create bound handlers
this.updateScrollState = throttle(this.scrollHandler, 200)
this.updateMobileState = throttle(this.resizeHandler, 200)
},
mounted () {
window.addEventListener('resize', this.updateMobileState)
this.scrollParent.addEventListener('scroll', this.updateScrollState)
if (useInterfaceStore().themeApplied) {
this.setThemeBodyClass()
this.removeSplash()
}
getOrCreateServiceWorker()
},
unmounted () {
window.removeEventListener('resize', this.updateMobileState)
this.scrollParent.removeEventListener('scroll', this.updateScrollState)
},
computed: {
themeApplied () {
return useInterfaceStore().themeApplied
},
currentTheme () {
if (useInterfaceStore().styleDataUsed) {
const styleMeta = useInterfaceStore().styleDataUsed.find(x => x.component === '@meta')
if (styleMeta !== undefined) {
return styleMeta.directives.name.replaceAll(" ", "-").toLowerCase()
}
}
return 'stock'
},
layoutModalClass () {
return '-' + this.layoutType
},
@ -146,19 +172,51 @@ export default {
},
noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders },
showScrollbars () { return this.$store.getters.mergedConfig.showScrollbars },
scrollParent () { return window; /* this.$refs.appContentRef */ },
...mapGetters(['mergedConfig'])
},
methods: {
updateMobileState () {
resizeHandler () {
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
},
scrollHandler () {
const scrollPosition = this.scrollParent === window ? window.scrollY : this.scrollParent.scrollTop
if (scrollPosition != 0) {
this.$refs.appContentRef.classList.add(['-scrolled'])
} else {
this.$refs.appContentRef.classList.remove(['-scrolled'])
}
},
setThemeBodyClass () {
const themeName = this.currentTheme
const classList = Array.from(document.body.classList)
const oldTheme = classList.filter(c => c.startsWith('theme-'))
if (themeName !== null && themeName !== '') {
const newTheme = `theme-${themeName.toLowerCase()}`
// remove old theme reference if there are any
if (oldTheme.length) {
document.body.classList.replace(oldTheme[0], newTheme)
} else {
document.body.classList.add(newTheme)
}
} else {
// remove theme reference if non-V3 theme is used
document.body.classList.remove(...oldTheme)
}
},
removeSplash () {
document.querySelector('#status').textContent = this.$t('splash.fun_' + Math.ceil(Math.random() * 4))
const splashscreenRoot = document.querySelector('#splash')
splashscreenRoot.addEventListener('transitionend', () => {
splashscreenRoot.remove()
})
setTimeout(() => {
splashscreenRoot.remove() // forcibly remove it, should fix my plasma browser widget t. HJ
}, 600)
splashscreenRoot.classList.add('hidden')
document.querySelector('#app').classList.remove('hidden')
}

View file

@ -2,6 +2,9 @@
/* stylelint-disable no-descending-specificity */
@use "panel";
@import '@fortawesome/fontawesome-svg-core/styles.css';
@import '@kazvmoe-infra/pinch-zoom-element/dist/pinch-zoom.css';
:root {
--status-margin: 0.75em;
--post-line-height: 1.4;
@ -18,7 +21,7 @@
}
html {
font-size: var(--textSize, 14px);
font-size: var(--textSize, 1rem);
--navbar-height: var(--navbarSize, 3.5rem);
--emoji-size: var(--emojiSize, 32px);
@ -30,12 +33,12 @@ body {
font-family: sans-serif;
font-family: var(--font);
margin: 0;
padding: 0;
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overscroll-behavior-y: none;
overflow-x: clip;
overflow-y: scroll;
overflow: clip scroll;
&.hidden {
display: none;
@ -224,9 +227,8 @@ nav {
grid-template-rows: 1fr;
box-sizing: border-box;
margin: 0 auto;
align-content: flex-start;
place-content: flex-start center;
flex-wrap: wrap;
justify-content: center;
min-height: 100vh;
overflow-x: clip;
@ -262,8 +264,7 @@ nav {
position: sticky;
top: var(--navbar-height);
max-height: calc(100vh - var(--navbar-height));
overflow-y: auto;
overflow-x: hidden;
overflow: hidden auto;
margin-left: calc(var(--___paddingIncrease) * -1);
padding-left: calc(var(--___paddingIncrease) + var(--___columnMargin) / 2);
@ -381,6 +382,10 @@ nav {
font-family: sans-serif;
font-family: var(--font);
&.-transparent {
backdrop-filter: blur(0.125em) contrast(60%);
}
&::-moz-focus-inner {
border: none;
}
@ -524,6 +529,10 @@ textarea {
height: unset;
}
&::placeholder {
color: var(--textFaint)
}
--_padding: 0.5em;
border: none;
@ -678,11 +687,6 @@ option {
}
}
.btn-block {
display: block;
width: 100%;
}
.btn-group {
position: relative;
display: inline-flex;
@ -741,17 +745,17 @@ option {
}
&.-dot {
min-height: 8px;
max-height: 8px;
min-width: 8px;
max-width: 8px;
min-height: 0.6em;
max-height: 0.6em;
min-width: 0.6em;
max-width: 0.6em;
padding: 0;
line-height: 0;
font-size: 0;
left: calc(50% - 4px);
top: calc(50% - 4px);
margin-left: 6px;
margin-top: -6px;
left: calc(50% - 0.6em);
top: calc(50% - 0.6em);
margin-left: 0.4em;
margin-top: -0.4em;
}
&.-counter {
@ -782,12 +786,6 @@ option {
color: var(--text);
}
.visibility-notice {
padding: 0.5em;
border: 1px solid var(--textFaint);
border-radius: var(--roundness);
}
.notice-dismissible {
padding-right: 4rem;
position: relative;
@ -832,7 +830,7 @@ option {
.login-hint {
text-align: center;
@media all and (min-width: 801px) {
@media all and (width >= 801px) {
display: none;
}
@ -854,7 +852,7 @@ option {
flex: 1;
}
@media all and (max-width: 800px) {
@media all and (width <= 800px) {
.mobile-hidden {
display: none;
}
@ -935,12 +933,7 @@ option {
#splash {
pointer-events: none;
transition: opacity 0.5s;
opacity: 1;
&.hidden {
opacity: 0;
}
// transition: opacity 0.5s;
#status {
&.css-ok {
@ -1079,7 +1072,7 @@ option {
scale: 1.0063 0.9938;
translate: 0 -10%;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-in-ou;
animation-timing-function: ease-in-out;
}
90% {

View file

@ -16,6 +16,7 @@
<Notifications v-if="currentUser" />
<div
id="content"
ref="appContentRef"
class="app-layout container"
:class="classes"
>

View file

@ -6,6 +6,8 @@ import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import { config } from '@fortawesome/fontawesome-svg-core';
config.autoAddCss = false
import App from '../App.vue'
import routes from './routes'
@ -21,6 +23,7 @@ import { useOAuthStore } from 'src/stores/oauth'
import { useI18nStore } from 'src/stores/i18n'
import { useInterfaceStore } from 'src/stores/interface'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useAuthFlowStore } from 'src/stores/auth_flow'
let staticInitialResults = null
@ -63,10 +66,11 @@ const getInstanceConfig = async ({ store }) => {
const textlimit = data.max_toot_chars
const vapidPublicKey = data.pleroma.vapid_public_key
store.dispatch('setInstanceOption', { name: 'pleromaExtensionsAvailable', value: data.pleroma })
store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit })
store.dispatch('setInstanceOption', { name: 'accountApprovalRequired', value: data.approval_required })
store.dispatch('setInstanceOption', { name: 'birthdayRequired', value: !!data.pleroma.metadata.birthday_required })
store.dispatch('setInstanceOption', { name: 'birthdayMinAge', value: data.pleroma.metadata.birthday_min_age || 0 })
store.dispatch('setInstanceOption', { name: 'birthdayRequired', value: !!data.pleroma?.metadata.birthday_required })
store.dispatch('setInstanceOption', { name: 'birthdayMinAge', value: data.pleroma?.metadata.birthday_min_age || 0 })
if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
@ -78,6 +82,8 @@ const getInstanceConfig = async ({ store }) => {
console.error('Could not load instance config, potentially fatal')
console.error(error)
}
// We should check for scrobbles support here but it requires userId
// so instead we check for it where it's fetched (statuses.js)
}
const getBackendProvidedConfig = async () => {
@ -153,7 +159,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
: config.logoMargin
})
copyInstanceOption('logoLeft')
store.commit('authFlow/setInitialStrategy', config.loginMethod)
useAuthFlowStore().setInitialStrategy(config.loginMethod)
copyInstanceOption('redirectRootNoLogin')
copyInstanceOption('redirectRootLogin')
@ -242,7 +248,8 @@ const resolveStaffAccounts = ({ store, accounts }) => {
const getNodeInfo = async ({ store }) => {
try {
const res = await preloadFetch('/nodeinfo/2.1.json')
let res = await preloadFetch('/nodeinfo/2.1.json')
if (!res.ok) res = await preloadFetch('/nodeinfo/2.0.json')
if (res.ok) {
const data = await res.json()
const metadata = data.metadata
@ -253,7 +260,12 @@ const getNodeInfo = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
store.dispatch('setInstanceOption', { name: 'shoutAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
store.dispatch('setInstanceOption', { name: 'pleromaCustomEmojiReactionsAvailable', value: features.includes('pleroma_custom_emoji_reactions') })
store.dispatch('setInstanceOption', {
name: 'pleromaCustomEmojiReactionsAvailable',
value:
features.includes('pleroma_custom_emoji_reactions') ||
features.includes('custom_emoji_reactions')
})
store.dispatch('setInstanceOption', { name: 'pleromaBookmarkFoldersAvailable', value: features.includes('pleroma:bookmark_folders') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
@ -262,6 +274,8 @@ const getNodeInfo = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
store.dispatch('setInstanceOption', { name: 'quotingAvailable', value: features.includes('quote_posting') })
store.dispatch('setInstanceOption', { name: 'groupActorAvailable', value: features.includes('pleroma:group_actors') })
store.dispatch('setInstanceOption', { name: 'blockExpiration', value: features.includes('pleroma:block_expiration') })
store.dispatch('setInstanceOption', { name: 'localBubbleInstances', value: metadata.localBubbleInstances ?? [] })
const uploadLimits = metadata.uploadLimits
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) })
@ -280,7 +294,6 @@ const getNodeInfo = async ({ store }) => {
const software = data.software
store.dispatch('setInstanceOption', { name: 'backendVersion', value: software.version })
store.dispatch('setInstanceOption', { name: 'backendRepository', value: software.repository })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: software.name === 'pleroma' })
const priv = metadata.private
store.dispatch('setInstanceOption', { name: 'private', value: priv })

View file

@ -1,4 +1,5 @@
import PublicTimeline from 'components/public_timeline/public_timeline.vue'
import BubbleTimeline from 'components/bubble_timeline/bubble_timeline.vue'
import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
@ -54,6 +55,7 @@ export default (store) => {
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'bubble', path: '/bubble', component: BubbleTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'quotes', path: '/notice/:id/quotes', component: QuotesTimeline },
{

View file

@ -3,6 +3,7 @@ import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue'
import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEllipsisV
@ -27,15 +28,10 @@ const AccountActions = {
ProgressButton,
Popover,
UserListMenu,
ConfirmModal
ConfirmModal,
UserTimedFilterModal
},
methods: {
showConfirmBlock () {
this.showingConfirmBlock = true
},
hideConfirmBlock () {
this.showingConfirmBlock = false
},
showConfirmRemoveUserFromFollowers () {
this.showingConfirmRemoveFollower = true
},
@ -49,10 +45,14 @@ const AccountActions = {
this.$store.dispatch('hideReblogs', this.user.id)
},
blockUser () {
if (!this.shouldConfirmBlock) {
this.doBlockUser()
if (this.$refs.timedBlockDialog) {
this.$refs.timedBlockDialog.optionallyPrompt()
} else {
this.showConfirmBlock()
if (!this.shouldConfirmBlock) {
this.doBlockUser()
} else {
this.showingConfirmBlock = true
}
}
},
doBlockUser () {
@ -91,6 +91,7 @@ const AccountActions = {
return this.$store.getters.mergedConfig.modalOnRemoveUserFromFollowers
},
...mapState({
blockExpirationSupported: state => state.instance.blockExpiration,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
})
}

View file

@ -3,7 +3,6 @@
<Popover
trigger="click"
placement="bottom"
:bound-to="{ x: 'container' }"
remove-padding
>
<template #content>
@ -96,7 +95,8 @@
</Popover>
<teleport to="#modal">
<confirm-modal
v-if="showingConfirmBlock"
v-if="showingConfirmBlock && !blockExpirationSupported"
ref="blockDialog"
:title="$t('user_card.block_confirm_title')"
:confirm-text="$t('user_card.block_confirm_accept_button')"
:cancel-text="$t('user_card.block_confirm_cancel_button')"
@ -137,6 +137,12 @@
</template>
</i18n-t>
</confirm-modal>
<UserTimedFilterModal
v-if="blockExpirationSupported"
ref="timedBlockDialog"
:is-mute="false"
:user="user"
/>
</teleport>
</div>
</template>

View file

@ -56,7 +56,7 @@
.post-textarea {
resize: vertical;
height: 10em;
overflow: none;
overflow: visible;
box-sizing: content-box;
}
}

View file

@ -107,9 +107,9 @@
.play-icon {
position: absolute;
font-size: 64px;
top: calc(50% - 32px);
left: calc(50% - 32px);
font-size: 4.5em;
top: calc(50% - 2.25rem);
left: calc(50% - 2.25rem);
color: rgb(255 255 255 / 75%);
text-shadow: 0 0 2px rgb(0 0 0 / 40%);
@ -177,7 +177,8 @@
.text {
flex: 2;
margin: 8px;
word-break: break-all;
overflow-wrap: break-word;
text-wrap: pretty;
h1 {
font-size: 1rem;

View file

@ -1,27 +0,0 @@
export default {
name: 'Attachment',
selector: '.Attachment',
notEditable: true,
validInnerComponents: [
'Border',
'Button',
'Input'
],
defaultRules: [
{
directives: {
roundness: 3
}
},
{
component: 'Button',
parent: {
component: 'Attachment'
},
directives: {
background: '#FFFFFF',
opacity: 0.5
}
}
]
}

View file

@ -23,7 +23,7 @@
>
<button
v-if="remove"
class="button-default attachment-button"
class="button-default attachment-button -transparent"
@click.prevent="onRemove"
>
<FAIcon icon="trash-alt" />
@ -81,7 +81,7 @@
>
<button
v-if="type === 'flash' && flashLoaded"
class="button-default attachment-button"
class="button-default attachment-button -transparent"
:title="$t('status.attachment_stop_flash')"
@click.prevent="stopFlash"
>
@ -89,7 +89,7 @@
</button>
<button
v-if="attachment.description && size !== 'small' && !edit && type !== 'unknown'"
class="button-default attachment-button"
class="button-default attachment-button -transparent"
:title="$t('status.show_attachment_description')"
@click.prevent="toggleDescription"
>
@ -97,7 +97,7 @@
</button>
<button
v-if="!useModal && type !== 'unknown'"
class="button-default attachment-button"
class="button-default attachment-button -transparent"
:title="$t('status.show_attachment_in_modal')"
@click.prevent="openModalForce"
>
@ -105,7 +105,7 @@
</button>
<button
v-if="nsfw && hideNsfwLocal"
class="button-default attachment-button"
class="button-default attachment-button -transparent"
:title="$t('status.hide_attachment')"
@click.prevent="toggleHidden"
>
@ -113,7 +113,7 @@
</button>
<button
v-if="shiftUp"
class="button-default attachment-button"
class="button-default attachment-button -transparent"
:title="$t('status.move_up')"
@click.prevent="onShiftUp"
>
@ -121,7 +121,7 @@
</button>
<button
v-if="shiftDn"
class="button-default attachment-button"
class="button-default attachment-button -transparent"
:title="$t('status.move_down')"
@click.prevent="onShiftDn"
>
@ -129,7 +129,7 @@
</button>
<button
v-if="remove"
class="button-default attachment-button"
class="button-default attachment-button -transparent"
:title="$t('status.remove_attachment')"
@click.prevent="onRemove"
>

View file

@ -2,7 +2,8 @@ import { h, resolveComponent } from 'vue'
import LoginForm from '../login_form/login_form.vue'
import MFARecoveryForm from '../mfa_form/recovery_form.vue'
import MFATOTPForm from '../mfa_form/totp_form.vue'
import { mapGetters } from 'vuex'
import { mapState } from 'pinia'
import { useAuthFlowStore } from 'src/stores/auth_flow'
const AuthForm = {
name: 'AuthForm',
@ -15,7 +16,7 @@ const AuthForm = {
if (this.requiredRecovery) { return 'MFARecoveryForm' }
return 'LoginForm'
},
...mapGetters('authFlow', ['requiredTOTP', 'requiredRecovery'])
...mapState(useAuthFlowStore, ['requiredTOTP', 'requiredRecovery'])
},
components: {
MFARecoveryForm,

View file

@ -1,12 +1,9 @@
import { mapState } from 'vuex'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const BlockCard = {
props: ['userId'],
data () {
return {
progress: false
}
},
computed: {
user () {
return this.$store.getters.findUser(this.userId)
@ -16,23 +13,32 @@ const BlockCard = {
},
blocked () {
return this.relationship.blocking
}
},
blockExpiryAvailable () {
return this.user.block_expires_at !== undefined
},
blockExpiry () {
return this.user.block_expires_at == null
? this.$t('user_card.block_expires_forever')
: this.$t('user_card.block_expires_at', [new Date(this.user.mute_expires_at).toLocaleString()])
},
...mapState({
blockExpirationSupported: state => state.instance.blockExpiration,
})
},
components: {
BasicUserCard
},
methods: {
unblockUser () {
this.progress = true
this.$store.dispatch('unblockUser', this.user.id).then(() => {
this.progress = false
})
this.$store.dispatch('unblockUser', this.user.id)
},
blockUser () {
this.progress = true
this.$store.dispatch('blockUser', this.user.id).then(() => {
this.progress = false
})
if (this.blockExpirationSupported) {
this.$refs.timedBlockDialog.optionallyPrompt()
} else {
this.$store.dispatch('blockUser', this.user.id)
}
}
}
}

View file

@ -1,33 +1,35 @@
<template>
<basic-user-card :user="user">
<div class="block-card-content-container">
<span
v-if="blocked && blockExpiryAvailable"
class="alert neutral"
>
{{ blockExpiry }}
</span>
{{ ' ' }}
<button
v-if="blocked"
class="btn button-default"
:disabled="progress"
@click="unblockUser"
>
<template v-if="progress">
{{ $t('user_card.unblock_progress') }}
</template>
<template v-else>
{{ $t('user_card.unblock') }}
</template>
{{ $t('user_card.unblock') }}
</button>
<button
v-else
class="btn button-default"
:disabled="progress"
@click="blockUser"
>
<template v-if="progress">
{{ $t('user_card.block_progress') }}
</template>
<template v-else>
{{ $t('user_card.block') }}
</template>
{{ $t('user_card.block') }}
</button>
</div>
<teleport to="#modal">
<UserTimedFilterModal
ref="timedBlockDialog"
:user="user"
:is-mute="false"
/>
</teleport>
</basic-user-card>
</template>

View file

@ -0,0 +1,18 @@
import Timeline from '../timeline/timeline.vue'
const BubbleTimeline = {
components: {
Timeline
},
computed: {
timeline () { return this.$store.state.statuses.timelines.bubble }
},
created () {
this.$store.dispatch('startFetchingTimeline', { timeline: 'bubble' })
},
unmounted () {
this.$store.dispatch('stopFetchingTimeline', 'bubble')
}
}
export default BubbleTimeline

View file

@ -0,0 +1,9 @@
<template>
<Timeline
:title="$t('nav.bubble')"
:timeline="timeline"
:timeline-name="'bubble'"
/>
</template>
<script src="./bubble_timeline.js"></script>

View file

@ -10,7 +10,7 @@ export default {
// normal: '' // normal state is implicitly added, it is always included
toggled: '.toggled',
focused: ':focus-within',
pressed: ':focus:active',
pressed: ':active',
hover: ':is(:hover, :focus-visible):not(:disabled)',
disabled: ':disabled'
},
@ -18,7 +18,8 @@ export default {
variants: {
// Variants save on computation time since adding new variant just adds one more "set".
// normal: '', // you can override normal variant, it will be appenended to the main class
danger: '.danger'
danger: '.-danger',
transparent: '.-transparent'
// Overall the compuation difficulty is N*((1/6)M^3+M) where M is number of distinct states and N is number of variants.
// This (currently) is further multipled by number of places where component can exist.
},
@ -51,6 +52,38 @@ export default {
roundness: 3
}
},
{
variant: 'danger',
directives: {
background: '--cRed'
}
},
{
variant: 'transparent',
directives: {
opacity: 0.5
}
},
{
component: 'Text',
parent: {
component: 'Button',
variant: 'transparent'
},
directives: {
textColor: '--text'
}
},
{
component: 'Icon',
parent: {
component: 'Button',
variant: 'transparent'
},
directives: {
textColor: '--text'
}
},
{
state: ['hover'],
directives: {

View file

@ -17,7 +17,6 @@
width: 100%;
box-sizing: border-box;
overflow: hidden;
word-wrap: break-word;
}
.heading {

View file

@ -107,8 +107,7 @@
.outgoing {
display: flex;
flex-flow: row wrap;
align-content: end;
justify-content: flex-end;
place-content: end flex-end;
.chat-message-inner {
align-items: flex-end;

View file

@ -8,9 +8,6 @@ export default {
'Text',
'Icon',
'Border',
'Button',
'RichContent',
'Attachment',
'PollGraph'
],
defaultRules: [

View file

@ -19,6 +19,7 @@
:title="'@'+(user && user.screen_name_ui)"
:html="htmlTitle"
:emoji="user.emoji || []"
:is-local="user.is_local"
/>
</div>
</template>
@ -39,7 +40,6 @@
text-overflow: ellipsis;
white-space: nowrap;
display: inline;
word-wrap: break-word;
overflow: hidden;
}

View file

@ -19,14 +19,14 @@
/>
<div
class="input color-input-field"
:class="{ disabled: !present || disabled }"
:class="{ disabled: !present || disabled, unstyled }"
>
<input
:id="name + '-t'"
class="textColor unstyled"
:class="{ disabled: !present || disabled }"
type="text"
:value="modelValue || fallback"
:value="modelValue ?? fallback"
:disabled="!present || disabled"
@input="updateValue($event.target.value)"
>
@ -92,6 +92,11 @@ export default {
required: true,
type: String
},
// use unstyled, uh, style
unstyled: {
required: false,
type: Boolean
},
// Color value, should be required but vue cannot tell the difference
// between "property missing" and "property set to undefined"
modelValue: {

View file

@ -0,0 +1,82 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import genRandomSeed from 'src/services/random_seed/random_seed.service.js'
import { createStyleSheet, adoptStyleSheets } from 'src/services/style_setter/style_setter.js'
export default {
components: {
Checkbox,
ColorInput
},
props: [
'shadow',
'shadowControl',
'previewClass',
'previewStyle',
'previewCss',
'disabled',
'invalid',
'noColorControl'
],
emits: ['update:shadow'],
data () {
return {
colorOverride: undefined,
lightGrid: false,
zoom: 100,
randomSeed: genRandomSeed()
}
},
mounted () {
this.update()
},
computed: {
hideControls () {
return typeof this.shadow === 'string'
}
},
watch: {
previewCss () {
this.update()
},
previewStyle () {
this.update()
},
zoom () {
this.update()
}
},
methods: {
updateProperty (axis, value) {
this.$emit('update:shadow', { axis, value: Number(value) })
},
update () {
const sheet = createStyleSheet('style-component-preview', 90)
sheet.clear()
const result = [this.previewCss]
if (this.colorOverride) result.push(`--background: ${this.colorOverride}`)
const styleRule = [
'#component-preview-', this.randomSeed, ' {\n',
'.preview-block {\n',
`zoom: ${this.zoom / 100};`,
this.previewStyle,
'\n}',
'\n}'
].join('')
sheet.addRule(styleRule)
sheet.addRule([
'#component-preview-', this.randomSeed, ' {\n',
...result,
'\n}'
].join(''))
sheet.ready = true
adoptStyleSheets()
}
}
}

View file

@ -0,0 +1,151 @@
.ComponentPreview {
display: grid;
grid-template-columns: 1em 1fr 1fr 1em;
grid-template-rows: 2em 1fr 1fr 1fr 1em 2em max-content;
grid-template-areas:
"header header header header "
"preview preview preview y-slide"
"preview preview preview y-slide"
"preview preview preview y-slide"
"x-slide x-slide x-slide . "
"x-num x-num y-num y-num "
"assists assists assists assists";
grid-gap: 0.5em;
&:not(.-shadow-controls) {
grid-template-areas:
"header header header header "
"preview preview preview y-slide"
"preview preview preview y-slide"
"preview preview preview y-slide"
"assists assists assists assists";
grid-template-rows: 2em 1fr 1fr 1fr max-content;
}
.header {
grid-area: header;
place-self: baseline center;
line-height: 2;
}
.invalid-container {
position: absolute;
inset: 0;
display: grid;
place-items: center center;
background-color: rgb(100 0 0 / 50%);
.alert {
padding: 0.5em 1em;
}
}
.assists {
grid-area: assists;
display: grid;
grid-auto-flow: row;
grid-auto-rows: 2em;
grid-gap: 0.5em;
}
.input-light-grid {
justify-self: center;
}
.input-number {
min-width: 2em;
}
.x-shift-number {
grid-area: x-num;
justify-self: right;
}
.y-shift-number {
grid-area: y-num;
justify-self: left;
}
.x-shift-number,
.y-shift-number {
input {
max-width: 4em;
}
}
.x-shift-slider {
grid-area: x-slide;
height: auto;
align-self: start;
min-width: 10em;
}
.y-shift-slider {
grid-area: y-slide;
writing-mode: vertical-lr;
justify-self: left;
min-height: 10em;
}
.x-shift-slider,
.y-shift-slider {
padding: 0;
}
.preview-window {
--__grid-color1: rgb(102 102 102);
--__grid-color2: rgb(153 153 153);
--__grid-color1-disabled: rgb(102 102 102 / 20%);
--__grid-color2-disabled: rgb(153 153 153 / 20%);
&.-light-grid {
--__grid-color1: rgb(205 205 205);
--__grid-color2: rgb(255 255 255);
--__grid-color1-disabled: rgb(205 205 205 / 20%);
--__grid-color2-disabled: rgb(255 255 255 / 20%);
}
position: relative;
grid-area: preview;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
min-width: 10em;
min-height: 10em;
background-color: var(--__grid-color2);
background-image:
linear-gradient(45deg, var(--__grid-color1) 25%, transparent 25%),
linear-gradient(-45deg, var(--__grid-color1) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--__grid-color1) 75%),
linear-gradient(-45deg, transparent 75%, var(--__grid-color1) 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
border-radius: var(--roundness);
&.disabled {
background-color: var(--__grid-color2-disabled);
background-image:
linear-gradient(45deg, var(--__grid-color1-disabled) 25%, transparent 25%),
linear-gradient(-45deg, var(--__grid-color1-disabled) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--__grid-color1-disabled) 75%),
linear-gradient(-45deg, transparent 75%, var(--__grid-color1-disabled) 75%);
}
.preview-block {
background: var(--background, var(--bg));
display: flex;
justify-content: center;
align-items: center;
min-width: 33%;
min-height: 33%;
max-width: 80%;
max-height: 80%;
border-width: 0;
border-style: solid;
border-color: var(--border);
border-radius: var(--roundness);
box-shadow: var(--shadow);
}
}
}

View file

@ -1,14 +1,9 @@
<template>
<div
:id="'component-preview-' + randomSeed"
class="ComponentPreview"
:class="{ '-shadow-controls': shadowControl }"
>
<!-- eslint-disable vue/no-v-html vue/no-v-text-v-html-on-component -->
<component
:is="'style'"
v-html="previewCss"
/>
<!-- eslint-enable vue/no-v-html vue/no-v-text-v-html-on-component -->
<label
v-show="shadowControl"
role="heading"
@ -74,7 +69,6 @@
<div
class="preview-block"
:class="previewClass"
:style="style"
>
{{ $t('settings.style.themes3.editor.test_string') }}
</div>
@ -116,208 +110,5 @@
</div>
</template>
<script>
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
export default {
components: {
Checkbox,
ColorInput
},
props: [
'shadow',
'shadowControl',
'previewClass',
'previewStyle',
'previewCss',
'disabled',
'invalid',
'noColorControl'
],
emits: ['update:shadow'],
data () {
return {
colorOverride: undefined,
lightGrid: false,
zoom: 100
}
},
computed: {
style () {
const result = [
this.previewStyle,
`zoom: ${this.zoom / 100}`
]
if (this.colorOverride) result.push(`--background: ${this.colorOverride}`)
return result
},
hideControls () {
return typeof this.shadow === 'string'
}
},
methods: {
updateProperty (axis, value) {
this.$emit('update:shadow', { axis, value: Number(value) })
}
}
}
</script>
<style lang="scss">
.ComponentPreview {
display: grid;
grid-template-columns: 1em 1fr 1fr 1em;
grid-template-rows: 2em 1fr 1fr 1fr 1em 2em max-content;
grid-template-areas:
"header header header header "
"preview preview preview y-slide"
"preview preview preview y-slide"
"preview preview preview y-slide"
"x-slide x-slide x-slide . "
"x-num x-num y-num y-num "
"assists assists assists assists";
grid-gap: 0.5em;
&:not(.-shadow-controls) {
grid-template-areas:
"header header header header "
"preview preview preview y-slide"
"preview preview preview y-slide"
"preview preview preview y-slide"
"assists assists assists assists";
grid-template-rows: 2em 1fr 1fr 1fr max-content;
}
.header {
grid-area: header;
justify-self: center;
align-self: baseline;
line-height: 2;
}
.invalid-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: grid;
align-items: center;
justify-items: center;
background-color: rgba(100 0 0 / 50%);
.alert {
padding: 0.5em 1em;
}
}
.assists {
grid-area: assists;
display: grid;
grid-auto-flow: rows;
grid-auto-rows: 2em;
grid-gap: 0.5em;
}
.input-light-grid {
justify-self: center;
}
.input-number {
min-width: 2em;
}
.x-shift-number {
grid-area: x-num;
justify-self: right;
}
.y-shift-number {
grid-area: y-num;
justify-self: left;
}
.x-shift-number,
.y-shift-number {
input {
max-width: 4em;
}
}
.x-shift-slider {
grid-area: x-slide;
height: auto;
align-self: start;
min-width: 10em;
}
.y-shift-slider {
grid-area: y-slide;
writing-mode: vertical-lr;
justify-self: left;
min-height: 10em;
}
.x-shift-slider,
.y-shift-slider {
padding: 0;
}
.preview-window {
--__grid-color1: rgb(102 102 102);
--__grid-color2: rgb(153 153 153);
--__grid-color1-disabled: rgba(102 102 102 / 20%);
--__grid-color2-disabled: rgba(153 153 153 / 20%);
&.-light-grid {
--__grid-color1: rgb(205 205 205);
--__grid-color2: rgb(255 255 255);
--__grid-color1-disabled: rgba(205 205 205 / 20%);
--__grid-color2-disabled: rgba(255 255 255 / 20%);
}
position: relative;
grid-area: preview;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
min-width: 10em;
min-height: 10em;
background-color: var(--__grid-color2);
background-image:
linear-gradient(45deg, var(--__grid-color1) 25%, transparent 25%),
linear-gradient(-45deg, var(--__grid-color1) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--__grid-color1) 75%),
linear-gradient(-45deg, transparent 75%, var(--__grid-color1) 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
border-radius: var(--roundness);
&.disabled {
background-color: var(--__grid-color2-disabled);
background-image:
linear-gradient(45deg, var(--__grid-color1-disabled) 25%, transparent 25%),
linear-gradient(-45deg, var(--__grid-color1-disabled) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--__grid-color1-disabled) 75%),
linear-gradient(-45deg, transparent 75%, var(--__grid-color1-disabled) 75%);
}
.preview-block {
background: var(--background, var(--bg));
display: flex;
justify-content: center;
align-items: center;
min-width: 33%;
min-height: 33%;
max-width: 80%;
max-height: 80%;
border-width: 0;
border-style: solid;
border-color: var(--border);
border-radius: var(--roundness);
box-shadow: var(--shadow);
}
}
}
</style>
<script src="./component_preview.js" />
<style src="./component_preview.scss" lang="scss" />

View file

@ -11,6 +11,7 @@
<slot />
<template #footer>
<slot name="footerLeft" />
<button
class="btn button-default"
@click.prevent="onAccept"

View file

@ -1,4 +1,3 @@
import { unitToSeconds } from 'src/services/date_utils/date_utils.js'
import { mapGetters } from 'vuex'
import ConfirmModal from './confirm_modal.vue'
@ -8,21 +7,13 @@ export default {
props: ['type', 'user', 'status'],
emits: ['hide', 'show', 'muted'],
data: () => ({
showing: false,
muteExpiryAmount: 2,
muteExpiryUnit: 'hours'
showing: false
}),
components: {
ConfirmModal,
Select
},
computed: {
muteExpiryValue () {
unitToSeconds(this.muteExpiryUnit, this.muteExpiryAmount)
},
muteExpiryUnits () {
return ['minutes', 'hours', 'days']
},
domain () {
return this.user.fqn.split('@')[1]
},
@ -31,13 +22,8 @@ export default {
return 'status.mute_domain_confirm'
} else if (this.type === 'conversation') {
return 'status.mute_conversation_confirm'
} else {
return 'user_card.mute_confirm'
}
},
userIsMuted () {
return this.$store.getters.relationship(this.user.id).muting
},
conversationIsMuted () {
return this.status.conversation_muted
},
@ -49,12 +35,9 @@ export default {
case 'domain': {
return this.mergedConfig.modalOnMuteDomain
}
case 'conversation': {
default: { // conversation
return this.mergedConfig.modalOnMuteConversation
}
default: {
return this.mergedConfig.modalOnMute
}
}
},
...mapGetters(['mergedConfig'])
@ -79,7 +62,7 @@ export default {
switch (this.type) {
case 'domain': {
if (!this.domainIsMuted) {
this.$store.dispatch('muteDomain', { id: this.domain, expiresIn: this.muteExpiryValue })
this.$store.dispatch('muteDomain', { id: this.domain })
} else {
this.$store.dispatch('unmuteDomain', { id: this.domain })
}
@ -87,20 +70,12 @@ export default {
}
case 'conversation': {
if (!this.conversationIsMuted) {
this.$store.dispatch('muteConversation', { id: this.status.id, expiresIn: this.muteExpiryValue })
this.$store.dispatch('muteConversation', { id: this.status.id })
} else {
this.$store.dispatch('unmuteConversation', { id: this.status.id })
}
break
}
default: {
if (!this.userIsMuted) {
this.$store.dispatch('muteUser', { id: this.user.id, expiresIn: this.muteExpiryValue })
} else {
this.$store.dispatch('unmuteUser', { id: this.user.id })
}
break
}
}
this.$emit('muted')
this.hide()

View file

@ -18,36 +18,6 @@
<span v-text="user.screen_name_ui" />
</template>
</i18n-t>
<div
v-if="type !== 'domain'"
class="mute-expiry"
>
<p>
<label>
{{ $t('user_card.mute_duration_prompt') }}
</label>
<input
v-model="muteExpiryAmount"
type="number"
class="input expiry-amount hide-number-spinner"
:min="0"
>
{{ ' ' }}
<Select
v-model="muteExpiryUnit"
unstyled="true"
class="expiry-unit"
>
<option
v-for="unit in muteExpiryUnits"
:key="unit"
:value="unit"
>
{{ $t(`time.unit.${unit}_short`, ['']) }}
</option>
</Select>
</p>
</div>
</confirm-modal>
</template>

View file

@ -339,11 +339,6 @@ const conversation = {
canDive () {
return this.isTreeView && this.isExpanded
},
focused () {
return (id) => {
return (this.isExpanded) && id === this.highlight
}
},
maybeHighlight () {
return this.isExpanded ? this.highlight : null
},
@ -406,6 +401,9 @@ const conversation = {
})
}
},
isFocused (id) {
return (this.isExpanded) && id === this.highlight
},
getReplies (id) {
return this.replies[id] || []
},

View file

@ -94,7 +94,7 @@
:statusoid="status"
:expandable="!isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="focused(status.id)"
:focused="isFocused(status.id)"
:in-conversation="isExpanded"
:highlight="getHighlight()"
:replies="getReplies(status.id)"
@ -168,7 +168,7 @@
:pinned-status-ids-object="pinnedStatusIdsObject"
:profile-user-id="profileUserId"
:focused="focused"
:is-focused-function="isFocused"
:get-replies="getReplies"
:highlight="maybeHighlight"
:set-highlight="setHighlight"
@ -199,7 +199,7 @@
:statusoid="status"
:expandable="!isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="focused(status.id)"
:focused="isFocused(status.id)"
:in-conversation="isExpanded"
:highlight="getHighlight()"
:replies="getReplies(status.id)"
@ -322,10 +322,7 @@
content: "";
display: block;
position: absolute;
top: calc(var(--___margin) * -1);
bottom: calc(var(--___margin) * -1);
left: calc(var(--___margin) * -1);
right: calc(var(--___margin) * -1);
inset: calc(var(--___margin) * -1);
background: var(--background);
backdrop-filter: var(--__panel-backdrop-filter);
}

View file

@ -59,7 +59,7 @@
transition-timing-function: ease-out;
transition-duration: 100ms;
@media all and (min-width: 800px) {
@media all and (width >= 800px) {
/* stylelint-disable-next-line declaration-no-important */
opacity: 1 !important;
}
@ -70,10 +70,7 @@
mask-size: contain;
background-color: var(--text);
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
inset: 0;
}
img {

View file

@ -29,14 +29,11 @@
// TODO: unify with other modals.
.dark-overlay {
&::before {
bottom: 0;
inset: 0;
content: " ";
display: block;
cursor: default;
left: 0;
position: fixed;
right: 0;
top: 0;
background: rgb(27 31 35 / 50%);
z-index: 2000;
}
@ -45,17 +42,13 @@
.dialog-container {
display: grid;
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
inset: 0;
justify-content: center;
align-items: center;
justify-items: center;
place-items: center center;
overflow: auto;
}
.dialog-modal.panel {
max-height: 80vh;
max-width: 90vw;
z-index: 2001;
cursor: default;
@ -98,8 +91,7 @@
#modal.-mobile {
.dialog-container {
justify-content: stretch;
align-items: end;
justify-items: stretch;
place-items: end stretch;
&.-center-mobile {
align-items: center;
@ -114,7 +106,6 @@
flex-direction: column;
justify-content: flex-end;
grid-template-columns: 1fr;
grid-auto-columns: none;
grid-auto-rows: auto;
grid-auto-flow: row dense;

View file

@ -127,7 +127,6 @@
max-width: 100%;
p {
word-wrap: break-word;
white-space: normal;
overflow-x: hidden;
}
@ -135,8 +134,7 @@
.poll-indicator-container {
border-radius: var(--roundness);
display: grid;
justify-items: center;
align-items: center;
place-items: center center;
align-self: start;
height: 0;
padding-bottom: 62.5%;
@ -147,13 +145,9 @@
box-sizing: border-box;
border: 1px solid var(--border);
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
inset: 0;
display: grid;
justify-items: center;
align-items: center;
place-items: center center;
width: 100%;
height: 100%;
}

View file

@ -205,12 +205,6 @@ const EmojiInput = {
return emoji.displayText
}
},
onInputScroll () {
this.$refs.hiddenOverlay.scrollTo({
top: this.input.scrollTop,
left: this.input.scrollLeft
})
},
suggestionListId () {
return `suggestions-${this.randomSeed}`
},
@ -239,7 +233,6 @@ const EmojiInput = {
this.overlayStyle.fontSize = style.fontSize
this.overlayStyle.wordWrap = style.wordWrap
this.overlayStyle.whiteSpace = style.whiteSpace
this.resize()
input.addEventListener('blur', this.onBlur)
input.addEventListener('focus', this.onFocus)
input.addEventListener('paste', this.onPaste)
@ -302,6 +295,13 @@ const EmojiInput = {
}
},
methods: {
onInputScroll (e) {
this.$refs.hiddenOverlay.scrollTo({
top: this.input.scrollTop,
left: this.input.scrollLeft
})
this.setCaret(e)
},
triggerShowPicker () {
this.$nextTick(() => {
this.$refs.picker.showPicker()
@ -561,8 +561,6 @@ const EmojiInput = {
this.$refs.suggestorPopover.updateStyles()
})
},
resize () {
},
autoCompleteItemLabel (suggestion) {
if (suggestion.user) {
return suggestion.displayText + ' ' + suggestion.detailText

View file

@ -123,7 +123,7 @@
margin: 0.2em 0.25em;
font-size: 1.3em;
cursor: pointer;
line-height: 24px;
line-height: 1.2em;
&:hover i {
color: var(--text);
@ -133,7 +133,7 @@
.emoji-picker-panel {
position: absolute;
z-index: 20;
margin-top: 2px;
margin-top: 0.2em;
&.hide {
display: none;
@ -152,17 +152,14 @@
}
&.with-picker input {
padding-right: 30px;
padding-right: 2em;
}
.hidden-overlay {
opacity: 0;
pointer-events: none;
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
inset: 0;
overflow: hidden;
/* DEBUG STUFF */
@ -218,8 +215,8 @@
}
.detailText {
font-size: 9px;
line-height: 9px;
font-size: 0.6em;
line-height: 0.6em;
}
}
}

View file

@ -140,13 +140,13 @@ const EmojiPicker = {
},
updateEmojiSize () {
const css = window.getComputedStyle(this.$refs.popover.$el)
const fontSize = css.getPropertyValue('font-size') || '14px'
const fontSize = css.getPropertyValue('font-size') || '1rem'
const emojiSize = css.getPropertyValue('--emojiSize') || '2.2rem'
const fontSizeUnit = fontSize.replace(/[0-9,.]+/, '')
const fontSizeUnit = fontSize.replace(/[0-9,.]+/, '').trim()
const fontSizeValue = Number(fontSize.replace(/[^0-9,.]+/, ''))
const emojiSizeUnit = emojiSize.replace(/[0-9,.]+/, '')
const emojiSizeUnit = emojiSize.replace(/[0-9,.]+/, '').trim()
const emojiSizeValue = Number(emojiSize.replace(/[^0-9,.]+/, ''))
let fontSizeMultiplier

View file

@ -64,8 +64,7 @@
flex-grow: 1;
display: flex;
flex-flow: row nowrap;
overflow-x: auto;
overflow-y: hidden;
overflow: auto hidden;
}
.additional-tabs {
@ -153,7 +152,15 @@
transition: mask-size 150ms;
mask-size: 100% 20px, 100% 20px, auto;
// Autoprefixed seem to ignore this one, and also syntax is different
/* stylelint-disable mask-composite */
/* stylelint-disable declaration-property-value-no-unknown */
/* stylelint-disable scss/declaration-property-value-no-unknown */
/* TODO check if this is still needed */
mask-composite: xor;
/* stylelint-enable declaration-property-value-no-unknown */
/* stylelint-enable scss/declaration-property-value-no-unknown */
/* stylelint-enable mask-composite */
mask-composite: exclude;
&.scrolled {
@ -197,8 +204,7 @@
&-group {
display: grid;
grid-template-columns: repeat(var(--__amount), 1fr);
align-items: center;
justify-items: center;
place-items: center center;
justify-content: center;
grid-template-rows: repeat(1, auto);

View file

@ -72,12 +72,11 @@
flex: 1 1 0;
line-height: 1.2;
white-space: normal;
word-wrap: normal;
}
.hidden {
display: none;
visibility: "hidden";
visibility: hidden;
}
}
</style>

View file

@ -101,10 +101,7 @@
.gallery-row-inner {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
display: flex;
flex-flow: row wrap;
align-content: stretch;
@ -132,7 +129,7 @@
.gallery-item {
margin: 0;
height: 200px;
height: 15em;
}
}
}
@ -160,7 +157,15 @@
linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */
/* stylelint-disable mask-composite */
/* stylelint-disable declaration-property-value-no-unknown */
/* stylelint-disable scss/declaration-property-value-no-unknown */
/* TODO check if this is still needed */
mask-composite: xor;
/* stylelint-enable scss/declaration-property-value-no-unknown */
/* stylelint-enable declaration-property-value-no-unknown */
/* stylelint-enable mask-composite */
mask-composite: exclude;
}
}

View file

@ -1,5 +1,4 @@
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
import 'cropperjs' // This adds all of the cropperjs's components into DOM
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faCircleNotch
@ -11,86 +10,47 @@ library.add(
const ImageCropper = {
props: {
trigger: {
type: [String, window.Element],
required: true
},
submitHandler: {
type: Function,
required: true
},
cropperOptions: {
type: Object,
default () {
return {
aspectRatio: 1,
autoCropArea: 1,
viewMode: 1,
movable: false,
zoomable: false,
guides: false
}
}
},
// Mime-types to accept, i.e. which filetypes to accept (.gif, .png, etc.)
mimes: {
type: String,
default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon'
},
saveButtonLabel: {
type: String
},
saveWithoutCroppingButtonlabel: {
type: String
},
cancelButtonLabel: {
type: String
// Fixed aspect-ratio for selection box
aspectRatio: {
type: Number
}
},
data () {
return {
cropper: undefined,
dataUrl: undefined,
filename: undefined,
submitting: false
}
},
computed: {
saveText () {
return this.saveButtonLabel || this.$t('image_cropper.save')
},
saveWithoutCroppingText () {
return this.saveWithoutCroppingButtonlabel || this.$t('image_cropper.save_without_cropping')
},
cancelText () {
return this.cancelButtonLabel || this.$t('image_cropper.cancel')
filename: undefined
}
},
emits: [
'submit', // cropping complete or uncropped image returned
'close', // cropper is closed
],
methods: {
destroy () {
if (this.cropper) {
this.cropper.destroy()
}
this.$refs.input.value = ''
this.dataUrl = undefined
this.$emit('close')
},
submit (cropping = true) {
this.submitting = true
this.submitHandler(cropping && this.cropper, this.file)
.then(() => this.destroy())
.finally(() => {
this.submitting = false
})
let cropperPromise
if (cropping) {
cropperPromise = this.$refs.cropperSelection.$toCanvas()
} else {
cropperPromise = Promise.resolve()
}
cropperPromise.then(canvas => {
this.$emit('submit', { canvas, file: this.file })
})
},
pickImage () {
this.$refs.input.click()
},
createCropper () {
this.cropper = new Cropper(this.$refs.img, this.cropperOptions)
},
getTriggerDOM () {
return typeof this.trigger === 'object' ? this.trigger : document.querySelector(this.trigger)
},
readFile () {
const fileInput = this.$refs.input
if (fileInput.files != null && fileInput.files[0] != null) {
@ -103,26 +63,37 @@ const ImageCropper = {
reader.readAsDataURL(this.file)
this.$emit('changed', this.file, reader)
}
},
inSelection(selection, maxSelection) {
return (
selection.x >= maxSelection.x
&& selection.y >= maxSelection.y
&& (selection.x + selection.width) <= (maxSelection.x + maxSelection.width)
&& (selection.y + selection.height) <= (maxSelection.y + maxSelection.height)
)
},
onCropperSelectionChange(event) {
const cropperCanvas = this.$refs.cropperCanvas
const cropperCanvasRect = cropperCanvas.getBoundingClientRect()
const selection = event.detail
const maxSelection = {
x: 0,
y: 0,
width: cropperCanvasRect.width,
height: cropperCanvasRect.height,
}
if (!this.inSelection(selection, maxSelection)) {
event.preventDefault();
}
}
},
mounted () {
// listen for click event on trigger
const trigger = this.getTriggerDOM()
if (!trigger) {
this.$emit('error', 'No image make trigger found.', 'user')
} else {
trigger.addEventListener('click', this.pickImage)
}
// listen for input file changes
const fileInput = this.$refs.input
fileInput.addEventListener('change', this.readFile)
},
beforeUnmount: function () {
// remove the event listeners
const trigger = this.getTriggerDOM()
if (trigger) {
trigger.removeEventListener('click', this.pickImage)
}
const fileInput = this.$refs.input
fileInput.removeEventListener('change', this.readFile)
}

View file

@ -1,42 +1,53 @@
<template>
<div class="image-cropper">
<div v-if="dataUrl">
<div class="image-cropper-image-container">
<img
ref="img"
<div class="image">
<cropper-canvas
ref="cropperCanvas"
background
class="image-cropper-canvas"
height="100%"
>
<cropper-image
v-if="dataUrl"
ref="cropperImage"
:src="dataUrl"
alt=""
@load.stop="createCropper"
alt="Picture"
class="image-cropper-image"
translatable
scalable
/>
<cropper-shade hidden />
<cropper-handle
action="select"
plain
/>
<cropper-selection
ref="cropperSelection"
initial-coverage="0.9"
:aspect-ratio="aspectRatio"
movable
resizable
@change="onCropperSelectionChange"
>
</div>
<div class="image-cropper-buttons-wrapper">
<button
class="button-default btn"
type="button"
:disabled="submitting"
@click="submit()"
v-text="saveText"
/>
<button
class="button-default btn"
type="button"
:disabled="submitting"
@click="destroy"
v-text="cancelText"
/>
<button
class="button-default btn"
type="button"
:disabled="submitting"
@click="submit(false)"
v-text="saveWithoutCroppingText"
/>
<FAIcon
v-if="submitting"
spin
icon="circle-notch"
/>
</div>
<cropper-grid
role="grid"
covered
/>
<cropper-crosshair centered />
<cropper-handle
action="move"
theme-color="rgba(255, 255, 255, 0.35)"
/>
<cropper-handle action="n-resize" />
<cropper-handle action="e-resize" />
<cropper-handle action="s-resize" />
<cropper-handle action="w-resize" />
<cropper-handle action="ne-resize" />
<cropper-handle action="nw-resize" />
<cropper-handle action="se-resize" />
<cropper-handle action="sw-resize" />
</cropper-selection>
</cropper-canvas>
</div>
<input
ref="input"
@ -51,24 +62,24 @@
<style lang="scss">
.image-cropper {
&-img-input {
display: flex;
flex-direction: column;
&-canvas, .image {
height: 100%;
}
& &-img-input {
display: none;
}
&-image-container {
position: relative;
img {
display: block;
max-width: 100%;
}
}
&-buttons-wrapper {
margin-top: 10px;
display: grid;
grid-gap: 0.5em;
grid-template-columns: 1fr 1fr 1fr;
button {
margin-top: 5px;
margin-top: 1em;
}
}
}

View file

@ -0,0 +1,58 @@
import localeService from '../../services/locale/locale.service.js'
import Select from '../select/select.vue'
import ProfileSettingIndicator from 'src/components/settings_modal/helpers/profile_setting_indicator.vue'
export default {
components: {
Select,
ProfileSettingIndicator
},
props: {
// List of languages (or just one language)
modelValue: {
type: [Array, String],
required: true
},
// Is this setting stored in user profile (true) or elsewhere (false)
// Doesn't affect storage, just shows an icon if true
profile: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue'],
computed: {
languages () {
return localeService.languages
},
controlledLanguage: {
get: function () {
return Array.isArray(this.modelValue) ? this.modelValue : [this.modelValue]
},
set: function (val) {
this.$emit('update:modelValue', val)
}
}
},
methods: {
getLanguageName (code) {
return localeService.getLanguageName(code)
},
addLanguage () {
this.controlledLanguage = [...this.controlledLanguage, '']
},
setLanguageAt (index, val) {
const lang = [...this.controlledLanguage]
lang[index] = val
this.controlledLanguage = lang
},
removeLanguageAt (index) {
const lang = [...this.controlledLanguage]
lang.splice(index, 1)
this.controlledLanguage = lang
}
}
}

View file

@ -1,7 +1,8 @@
<template>
<div class="interface-language-switcher">
<label>
{{ promptText }}
<slot />
<ProfileSettingIndicator :is-profile="profile" />
</label>
<ul class="setting-list">
<li
@ -44,64 +45,7 @@
</div>
</template>
<script>
import localeService from '../../services/locale/locale.service.js'
import Select from '../select/select.vue'
export default {
components: {
// eslint-disable-next-line vue/no-reserved-component-names
Select
},
props: {
promptText: {
type: String,
required: true
},
language: {
type: [Array, String],
required: true
},
setLanguage: {
type: Function,
required: true
}
},
computed: {
languages () {
return localeService.languages
},
controlledLanguage: {
get: function () {
return Array.isArray(this.language) ? this.language : [this.language]
},
set: function (val) {
this.setLanguage(val)
}
}
},
methods: {
getLanguageName (code) {
return localeService.getLanguageName(code)
},
addLanguage () {
this.controlledLanguage = [...this.controlledLanguage, '']
},
setLanguageAt (index, val) {
const lang = [...this.controlledLanguage]
lang[index] = val
this.controlledLanguage = lang
},
removeLanguageAt (index) {
const lang = [...this.controlledLanguage]
lang.splice(index, 1)
this.controlledLanguage = lang
}
}
}
</script>
<script src="./interface_language_switcher.js"></script>
<style lang="scss">
.interface-language-switcher {

View file

@ -68,10 +68,13 @@
margin: 0.5em 0 0;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
overflow-wrap: break-word;
text-wrap: pretty;
line-height: 1.2em;
// cap description at 3 lines, the 1px is to clean up some stray pixels
// TODO: fancier fade-out at the bottom to show off that it's too long?
/* cap description at 3 lines, the 1px is to clean up some stray pixels
TODO: fancier fade-out at the bottom to show off that it's too long?
*/
max-height: calc(1.2em * 3 - 1px);
}

View file

@ -1,48 +0,0 @@
export default {
name: 'ListItem',
selector: '.list-item',
states: {
active: '.-active',
hover: ':is(:hover, :focus-visible, :has(:focus-visible)):not(.-non-interactive)'
},
validInnerComponents: [
'Text',
'Link',
'Icon',
'Border',
'Button',
'ButtonUnstyled',
'RichContent',
'Input',
'Avatar'
],
defaultRules: [
{
directives: {
background: '--bg',
opacity: 0
}
},
{
state: ['active'],
directives: {
background: '--inheritedBackground, 10',
opacity: 1
}
},
{
state: ['hover'],
directives: {
background: '--inheritedBackground, 10',
opacity: 1
}
},
{
state: ['hover', 'active'],
directives: {
background: '--inheritedBackground, 20',
opacity: 1
}
}
]
}

View file

@ -1,7 +1,8 @@
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
import { mapStores } from 'pinia'
import { mapState } from 'vuex'
import { mapStores, mapActions, mapState as mapPiniaState } from 'pinia'
import oauthApi from '../../services/new_api/oauth.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useAuthFlowStore } from 'src/stores/auth_flow.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes
@ -25,13 +26,10 @@ const LoginForm = {
instance: state => state.instance,
loggingIn: state => state.users.loggingIn,
}),
...mapGetters(
'authFlow', ['requiredPassword', 'requiredToken', 'requiredMFA']
)
...mapPiniaState(useAuthFlowStore, ['requiredPassword', 'requiredToken', 'requiredMFA'])
},
methods: {
...mapMutations('authFlow', ['requireMFA']),
...mapActions({ login: 'authFlow/login' }),
...mapActions(useAuthFlowStore, ['requireMFA', 'login']),
submit () {
this.isTokenAuth ? this.submitToken() : this.submitPassword()
},

View file

@ -93,4 +93,4 @@
<script src="./login_form.js"></script>
<style src="./login_form.scss"/>
<style src="./login_form.scss" />

View file

@ -168,9 +168,10 @@ $modal-view-button-icon-margin: 0.5em;
flex: 0 0 auto;
overflow-y: auto;
min-height: 1em;
max-width: 500px;
max-width: 35.8em;
max-height: 9.5em;
word-break: break-all;
overflow-wrap: break-word;
text-wrap: pretty;
}
.modal-image {

View file

@ -7,7 +7,6 @@
& .new,
& .original {
display: inline;
border-radius: 2px;
}
.mention-avatar {
@ -27,7 +26,6 @@
top: 100%;
left: 0;
height: 100%;
word-wrap: normal;
white-space: nowrap;
transition: opacity 0.2s ease;
z-index: 1;

View file

@ -1,5 +1,6 @@
.MentionsLine {
word-break: break-all;
overflow-wrap: break-word;
text-wrap: pretty;
.mention-link:not(:first-child)::before {
content: " ";

View file

@ -4,11 +4,7 @@ export default {
validInnerComponents: [
'Text',
'Icon',
'Input',
'Border',
'ButtonUnstyled',
'Badge',
'Avatar'
'Border'
],
states: {
hover: ':is(:hover, :focus-visible, :has(:focus-visible)):not(.disabled)',

View file

@ -1,7 +1,8 @@
import mfaApi from '../../services/new_api/mfa.js'
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
import { mapStores } from 'pinia'
import { mapState } from 'vuex'
import { mapStores, mapActions, mapState as mapPiniaState } from 'pinia'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useAuthFlowStore } from 'src/stores/auth_flow.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes
@ -17,8 +18,8 @@ export default {
error: false
}),
computed: {
...mapGetters({
authSettings: 'authFlow/settings'
...mapPiniaState(useAuthFlowStore, {
authSettings: store => store.settings
}),
...mapStores(useOAuthStore),
...mapState({
@ -26,8 +27,7 @@ export default {
})
},
methods: {
...mapMutations('authFlow', ['requireTOTP', 'abortMFA']),
...mapActions({ login: 'authFlow/login' }),
...mapActions(useAuthFlowStore, ['requireTOTP', 'abortMFA', 'login']),
clearError () { this.error = false },
focusOnCodeInput () {

View file

@ -1,7 +1,8 @@
import mfaApi from '../../services/new_api/mfa.js'
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
import { mapStores } from 'pinia'
import { mapState } from 'vuex'
import { mapStores, mapActions, mapState as mapPiniaState } from 'pinia'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useAuthFlowStore } from 'src/stores/auth_flow.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes
@ -17,8 +18,8 @@ export default {
error: false
}),
computed: {
...mapGetters({
authSettings: 'authFlow/settings'
...mapPiniaState(useAuthFlowStore, {
authSettings: store => store.settings
}),
...mapStores(useOAuthStore),
...mapState({
@ -26,8 +27,7 @@ export default {
})
},
methods: {
...mapMutations('authFlow', ['requireRecovery', 'abortMFA']),
...mapActions({ login: 'authFlow/login' }),
...mapActions(useAuthFlowStore, ['requireRecovery', 'abortMFA', 'login']),
clearError () { this.error = false },
focusOnCodeInput () {

View file

@ -2,18 +2,7 @@ export default {
name: 'MobileDrawer',
selector: '.mobile-drawer',
validInnerComponents: [
'Text',
'Link',
'Icon',
'Border',
'Button',
'ButtonUnstyled',
'Input',
'PanelHeader',
'MenuItem',
'Notification',
'Alert',
'UserCard'
'MenuItem'
],
defaultRules: [
{
@ -21,21 +10,6 @@ export default {
background: '--bg',
backgroundNoCssColor: 'yes'
}
},
{
component: 'PanelHeader',
parent: { component: 'MobileDrawer' },
directives: {
background: '--fg',
shadow: [{
x: 0,
y: 0,
blur: 4,
spread: 0,
color: '#000000',
alpha: 0.6
}]
}
}
]
}

View file

@ -1,14 +1,19 @@
import SideDrawer from '../side_drawer/side_drawer.vue'
import Notifications from '../notifications/notifications.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import GestureService from '../../services/gesture_service/gesture_service'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import {
unseenNotificationsFromStore,
countExtraNotifications
} from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import { mapGetters } from 'vuex'
import { mapState } from 'pinia'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes,
@ -18,7 +23,6 @@ import {
faMinus,
faCheckDouble
} from '@fortawesome/free-solid-svg-icons'
import { useAnnouncementsStore } from 'src/stores/announcements'
library.add(
faTimes,
@ -71,10 +75,9 @@ const MobileNav = {
return this.$route.name === 'chat'
},
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
...mapGetters(['unreadChatCount']),
chatsPinned () {
return new Set(this.$store.state.serverSideStorage.prefsStorage.collections.pinnedNavItems).has('chats')
},
...mapState(useServerSideStorageStore, {
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems).has('chats')
}),
shouldConfirmLogout () {
return this.$store.getters.mergedConfig.modalOnLogout
},

View file

@ -220,8 +220,7 @@
margin-top: 3.5em;
width: 100vw;
height: calc(100vh - var(--navbar-height));
overflow-x: hidden;
overflow-y: scroll;
overflow: hidden scroll;
.notifications {
padding: 0;

View file

@ -42,7 +42,7 @@
}
}
@media all and (min-width: 801px) {
@media all and (width >= 801px) {
.new-status-button:not(.always-show) {
display: none;
}

View file

@ -41,10 +41,7 @@ export default {
.modal-view {
z-index: var(--ZI_modals);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
display: flex;
justify-content: center;
align-items: center;

View file

@ -208,6 +208,8 @@
}
.moderation-tools-button {
white-space: nowrap;
svg,
i {
font-size: 0.8em;

View file

@ -4,13 +4,13 @@
table {
width: 100%;
text-align: left;
padding-left: 10px;
padding-bottom: 20px;
padding-left: 0.5em;
padding-bottom: 1.1em;
th,
td {
width: 180px;
max-width: 360px;
width: 11em;
max-width: 25em;
overflow: hidden;
vertical-align: text-top;
}

View file

@ -1,12 +1,8 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
const MuteCard = {
props: ['userId'],
data () {
return {
progress: false
}
},
computed: {
user () {
return this.$store.getters.findUser(this.userId)
@ -16,23 +12,26 @@ const MuteCard = {
},
muted () {
return this.relationship.muting
},
muteExpiryAvailable () {
return this.user.mute_expires_at !== undefined
},
muteExpiry () {
return this.user.mute_expires_at == null
? this.$t('user_card.mute_expires_forever')
: this.$t('user_card.mute_expires_at', [new Date(this.user.mute_expires_at).toLocaleString()])
}
},
components: {
BasicUserCard
BasicUserCard,
UserTimedFilterModal
},
methods: {
unmuteUser () {
this.progress = true
this.$store.dispatch('unmuteUser', this.userId).then(() => {
this.progress = false
})
this.$store.dispatch('unmuteUser', this.userId)
},
muteUser () {
this.progress = true
this.$store.dispatch('muteUser', this.userId).then(() => {
this.progress = false
})
this.$refs.timedMuteDialog.optionallyPrompt()
}
}
}

View file

@ -1,33 +1,35 @@
<template>
<basic-user-card :user="user">
<div class="mute-card-content-container">
<span
v-if="muted && muteExpiryAvailable"
class="alert neutral"
>
{{ muteExpiry }}
</span>
{{ ' ' }}
<button
v-if="muted"
class="btn button-default"
:disabled="progress"
@click="unmuteUser"
>
<template v-if="progress">
{{ $t('user_card.unmute_progress') }}
</template>
<template v-else>
{{ $t('user_card.unmute') }}
</template>
{{ $t('user_card.unmute') }}
</button>
<button
v-else
class="btn button-default"
:disabled="progress"
@click="muteUser"
>
<template v-if="progress">
{{ $t('user_card.mute_progress') }}
</template>
<template v-else>
{{ $t('user_card.mute') }}
</template>
{{ $t('user_card.mute') }}
</button>
</div>
<teleport to="#modal">
<UserTimedFilterModal
ref="timedMuteDialog"
:user="user"
:is-mute="true"
/>
</teleport>
</basic-user-card>
</template>

View file

@ -7,12 +7,15 @@ import { filterNavigation } from 'src/components/navigation/filter.js'
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faUsers,
faGlobe,
faCity,
faBookmark,
faEnvelope,
faChevronDown,
@ -29,6 +32,7 @@ import {
library.add(
faUsers,
faGlobe,
faCity,
faBookmark,
faEnvelope,
faChevronDown,
@ -76,19 +80,19 @@ const NavPanel = {
this.editMode = !this.editMode
},
toggleCollapse () {
this.$store.commit('setPreference', { path: 'simple.collapseNav', value: !this.collapsed })
this.$store.dispatch('pushServerSideStorage')
useServerSideStorageStore().setPreference({ path: 'simple.collapseNav', value: !this.collapsed })
useServerSideStorageStore().pushServerSideStorage()
},
isPinned (item) {
return this.pinnedItems.has(item)
},
togglePin (item) {
if (this.isPinned(item)) {
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value: item })
useServerSideStorageStore().removeCollectionPreference({ path: 'collections.pinnedNavItems', value: item })
} else {
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value: item })
useServerSideStorageStore().addCollectionPreference({ path: 'collections.pinnedNavItems', value: item })
}
this.$store.dispatch('pushServerSideStorage')
useServerSideStorageStore().pushServerSideStorage()
}
},
computed: {
@ -96,20 +100,25 @@ const NavPanel = {
unreadAnnouncementCount: 'unreadAnnouncementCount',
supportsAnnouncements: store => store.supportsAnnouncements
}),
...mapPiniaState(useServerSideStorageStore, {
collapsed: store => store.prefsStorage.simple.collapseNav,
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems)
}),
...mapState({
currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private,
federating: state => state.instance.federating,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems),
collapsed: state => state.serverSideStorage.prefsStorage.simple.collapseNav,
bookmarkFolders: state => state.instance.pleromaBookmarkFoldersAvailable
bookmarkFolders: state => state.instance.pleromaBookmarkFoldersAvailable,
bubbleTimeline: state => state.instance.localBubbleInstances.length > 0
}),
timelinesItems () {
return filterNavigation(
Object
.entries({ ...TIMELINES })
// do not show in timeliens list since it's in a better place now
.filter(([key]) => key !== 'bookmarks')
.map(([k, v]) => ({ ...v, name: k })),
{
hasChats: this.pleromaChatMessagesAvailable,
@ -117,6 +126,7 @@ const NavPanel = {
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser,
supportsBubbleTimeline: this.bubbleTimeline,
supportsBookmarkFolders: this.bookmarkFolders
}
)
@ -132,6 +142,7 @@ const NavPanel = {
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser,
supportsBubbleTimeline: this.bubbleTimeline,
supportsBookmarkFolders: this.bookmarkFolders
}
)

View file

@ -1,4 +1,12 @@
export const filterNavigation = (list = [], { hasChats, hasAnnouncements, isFederating, isPrivate, currentUser, supportsBookmarkFolders }) => {
export const filterNavigation = (list = [], {
hasChats,
hasAnnouncements,
isFederating,
isPrivate,
currentUser,
supportsBookmarkFolders,
supportsBubbleTimeline
}) => {
return list.filter(({ criteria, anon, anonRoute }) => {
const set = new Set(criteria || [])
if (!isFederating && set.has('federating')) return false
@ -7,6 +15,8 @@ export const filterNavigation = (list = [], { hasChats, hasAnnouncements, isFede
if ((!currentUser || !currentUser.locked) && set.has('lockedUser')) return false
if (!hasChats && set.has('chats')) return false
if (!hasAnnouncements && set.has('announcements')) return false
if (!supportsBubbleTimeline && set.has('supportsBubbleTimeline')) return false
if (!supportsBookmarkFolders && set.has('supportsBookmarkFolders')) return false
if (supportsBookmarkFolders && set.has('!supportsBookmarkFolders')) return false
return true
})
@ -19,11 +29,11 @@ export const getListEntries = store => store.allLists.map(list => ({
iconLetter: list.title[0]
}))
export const getBookmarkFolderEntries = store => store.allFolders.map(folder => ({
export const getBookmarkFolderEntries = store => store.allFolders ? store.allFolders.map(folder => ({
name: 'bookmark-folder-' + folder.id,
routeObject: { name: 'bookmark-folder', params: { id: folder.id } },
labelRaw: folder.name,
iconEmoji: folder.emoji,
iconEmojiUrl: folder.emoji_url,
iconLetter: folder.name[0]
}))
})) : []

View file

@ -27,6 +27,13 @@ export const TIMELINES = {
label: 'nav.public_tl',
criteria: ['!private']
},
bubble: {
route: 'bubble',
anon: true,
icon: 'city',
label: 'nav.bubble',
criteria: ['!private', 'federating', 'supportsBubbleTimeline']
},
twkn: {
route: 'public-external-timeline',
anon: true,
@ -34,11 +41,11 @@ export const TIMELINES = {
label: 'nav.twkn',
criteria: ['!private', 'federating']
},
// bookmarks are still technically a timeline so we should show it in the dropdown
bookmarks: {
route: 'bookmarks',
icon: 'bookmark',
label: 'nav.bookmarks',
criteria: ['!supportsBookmarkFolders']
},
favorites: {
routeObject: { name: 'user-profile', query: { tab: 'favorites' } },
@ -53,6 +60,15 @@ export const TIMELINES = {
}
export const ROOT_ITEMS = {
bookmarks: {
route: 'bookmarks',
icon: 'bookmark',
label: 'nav.bookmarks',
// shows bookmarks entry in a better suited location
// hides it when bookmark folders are supported since
// we show custom component instead of it
criteria: ['!supportsBookmarkFolders']
},
interactions: {
route: 'interactions',
icon: 'bell',

View file

@ -3,8 +3,10 @@ import { routeTo } from 'src/components/navigation/navigation.js'
import OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faThumbtack } from '@fortawesome/free-solid-svg-icons'
import { mapStores } from 'pinia'
import { mapStores, mapState as mapPiniaState } from 'pinia'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
library.add(faThumbtack)
@ -19,11 +21,11 @@ const NavigationEntry = {
},
togglePin (value) {
if (this.isPinned(value)) {
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value })
useServerSideStorageStore().removeCollectionPreference({ path: 'collections.pinnedNavItems', value })
} else {
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value })
useServerSideStorageStore().addCollectionPreference({ path: 'collections.pinnedNavItems', value })
}
this.$store.dispatch('pushServerSideStorage')
useServerSideStorageStore().pushServerSideStorage()
}
},
computed: {
@ -35,9 +37,11 @@ const NavigationEntry = {
},
...mapStores(useAnnouncementsStore),
...mapState({
currentUser: state => state.users.currentUser,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
})
currentUser: state => state.users.currentUser
}),
...mapPiniaState(useServerSideStorageStore, {
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems)
}),
}
}

View file

@ -9,6 +9,7 @@ import { library } from '@fortawesome/fontawesome-svg-core'
import {
faUsers,
faGlobe,
faCity,
faBookmark,
faEnvelope,
faComments,
@ -20,10 +21,12 @@ import {
import { useListsStore } from 'src/stores/lists'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
library.add(
faUsers,
faGlobe,
faCity,
faBookmark,
faEnvelope,
faComments,
@ -54,7 +57,10 @@ const NavPanel = {
supportsAnnouncements: store => store.supportsAnnouncements
}),
...mapPiniaState(useBookmarkFoldersStore, {
bookmarks: getBookmarkFolderEntries
bookmarks: getBookmarkFolderEntries,
}),
...mapPiniaState(useServerSideStorageStore, {
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems)
}),
...mapState({
currentUser: state => state.users.currentUser,
@ -62,7 +68,7 @@ const NavPanel = {
privateMode: state => state.instance.private,
federating: state => state.instance.federating,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
bubbleTimeline: state => state.instance.localBubbleInstances.length > 0
}),
pinnedList () {
if (!this.currentUser) {
@ -76,7 +82,9 @@ const NavPanel = {
hasAnnouncements: this.supportsAnnouncements,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser
currentUser: this.currentUser,
supportsBubbleTimeline: this.bubbleTimeline,
supportsBookmarkFolders: this.bookmarks
})
}
return filterNavigation(
@ -95,6 +103,8 @@ const NavPanel = {
{
hasChats: this.pleromaChatMessagesAvailable,
hasAnnouncements: this.supportsAnnouncements,
supportsBubbleTimeline: this.bubbleTimeline,
supportsBookmarkFolders: this.bookmarks,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser

View file

@ -42,6 +42,7 @@ library.add(
const Notification = {
data () {
return {
selecting: false,
statusExpanded: false,
unmuted: false,
showingApproveConfirmDialog: false,
@ -62,10 +63,35 @@ const Notification = {
UserLink,
ConfirmModal
},
mounted () {
document.addEventListener('selectionchange', this.onContentSelect)
},
unmounted () {
document.removeEventListener('selectionchange', this.onContentSelect)
},
methods: {
toggleStatusExpanded () {
if (!this.expandable) return
this.statusExpanded = !this.statusExpanded
},
onContentSelect () {
const { isCollapsed, anchorNode, offsetNode } = document.getSelection()
if (isCollapsed) {
this.selecting = false
return
}
const within = this.$refs.root.contains(anchorNode) || this.$refs.root.contains(offsetNode)
if (within) {
this.selecting = true
} else {
this.selecting = false
}
},
onContentClick (e) {
if (!this.selecting && !e.target.closest('a') && !e.target.closest('button')) {
this.toggleStatusExpanded()
}
},
generateUserProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
},
@ -136,6 +162,9 @@ const Notification = {
const user = this.notification.from_profile
return highlightStyle(highlight[user.screen_name])
},
expandable () {
return (new Set(['like', 'pleroma:emoji_reaction', 'repeat'])).has(this.notification.type)
},
user () {
return this.$store.getters.findUser(this.notification.from_profile.id)
},

View file

@ -1,9 +1,14 @@
// TODO Copypaste from Status, should unify it somehow
.Notification {
border-bottom: 1px solid;
border-color: var(--border);
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
text-wrap: pretty;
.status-content {
cursor: pointer;
}
&.Status {
/* stylelint-disable-next-line declaration-no-important */
@ -31,8 +36,6 @@
& .status-username,
& .mute-thread,
& .mute-words {
word-wrap: normal;
word-break: normal;
white-space: nowrap;
}

View file

@ -6,12 +6,7 @@ export default {
'Link',
'Icon',
'Border',
'Button',
'ButtonUnstyled',
'RichContent',
'Input',
'Avatar',
'Attachment',
'PollGraph'
],
defaultRules: []

View file

@ -1,6 +1,7 @@
<template>
<article
v-if="notification.type === 'mention' || notification.type === 'status'"
ref="root"
>
<Status
class="Notification"
@ -9,9 +10,17 @@
@interacted="interacted"
/>
</article>
<article v-else>
<article
v-else
ref="root"
class="NotificationParent"
:class="{ '-expandable': expandable }"
>
<div
v-if="needMute && !unmuted"
:id="'notif-' +notification.id"
:aria-expanded="statusExpanded"
:aria-controls="'notif-' +notification.id"
class="Notification container -muted"
>
<small>
@ -62,6 +71,7 @@
:title="'@'+notification.from_profile.screen_name_ui"
:html="notification.from_profile.name_html"
:emoji="notification.from_profile.emoji"
:is-local="notification.from_profile.is_local"
/>
</bdi>
<!-- eslint-enable vue/no-v-html -->
@ -245,8 +255,11 @@
/>
<template v-else>
<StatusContent
class="status-content"
:compact="!statusExpanded"
:status="notification.status"
:collapse="!statusExpanded"
@click="onContentClick"
/>
</template>
</div>

View file

@ -13,10 +13,7 @@
.notification-overlay {
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
inset: 0;
pointer-events: none;
}
@ -131,7 +128,6 @@
.notification-details {
min-width: 0;
word-wrap: break-word;
line-height: var(--post-line-height);
position: relative;
overflow: hidden;
@ -164,7 +160,8 @@
}
h1 {
word-break: break-all;
overflow-wrap: break-word;
text-wrap: pretty;
margin: 0 0 0.3em;
padding: 0;
font-size: 1em;

View file

@ -6,29 +6,17 @@ export default {
'Link',
'Icon',
'Border',
'Button',
'ButtonUnstyled',
'Input',
'PanelHeader',
'MenuItem',
'Post',
'Notification',
'Alert',
'UserCard',
'Chat',
'Attachment',
'Tab',
'ListItem'
'MenuItem'
],
validInnerComponentsLite: [
'Text',
'Link',
'Icon',
'Border',
'Button',
'Input',
'PanelHeader',
'Alert'
'PanelHeader'
],
defaultRules: [
{

View file

@ -7,9 +7,7 @@ export default {
'Icon',
'Button',
'ButtonUnstyled',
'Badge',
'Alert',
'Avatar'
'Alert'
],
defaultRules: [
{

View file

@ -26,7 +26,8 @@
align-items: center;
padding: 0.1em 0.25em;
z-index: 1;
word-break: break-word;
overflow-wrap: break-word;
text-wrap: pretty;
}
.result-percentage {

View file

@ -6,16 +6,7 @@ export default {
modal: '.modal'
},
validInnerComponents: [
'Text',
'Link',
'Icon',
'Border',
'Button',
'ButtonUnstyled',
'Input',
'MenuItem',
'Post',
'UserCard'
'MenuItem'
],
defaultRules: [
{

View file

@ -15,11 +15,7 @@
&::after {
content: "";
position: absolute;
top: -1px;
bottom: -1px;
left: -1px;
right: -1px;
z-index: -1px;
inset: -1px;
box-shadow: var(--_shadow);
pointer-events: none;
}
@ -64,11 +60,14 @@
}
.extra-button {
border-left: 1px solid var(--icon);
border-left: 1px solid;
border-image-source: linear-gradient(to bottom, transparent 0%, var(--icon) var(--__horizontal-gap) calc(100% - var(--__horizontal-gap)), transparent 100%);
border-image-slice: 1;
padding-left: calc(var(--__horizontal-gap) - 1px);
border-right: var(--__horizontal-gap) solid transparent;
border-top: var(--__horizontal-gap) solid transparent;
border-bottom: var(--__horizontal-gap) solid transparent;
padding-right: var(--__horizontal-gap);
padding-top: var(--__horizontal-gap);
padding-bottom: var(--__horizontal-gap);
max-width: fit-content;
}
.main-button {

View file

@ -363,12 +363,6 @@ const PostStatusForm = {
}
},
safeToSaveDraft () {
console.log('safe', (
this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files?.length ||
this.newStatus.hasPoll
) && this.saveable)
return (
this.newStatus.status ||
this.newStatus.spoilerText ||
@ -732,10 +726,6 @@ const PostStatusForm = {
scrollerRef.scrollTop = targetScroll
}
},
showEmojiPicker () {
this.$refs.textarea.focus()
this.$refs['emoji-input'].triggerShowPicker()
},
clearError () {
this.error = null
},

View file

@ -41,10 +41,12 @@
.form-bottom-left {
display: flex;
flex: 1;
padding-right: 7px;
margin-right: 7px;
max-width: 10em;
gap: 1.5em;
button {
padding: 0.5em;
margin: -0.5em;
}
}
.preview-heading {
@ -102,10 +104,16 @@
.visibility-tray {
display: flex;
justify-content: space-between;
padding-top: 5px;
padding-top: 0.5em;
align-items: baseline;
}
.visibility-notice {
border: 1px solid var(--border);
border-radius: var(--roundness);
padding: 0.5em 1em
}
.visibility-notice.edit-warning {
> :first-child {
margin-top: 0;
@ -193,6 +201,10 @@
line-height: 1.85;
}
.inputs-wrapper {
padding: 0;
}
.input.form-post-body {
// TODO: make a resizable textarea component?
box-sizing: content-box; // needed for easier computation of dynamic size
@ -200,11 +212,13 @@
transition: min-height 200ms 100ms;
// stock padding + 1 line of text (for counter)
padding-bottom: calc(var(--_padding) + var(--post-line-height) * 1em);
padding-right: 1.5em;
// two lines of text
height: calc(var(--post-line-height) * 1em);
min-height: calc(var(--post-line-height) * 1em);
resize: none;
background: transparent;
text-wrap: stable;
&.scrollable-form {
overflow-y: auto;
@ -215,6 +229,10 @@
position: relative;
}
.subject-input {
border-bottom: 1px solid var(--border);
}
.character-counter {
position: absolute;
bottom: 0;

View file

@ -156,67 +156,68 @@
class="preview-status"
/>
</div>
<EmojiInput
v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
v-model="newStatus.spoilerText"
enable-emoji-picker
:suggest="emojiSuggestor"
class="input form-control"
>
<template #default="inputProps">
<input
v-model="newStatus.spoilerText"
type="text"
:placeholder="$t('post_status.content_warning')"
:disabled="posting && !optimisticPosting"
v-bind="propsToNative(inputProps)"
size="1"
class="input form-post-subject"
>
</template>
</EmojiInput>
<EmojiInput
ref="emoji-input"
v-model="newStatus.status"
:suggest="emojiUserSuggestor"
:placement="emojiPickerPlacement"
class="input form-control main-input"
enable-sticker-picker
enable-emoji-picker
hide-emoji-button
:newline-on-ctrl-enter="submitOnEnter"
@input="onEmojiInputInput"
@sticker-uploaded="addMediaFile"
@sticker-upload-failed="uploadFailed"
@shown="handleEmojiInputShow"
>
<template #default="inputProps">
<textarea
ref="textarea"
v-model="newStatus.status"
:placeholder="placeholder || $t('post_status.default')"
rows="1"
cols="1"
:disabled="posting && !optimisticPosting"
class="input form-post-body"
:class="{ 'scrollable-form': !!maxHeight }"
v-bind="propsToNative(inputProps)"
@keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
@keydown.meta.enter="postStatus($event, newStatus)"
@keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
@input="resize"
@compositionupdate="resize"
@paste="paste"
/>
<p
v-if="hasStatusLengthLimit"
class="character-counter faint"
:class="{ error: isOverLengthLimit }"
>
{{ charactersLeft }}
</p>
</template>
</EmojiInput>
<div class="input inputs-wrapper">
<EmojiInput
v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
v-model="newStatus.spoilerText"
enable-emoji-picker
:suggest="emojiSuggestor"
class="input form-control subject-input unstyled"
>
<template #default="inputProps">
<input
v-model="newStatus.spoilerText"
type="text"
:placeholder="$t('post_status.content_warning')"
:disabled="posting && !optimisticPosting"
v-bind="propsToNative(inputProps)"
size="1"
class="input form-post-subject unstyled"
>
</template>
</EmojiInput>
<EmojiInput
ref="emoji-input"
v-model="newStatus.status"
:suggest="emojiUserSuggestor"
:placement="emojiPickerPlacement"
class="input form-control main-input unstyled"
enable-sticker-picker
enable-emoji-picker
:newline-on-ctrl-enter="submitOnEnter"
@input="onEmojiInputInput"
@sticker-uploaded="addMediaFile"
@sticker-upload-failed="uploadFailed"
@shown="handleEmojiInputShow"
>
<template #default="inputProps">
<textarea
ref="textarea"
v-model="newStatus.status"
:placeholder="placeholder || $t('post_status.default')"
rows="1"
cols="1"
:disabled="posting && !optimisticPosting"
class="input form-post-body"
:class="{ 'scrollable-form': !!maxHeight }"
v-bind="propsToNative(inputProps)"
@keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
@keydown.meta.enter="postStatus($event, newStatus)"
@keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
@input="resize"
@compositionupdate="resize"
@paste="paste"
/>
<p
v-if="hasStatusLengthLimit"
class="character-counter faint"
:class="{ error: isOverLengthLimit }"
>
{{ charactersLeft }}
</p>
</template>
</EmojiInput>
</div>
<div
v-if="!disableScopeSelector"
class="visibility-tray"
@ -236,8 +237,9 @@
>
<Select
v-model="newStatus.contentType"
class="input form-control"
class="input form-control unstyled"
:attrs="{ 'aria-label': $t('post_status.content_type_selection') }"
unstyled="true"
>
<option
v-for="postFormat in postFormats"
@ -285,13 +287,6 @@
@upload-failed="uploadFailed"
@all-uploaded="finishedUploadingFiles"
/>
<button
class="emoji-icon button-unstyled"
:title="$t('emoji.add_emoji')"
@click="showEmojiPicker"
>
<FAIcon icon="smile-beam" />
</button>
<button
v-if="pollsAvailable"
class="poll-icon button-unstyled"

View file

@ -418,7 +418,7 @@
margin: 0.6em;
}
@media all and (max-width: 800px) {
@media all and (width <= 800px) {
.registration-form .container {
flex-direction: column-reverse;

View file

@ -2,7 +2,7 @@ import { unescape, flattenDeep } from 'lodash'
import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
import StillImage from 'src/components/still-image/still-image.vue'
import StillImageEmojiPopover from 'src/components/still-image/still-image-emoji-popover.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.js'
import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
@ -86,6 +86,24 @@ export default {
required: false,
type: Boolean,
default: false
},
// Collapse newlines
collapse: {
required: false,
type: Boolean,
default: false
},
/* Content comes from current instance
*
* This is used for emoji stealing popover.
* By default we assume it is, so that steal
* emoji button isn't shown where it probably
* should not be.
*/
isLocal: {
required: false,
type: Boolean,
default: true
}
},
// NEVER EVER TOUCH DATA INSIDE RENDER
@ -162,11 +180,14 @@ export default {
item,
this.emoji,
({ shortcode, url }) => {
return <StillImage
return <StillImageEmojiPopover
class="emoji img"
src={url}
title={`:${shortcode}:`}
alt={`:${shortcode}:`}
shortcode={shortcode}
isLocal={this.isLocal}
/>
}
)]
@ -281,11 +302,20 @@ export default {
const pass1 = convertHtmlToTree(html).map(processItem)
const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
// DO NOT USE SLOTS they cause a re-render feedback loop here.
// slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
// at least until vue3?
const result = <span class={['RichContent', this.faint ? '-faint' : '']}>
{ pass2 }
const result =
<span class={['RichContent', this.faint ? '-faint' : '']}>
{
this.collapse
? pass2.map(x => {
if (!Array.isArray(x)) return x.replace(/\n/g, ' ')
return x.map(y => y.type === 'br' ? ' ' : y)
})
: pass2
}
</span>
const event = {

View file

@ -2,6 +2,7 @@
font-family: var(--font);
&.-faint {
color: var(--text);
/* stylelint-disable declaration-no-important */
--text: var(--textFaint) !important;
--link: var(--linkFaint) !important;
@ -63,6 +64,11 @@
.img {
display: inline-block;
// fix vertical alignment of stealable emoji
button {
display: inline-flex;
}
}
.emoji {

View file

@ -1,20 +0,0 @@
export default {
name: 'RichContent',
selector: '.RichContent',
notEditable: true,
transparent: true,
validInnerComponents: [
'Text',
'FunText',
'Link'
],
defaultRules: [
{
directives: {
'--font': 'generic | inherit',
'--monoFont': 'generic | monospace',
textNoCssColor: 'yes'
}
}
]
}

View file

@ -0,0 +1,18 @@
const ScrollTopButton = {
props: {
fast: {
type: Boolean,
required: false,
default: false
}
},
methods: {
scrollToTop() {
const speed = this.fast ? 'instant' : 'smooth';
window.scrollTo({ top: 0, behavior: speed })
}
}
}
export default ScrollTopButton

View file

@ -0,0 +1,29 @@
<template>
<div class="rightside-button scroll-to-top">
<button
class="button-unstyled scroll-to-top-button"
type="button"
:title="$t('general.scroll_to_top')"
@click="scrollToTop"
>
<FALayers class="fa-scale-110 fa-old-padding-layer">
<FAIcon icon="arrow-up" />
<FAIcon
icon="minus"
transform="up-7"
/>
</FALayers>
</button>
</div>
</template>
<script src="./scroll_top_button.js"></script>
<style lang="scss">
.scroll-to-top {
display: none;
}
.-scrolled .scroll-to-top {
display: inline-block;
}
</style>

View file

@ -157,7 +157,7 @@
text-align: center;
}
@media all and (max-width: 800px) {
@media all and (width <= 800px) {
.search-nav-heading {
.tab-switcher .tabs .tab-wrapper {
display: block;

View file

@ -8,6 +8,7 @@ import Popover from 'components/popover/popover.vue'
import ConfirmModal from 'components/confirm_modal/confirm_modal.vue'
import ModifiedIndicator from '../helpers/modified_indicator.vue'
import EmojiEditingPopover from '../helpers/emoji_editing_popover.vue'
import { useInterfaceStore } from 'src/stores/interface'
const EmojiTab = {
components: {
@ -31,7 +32,10 @@ const EmojiTab = {
newPackName: '',
deleteModalVisible: false,
remotePackInstance: '',
remotePackDownloadAs: ''
remotePackDownloadAs: '',
remotePackURL: '',
remotePackFile: null
}
},
@ -141,9 +145,9 @@ const EmojiTab = {
})
},
updatePackFiles (newFiles) {
this.pack.files = newFiles
this.sortPackFiles(this.packName)
updatePackFiles (newFiles, packName) {
this.knownPacks[packName].files = newFiles
this.sortPackFiles(packName)
},
loadPacksPaginated (listFunction) {
@ -219,7 +223,7 @@ const EmojiTab = {
.then(data => data.json())
.then(resp => {
if (resp === 'ok') {
this.$refs.dlPackPopover.hidePopover()
this.$refs.downloadPackPopover.hidePopover()
return this.refreshPackList()
} else {
@ -231,8 +235,49 @@ const EmojiTab = {
this.remotePackDownloadAs = ''
})
},
downloadRemoteURLPack () {
this.$store.state.api.backendInteractor.downloadRemoteEmojiPackZIP({
url: this.remotePackURL, packName: this.newPackName
})
.then(data => data.json())
.then(resp => {
if (resp === 'ok') {
this.$refs.additionalRemotePopover.hidePopover()
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
}).then(() => {
this.packName = this.newPackName
this.newPackName = ''
this.remotePackURL = ''
})
},
downloadRemoteFilePack () {
this.$store.state.api.backendInteractor.downloadRemoteEmojiPackZIP({
file: this.remotePackFile[0], packName: this.newPackName
})
.then(data => data.json())
.then(resp => {
if (resp === 'ok') {
this.$refs.additionalRemotePopover.hidePopover()
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
}).then(() => {
this.packName = this.newPackName
this.newPackName = ''
this.remotePackURL = ''
})
},
displayError (msg) {
this.$store.useInterfaceStore().pushGlobalNotice({
useInterfaceStore().pushGlobalNotice({
messageKey: 'admin_dash.emoji.error',
messageArgs: [msg],
level: 'error'

View file

@ -29,7 +29,7 @@
.emoji-list {
display: flex;
flex-wrap: wrap;
gap: 1em 1em;
gap: 1em;
}
}

View file

@ -62,6 +62,64 @@
</template>
</Popover>
</button>
<button
class="button button-default emoji-panel-additional-actions"
@click="$refs.additionalRemotePopover.showPopover"
>
<FAIcon
icon="chevron-down"
/>
<Popover
ref="additionalRemotePopover"
popover-class="emoji-tab-edit-popover popover-default"
trigger="click"
placement="bottom"
bound-to-selector=".emoji-tab"
:bound-to="{ x: 'container' }"
:offset="{ y: 5 }"
>
<template #content>
<div class="emoji-tab-popover-input">
<h3>{{ $t('admin_dash.emoji.new_pack_name') }}</h3>
<input
v-model="newPackName"
:placeholder="$t('admin_dash.emoji.new_pack_name')"
class="input"
>
<h3>Import pack from URL</h3>
<input
v-model="remotePackURL"
class="input"
placeholder="Pack .zip URL"
>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
:disabled="newPackName.trim() === '' || remotePackURL.trim() === ''"
@click="downloadRemoteURLPack"
>
Import
</button>
<h3>Import pack from a file</h3>
<input
type="file"
accept="application/zip"
class="emoji-tab-popover-file input"
@change="remotePackFile = $event.target.files"
>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
:disabled="newPackName.trim() === '' || remotePackFile === null || remotePackFile.length === 0"
@click="downloadRemoteFilePack"
>
Import
</button>
</div>
</template>
</Popover>
</button>
</li>
<h3>{{ $t('admin_dash.emoji.emoji_packs') }}</h3>
@ -240,12 +298,12 @@
v-if="pack.remote !== undefined"
class="button button-default btn"
type="button"
@click="$refs.dlPackPopover.showPopover"
@click="$refs.downloadPackPopover.showPopover"
>
{{ $t('admin_dash.emoji.download_pack') }}
<Popover
ref="dlPackPopover"
ref="downloadPackPopover"
trigger="click"
placement="bottom"
bound-to-selector=".emoji-tab"
@ -329,11 +387,12 @@
ref="emojiPopovers"
:key="shortcode"
placement="top"
:title="$t('admin_dash.emoji.editing', [shortcode])"
:disabled="pack.remote !== undefined"
:title="$t(`admin_dash.emoji.${pack.remote === undefined ? 'editing' : 'copying'}`, [shortcode])"
:shortcode="shortcode"
:file="file"
:pack-name="packName"
:remote="pack.remote"
:known-local-packs="knownLocalPacks"
@update-pack-files="updatePackFiles"
@display-error="displayError"
>

View file

@ -5,6 +5,7 @@ import StringSetting from '../helpers/string_setting.vue'
import GroupSetting from '../helpers/group_setting.vue'
import Popover from 'src/components/popover/popover.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import { useInterfaceStore } from 'src/stores/interface'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import { library } from '@fortawesome/fontawesome-svg-core'
@ -80,7 +81,7 @@ const FrontendsTab = {
this.$store.dispatch('loadFrontendsStuff')
if (response.error) {
const reason = await response.error.json()
this.$store.useInterfaceStore().pushGlobalNotice({
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'admin_dash.frontend.failure_installing_frontend',
messageArgs: {
@ -90,7 +91,7 @@ const FrontendsTab = {
timeout: 5000
})
} else {
this.$store.useInterfaceStore().pushGlobalNotice({
useInterfaceStore().pushGlobalNotice({
level: 'success',
messageKey: 'admin_dash.frontend.success_installing_frontend',
messageArgs: {

View file

@ -13,15 +13,11 @@
// fix buttons showing through
z-index: 2;
opacity: 0.9;
top: 0;
bottom: 0;
left: 0;
right: 0;
inset: 0;
}
dd {
text-overflow: ellipsis;
word-wrap: nowrap;
white-space: nowrap;
overflow-x: hidden;
max-width: 10em;

View file

@ -7,7 +7,6 @@
popover-class="emoji-tab-edit-popover popover-default"
:bound-to="{ x: 'container' }"
:offset="{ y: 5 }"
:disabled="disabled"
:class="{'emoji-unsaved': isEdited}"
>
<template #trigger>
@ -30,15 +29,27 @@
<div
v-if="newUpload"
class="emoji-tab-popover-input"
class="emoji-tab-popover-new-upload"
>
<input
type="file"
accept="image/*"
class="emoji-tab-popover-file input"
@change="uploadFile = $event.target.files"
>
<h4>{{ $t('admin_dash.emoji.emoji_source') }}</h4>
<div class="emoji-tab-popover-input">
<input
type="file"
accept="image/*"
class="emoji-tab-popover-file input"
@change="uploadFile = $event.target.files"
>
</div>
<div class="emoji-tab-popover-input ">
<input
v-model="uploadURL"
:placeholder="$t('admin_dash.emoji.upload_url')"
class="emoji-data-input input"
>
</div>
</div>
<div>
<div class="emoji-tab-popover-input">
<label>
@ -63,16 +74,50 @@
</label>
</div>
<div
v-if="remote !== undefined"
class="emoji-tab-popover-input"
>
<label>
{{ $t('admin_dash.emoji.copy_to') }}
<SelectComponent
v-model="copyToPack"
class="form-control"
>
<option
value=""
disabled
hidden
>
{{ $t('admin_dash.emoji.emoji_pack') }}
</option>
<option
v-for="(pack, listPackName) in knownLocalPacks"
:key="listPackName"
:label="listPackName"
>
{{ listPackName }}
</option>
</SelectComponent>
</label>
</div>
<!--
For local emojis, disable the button if nothing was edited.
For remote emojis, also disable it if a local pack is not selected.
Remote emojis are processed by the same function that uploads new ones, as that is effectively what it does
-->
<button
class="button button-default btn"
type="button"
:disabled="newUpload ? uploadFile.length == 0 : !isEdited"
@click="newUpload ? uploadEmoji() : saveEditedEmoji()"
:disabled="saveButtonDisabled"
@click="(newUpload || remote !== undefined) ? uploadEmoji() : saveEditedEmoji()"
>
{{ $t('admin_dash.emoji.save') }}
</button>
<template v-if="!newUpload">
<template v-if="!newUpload && remote === undefined">
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
@ -107,19 +152,16 @@
import Popover from 'components/popover/popover.vue'
import ConfirmModal from 'components/confirm_modal/confirm_modal.vue'
import StillImage from 'components/still-image/still-image.vue'
import SelectComponent from 'components/select/select.vue'
export default {
components: { Popover, ConfirmModal, StillImage },
components: { Popover, ConfirmModal, StillImage, SelectComponent },
inject: ['emojiAddr'],
props: {
placement: {
type: String,
required: true
},
disabled: {
type: Boolean,
default: false
},
newUpload: Boolean,
@ -140,21 +182,35 @@ export default {
type: String,
// Only exists when this is not a new upload
default: ''
},
// Only exists for emojis from remote packs
remote: {
type: Object,
default: undefined
},
knownLocalPacks: {
type: Object,
default: undefined
}
},
emits: ['updatePackFiles', 'displayError'],
data () {
return {
uploadFile: [],
uploadURL: '',
editedShortcode: this.shortcode,
editedFile: this.file,
deleteModalVisible: false
deleteModalVisible: false,
copyToPack: ''
}
},
computed: {
emojiPreview () {
if (this.newUpload && this.uploadFile.length > 0) {
return URL.createObjectURL(this.uploadFile[0])
} else if (this.newUpload && this.uploadURL !== '') {
return this.uploadURL
} else if (!this.newUpload) {
return this.emojiAddr(this.file)
}
@ -163,6 +219,12 @@ export default {
},
isEdited () {
return !this.newUpload && (this.editedShortcode !== this.shortcode || this.editedFile !== this.file)
},
saveButtonDisabled() {
if (this.remote === undefined)
return this.newUpload ? (this.uploadURL === "" && this.uploadFile.length == 0) : !this.isEdited
else
return this.copyToPack === ""
}
},
methods: {
@ -181,9 +243,12 @@ export default {
}).then(resp => this.$emit('updatePackFiles', resp))
},
uploadEmoji () {
let packName = this.remote === undefined ? this.packName : this.copyToPack
this.$store.state.api.backendInteractor.addNewEmojiFile({
packName: this.packName,
file: this.uploadFile[0],
packName: packName,
file: this.remote === undefined
? (this.uploadURL !== "" ? this.uploadURL : this.uploadFile[0])
: this.emojiAddr(this.file),
shortcode: this.editedShortcode,
filename: this.editedFile
}).then(resp => resp.json()).then(resp => {
@ -192,7 +257,7 @@ export default {
return
}
this.$emit('updatePackFiles', resp)
this.$emit('updatePackFiles', resp, packName)
this.$refs.emojiPopover.hidePopover()
this.editedFile = ''
@ -215,7 +280,7 @@ export default {
return
}
this.$emit('updatePackFiles', resp)
this.$emit('updatePackFiles', resp, this.packName)
})
}
}
@ -228,9 +293,22 @@ export default {
padding-right: 0.5em;
padding-bottom: 0.5em;
.emoji-tab-popover-new-upload {
margin-bottom: 2em;
}
.emoji {
width: 32px;
height: 32px;
width: 2.3em;
height: 2.3em;
}
.Select {
display: inline-block;
}
h4 {
margin-bottom: 1em;
margin-top: 1em;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show more