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": [ "extends": [
"stylelint-rscss/config",
"stylelint-config-standard", "stylelint-config-standard",
"stylelint-config-recommended-scss", "stylelint-config-recommended-scss",
"stylelint-config-html", "stylelint-config-html",
@ -8,20 +7,13 @@
], ],
"rules": { "rules": {
"declaration-no-important": true, "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, "selector-class-pattern": null,
"import-notation": null, "import-notation": null,
"custom-property-pattern": null, "custom-property-pattern": null,
"keyframes-name-pattern": null, "keyframes-name-pattern": null,
"scss/operator-no-newline-after": 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": [ "declaration-block-no-redundant-longhand-properties": [
true, true,
{ {

View file

@ -2,6 +2,42 @@
All notable changes to this project will be documented in this file. 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/). 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 ## 2.8.0
### Changed ### 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 - 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 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 = ({ export const devSwPlugin = ({
swSrc, swSrc,
swDest, swDest,
@ -32,12 +37,16 @@ export const devSwPlugin = ({
const name = id.startsWith('/') ? id.slice(1) : id const name = id.startsWith('/') ? id.slice(1) : id
if (name === swDest) { if (name === swDest) {
return swFullSrc return swFullSrc
} else if (name === swEnvName) {
return swEnvNameResolved
} }
return null return null
}, },
async load (id) { async load (id) {
if (id === swFullSrc) { if (id === swFullSrc) {
return readFile(swFullSrc, 'utf-8') return readFile(swFullSrc, 'utf-8')
} else if (id === swEnvNameResolved) {
return getDevSwEnv()
} }
return null return null
}, },
@ -79,6 +88,21 @@ export const devSwPlugin = ({
contents: await getSWMessagesAsText() 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 const text = res.outputFiles[0].text
@ -126,6 +150,30 @@ export const buildSwPlugin = ({
configFile: false 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: { closeBundle: {
order: 'post', order: 'post',
sequential: true, sequential: true,

View file

@ -5,140 +5,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no"> <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="/static/config.json" as="fetch" crossorigin />
<link rel="preload" href="/api/pleroma/frontend_configurations" 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="/nodeinfo/2.1.json" as="fetch" crossorigin />
<link rel="preload" href="/api/v1/instance" 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" /> <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 --> <!-- putting styles here to avoid having to wait for styles to load up -->
<style id="splashscreen"> <link rel="stylesheet" id="splashscreen" href="/static/splash.css" />
#splash { <link rel="stylesheet" id="custom-styles-holder" type="text/css" href="/static/empty.css" />
--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>
<!--server-generated-meta--> <!--server-generated-meta-->
</head> </head>
<body style="margin: 0; padding: 0"> <body>
<noscript>To use Pleroma, please enable JavaScript.</noscript> <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 --> <!-- we are hiding entire graphic so no point showing credit -->
<div aria-hidden="true" id="splash-credit"> <div aria-hidden="true" id="splash-credit">
Art by pipivovott Art by pipivovott

View file

@ -1,6 +1,6 @@
{ {
"name": "pleroma_fe", "name": "pleroma_fe",
"version": "2.7.1", "version": "2.9.2",
"description": "Pleroma frontend, the default frontend of Pleroma social network server", "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>", "author": "Pleroma contributors <https://git.pleroma.social/pleroma/pleroma-fe/-/blob/develop/CONTRIBUTORS.md>",
"private": false, "private": false,
@ -17,96 +17,98 @@
"lint-fix": "eslint --fix src test/unit/specs test/e2e/specs" "lint-fix": "eslint --fix src test/unit/specs test/e2e/specs"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.27.0", "@babel/runtime": "7.28.3",
"@chenfengyuan/vue-qrcode": "2.0.0", "@chenfengyuan/vue-qrcode": "2.0.0",
"@fortawesome/fontawesome-svg-core": "6.7.2", "@fortawesome/fontawesome-svg-core": "6.7.2",
"@fortawesome/free-regular-svg-icons": "6.7.2", "@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2", "@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/vue-fontawesome": "3.0.8", "@fortawesome/vue-fontawesome": "3.1.1",
"@kazvmoe-infra/pinch-zoom-element": "1.2.0", "@kazvmoe-infra/pinch-zoom-element": "1.3.0",
"@kazvmoe-infra/unicode-emoji-json": "0.4.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/core": "2.0.3",
"@vuelidate/validators": "2.0.4", "@vuelidate/validators": "2.0.4",
"@web3-storage/parse-link-header": "^3.1.0", "@web3-storage/parse-link-header": "^3.1.0",
"body-scroll-lock": "3.1.5", "body-scroll-lock": "3.1.5",
"chromatism": "3.0.0", "chromatism": "3.0.0",
"click-outside-vue3": "4.0.1", "click-outside-vue3": "4.0.1",
"cropperjs": "1.6.2", "cropperjs": "2.0.1",
"escape-html": "1.0.3", "escape-html": "1.0.3",
"globals": "^16.0.0", "globals": "^16.0.0",
"hash-sum": "^2.0.0", "hash-sum": "^2.0.0",
"js-cookie": "3.0.5", "js-cookie": "3.0.5",
"localforage": "1.10.0", "localforage": "1.10.0",
"parse-link-header": "2.0.0", "parse-link-header": "2.0.0",
"phoenix": "1.7.21", "phoenix": "1.8.0",
"pinia": "^3.0.0", "pinia": "^3.0.0",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"querystring-es3": "0.2.1", "querystring-es3": "0.2.1",
"url": "0.11.4", "url": "0.11.4",
"utf8": "3.0.0", "utf8": "3.0.0",
"vue": "3.5.13", "uuid": "11.1.0",
"vue-i18n": "10", "vue": "3.5.19",
"vue-router": "4.5.0", "vue-i18n": "11",
"vue-router": "4.5.1",
"vue-virtual-scroller": "^2.0.0-beta.7", "vue-virtual-scroller": "^2.0.0-beta.7",
"vuex": "4.1.0" "vuex": "4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.26.10", "@babel/core": "7.28.3",
"@babel/eslint-parser": "7.27.0", "@babel/eslint-parser": "7.28.0",
"@babel/plugin-transform-runtime": "7.26.10", "@babel/plugin-transform-runtime": "7.28.3",
"@babel/preset-env": "7.26.9", "@babel/preset-env": "7.28.3",
"@babel/register": "7.25.9", "@babel/register": "7.28.3",
"@ungap/event-target": "0.2.4", "@ungap/event-target": "0.2.4",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1", "@vitejs/plugin-vue-jsx": "^4.1.1",
"@vitest/browser": "^3.0.7", "@vitest/browser": "^3.0.7",
"@vitest/ui": "^3.0.7", "@vitest/ui": "^3.0.7",
"@vue/babel-helper-vue-jsx-merge-props": "1.4.0", "@vue/babel-helper-vue-jsx-merge-props": "1.4.0",
"@vue/babel-plugin-jsx": "1.4.0", "@vue/babel-plugin-jsx": "1.5.0",
"@vue/compiler-sfc": "3.5.13", "@vue/compiler-sfc": "3.5.19",
"@vue/test-utils": "2.4.6", "@vue/test-utils": "2.4.6",
"autoprefixer": "10.4.21", "autoprefixer": "10.4.21",
"babel-plugin-lodash": "3.3.4", "babel-plugin-lodash": "3.3.4",
"chai": "5.2.0", "chai": "5.3.2",
"chalk": "5.4.1", "chalk": "5.6.0",
"chromedriver": "134.0.5", "chromedriver": "135.0.4",
"connect-history-api-fallback": "2.0.0", "connect-history-api-fallback": "2.0.0",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"custom-event-polyfill": "1.0.7", "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-config-standard": "17.1.0",
"eslint-formatter-friendly": "7.0.0", "eslint-formatter-friendly": "7.0.0",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.32.0",
"eslint-plugin-n": "17.17.0", "eslint-plugin-n": "17.21.3",
"eslint-plugin-promise": "7.2.1", "eslint-plugin-promise": "7.2.1",
"eslint-plugin-vue": "9.33.0", "eslint-plugin-vue": "10.4.0",
"eventsource-polyfill": "0.9.6", "eventsource-polyfill": "0.9.6",
"express": "4.21.2", "express": "5.1.0",
"function-bind": "1.1.2", "function-bind": "1.1.2",
"http-proxy-middleware": "3.0.3", "http-proxy-middleware": "3.0.5",
"iso-639-1": "3.1.5", "iso-639-1": "3.1.5",
"lodash": "4.17.21", "lodash": "4.17.21",
"msw": "2.7.3", "msw": "2.10.5",
"nightwatch": "3.12.1", "nightwatch": "3.12.2",
"playwright": "1.49.1", "playwright": "1.55.0",
"postcss": "8.5.3", "postcss": "8.5.6",
"postcss-html": "^1.5.0", "postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6", "postcss-scss": "^4.0.6",
"sass": "1.86.0", "sass": "1.89.2",
"selenium-server": "3.141.59", "selenium-server": "3.141.59",
"semver": "7.7.1", "semver": "7.7.2",
"serve-static": "2.2.0", "serve-static": "2.2.0",
"shelljs": "0.9.2", "shelljs": "0.10.0",
"sinon": "20.0.0", "sinon": "20.0.0",
"sinon-chai": "4.0.0", "sinon-chai": "4.0.0",
"stylelint": "14.16.1", "stylelint": "16.19.1",
"stylelint-config-html": "^1.1.0", "stylelint-config-html": "^1.1.0",
"stylelint-config-recommended-scss": "^8.0.0", "stylelint-config-recommended": "^16.0.0",
"stylelint-config-recommended-vue": "^1.4.0", "stylelint-config-recommended-scss": "^14.0.0",
"stylelint-config-standard": "29.0.0", "stylelint-config-recommended-vue": "^1.6.0",
"stylelint-rscss": "0.4.0", "stylelint-config-standard": "38.0.0",
"vite": "^6.1.0", "vite": "^6.1.0",
"vite-plugin-eslint2": "^5.0.3", "vite-plugin-eslint2": "^5.0.3",
"vite-plugin-stylelint": "^6.0.0", "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 PostStatusModal from './components/post_status_modal/post_status_modal.vue'
import StatusHistoryModal from './components/status_history_modal/status_history_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 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 { windowWidth, windowHeight } from './services/window_utils/window_utils'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import { defineAsyncComponent } from 'vue' import { defineAsyncComponent } from 'vue'
import { useShoutStore } from './stores/shout' import { useShoutStore } from './stores/shout'
import { useInterfaceStore } from './stores/interface' import { useInterfaceStore } from './stores/interface'
import { throttle } from 'lodash'
export default { export default {
name: 'app', name: 'app',
components: { components: {
@ -50,6 +53,9 @@ export default {
themeApplied () { themeApplied () {
this.removeSplash() this.removeSplash()
}, },
currentTheme () {
this.setThemeBodyClass()
},
layoutType () { layoutType () {
document.getElementById('modal').classList = ['-' + this.layoutType] document.getElementById('modal').classList = ['-' + this.layoutType]
} }
@ -58,21 +64,41 @@ export default {
// Load the locale from the storage // Load the locale from the storage
const val = this.$store.getters.mergedConfig.interfaceLanguage const val = this.$store.getters.mergedConfig.interfaceLanguage
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val }) this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
window.addEventListener('resize', this.updateMobileState)
document.getElementById('modal').classList = ['-' + this.layoutType] document.getElementById('modal').classList = ['-' + this.layoutType]
// Create bound handlers
this.updateScrollState = throttle(this.scrollHandler, 200)
this.updateMobileState = throttle(this.resizeHandler, 200)
}, },
mounted () { mounted () {
window.addEventListener('resize', this.updateMobileState)
this.scrollParent.addEventListener('scroll', this.updateScrollState)
if (useInterfaceStore().themeApplied) { if (useInterfaceStore().themeApplied) {
this.setThemeBodyClass()
this.removeSplash() this.removeSplash()
} }
getOrCreateServiceWorker()
}, },
unmounted () { unmounted () {
window.removeEventListener('resize', this.updateMobileState) window.removeEventListener('resize', this.updateMobileState)
this.scrollParent.removeEventListener('scroll', this.updateScrollState)
}, },
computed: { computed: {
themeApplied () { themeApplied () {
return useInterfaceStore().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 () { layoutModalClass () {
return '-' + this.layoutType return '-' + this.layoutType
}, },
@ -146,19 +172,51 @@ export default {
}, },
noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders }, noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders },
showScrollbars () { return this.$store.getters.mergedConfig.showScrollbars }, showScrollbars () { return this.$store.getters.mergedConfig.showScrollbars },
scrollParent () { return window; /* this.$refs.appContentRef */ },
...mapGetters(['mergedConfig']) ...mapGetters(['mergedConfig'])
}, },
methods: { methods: {
updateMobileState () { resizeHandler () {
useInterfaceStore().setLayoutWidth(windowWidth()) useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight()) 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 () { removeSplash () {
document.querySelector('#status').textContent = this.$t('splash.fun_' + Math.ceil(Math.random() * 4)) document.querySelector('#status').textContent = this.$t('splash.fun_' + Math.ceil(Math.random() * 4))
const splashscreenRoot = document.querySelector('#splash') const splashscreenRoot = document.querySelector('#splash')
splashscreenRoot.addEventListener('transitionend', () => { splashscreenRoot.addEventListener('transitionend', () => {
splashscreenRoot.remove() splashscreenRoot.remove()
}) })
setTimeout(() => {
splashscreenRoot.remove() // forcibly remove it, should fix my plasma browser widget t. HJ
}, 600)
splashscreenRoot.classList.add('hidden') splashscreenRoot.classList.add('hidden')
document.querySelector('#app').classList.remove('hidden') document.querySelector('#app').classList.remove('hidden')
} }

View file

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

View file

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

View file

@ -6,6 +6,8 @@ import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import { config } from '@fortawesome/fontawesome-svg-core';
config.autoAddCss = false
import App from '../App.vue' import App from '../App.vue'
import routes from './routes' import routes from './routes'
@ -21,6 +23,7 @@ import { useOAuthStore } from 'src/stores/oauth'
import { useI18nStore } from 'src/stores/i18n' import { useI18nStore } from 'src/stores/i18n'
import { useInterfaceStore } from 'src/stores/interface' import { useInterfaceStore } from 'src/stores/interface'
import { useAnnouncementsStore } from 'src/stores/announcements' import { useAnnouncementsStore } from 'src/stores/announcements'
import { useAuthFlowStore } from 'src/stores/auth_flow'
let staticInitialResults = null let staticInitialResults = null
@ -63,10 +66,11 @@ const getInstanceConfig = async ({ store }) => {
const textlimit = data.max_toot_chars const textlimit = data.max_toot_chars
const vapidPublicKey = data.pleroma.vapid_public_key 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: 'textlimit', value: textlimit })
store.dispatch('setInstanceOption', { name: 'accountApprovalRequired', value: data.approval_required }) store.dispatch('setInstanceOption', { name: 'accountApprovalRequired', value: data.approval_required })
store.dispatch('setInstanceOption', { name: 'birthdayRequired', value: !!data.pleroma.metadata.birthday_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: 'birthdayMinAge', value: data.pleroma?.metadata.birthday_min_age || 0 })
if (vapidPublicKey) { if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: 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('Could not load instance config, potentially fatal')
console.error(error) 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 () => { const getBackendProvidedConfig = async () => {
@ -153,7 +159,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
: config.logoMargin : config.logoMargin
}) })
copyInstanceOption('logoLeft') copyInstanceOption('logoLeft')
store.commit('authFlow/setInitialStrategy', config.loginMethod) useAuthFlowStore().setInitialStrategy(config.loginMethod)
copyInstanceOption('redirectRootNoLogin') copyInstanceOption('redirectRootNoLogin')
copyInstanceOption('redirectRootLogin') copyInstanceOption('redirectRootLogin')
@ -242,7 +248,8 @@ const resolveStaffAccounts = ({ store, accounts }) => {
const getNodeInfo = async ({ store }) => { const getNodeInfo = async ({ store }) => {
try { 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) { if (res.ok) {
const data = await res.json() const data = await res.json()
const metadata = data.metadata 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: 'safeDM', value: features.includes('safe_dm_mentions') })
store.dispatch('setInstanceOption', { name: 'shoutAvailable', value: features.includes('chat') }) store.dispatch('setInstanceOption', { name: 'shoutAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') }) 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: 'pleromaBookmarkFoldersAvailable', value: features.includes('pleroma:bookmark_folders') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) 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: 'mailerEnabled', value: metadata.mailerEnabled })
store.dispatch('setInstanceOption', { name: 'quotingAvailable', value: features.includes('quote_posting') }) 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: '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 const uploadLimits = metadata.uploadLimits
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) }) store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) })
@ -280,7 +294,6 @@ const getNodeInfo = async ({ store }) => {
const software = data.software const software = data.software
store.dispatch('setInstanceOption', { name: 'backendVersion', value: software.version }) store.dispatch('setInstanceOption', { name: 'backendVersion', value: software.version })
store.dispatch('setInstanceOption', { name: 'backendRepository', value: software.repository }) store.dispatch('setInstanceOption', { name: 'backendRepository', value: software.repository })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: software.name === 'pleroma' })
const priv = metadata.private const priv = metadata.private
store.dispatch('setInstanceOption', { name: 'private', value: priv }) store.dispatch('setInstanceOption', { name: 'private', value: priv })

View file

@ -1,4 +1,5 @@
import PublicTimeline from 'components/public_timeline/public_timeline.vue' 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 PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue' import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
import TagTimeline from 'components/tag_timeline/tag_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: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline }, { name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline }, { name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'bubble', path: '/bubble', component: BubbleTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } }, { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'quotes', path: '/notice/:id/quotes', component: QuotesTimeline }, { 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 Popover from '../popover/popover.vue'
import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue' import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.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 { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faEllipsisV faEllipsisV
@ -27,15 +28,10 @@ const AccountActions = {
ProgressButton, ProgressButton,
Popover, Popover,
UserListMenu, UserListMenu,
ConfirmModal ConfirmModal,
UserTimedFilterModal
}, },
methods: { methods: {
showConfirmBlock () {
this.showingConfirmBlock = true
},
hideConfirmBlock () {
this.showingConfirmBlock = false
},
showConfirmRemoveUserFromFollowers () { showConfirmRemoveUserFromFollowers () {
this.showingConfirmRemoveFollower = true this.showingConfirmRemoveFollower = true
}, },
@ -49,10 +45,14 @@ const AccountActions = {
this.$store.dispatch('hideReblogs', this.user.id) this.$store.dispatch('hideReblogs', this.user.id)
}, },
blockUser () { blockUser () {
if (this.$refs.timedBlockDialog) {
this.$refs.timedBlockDialog.optionallyPrompt()
} else {
if (!this.shouldConfirmBlock) { if (!this.shouldConfirmBlock) {
this.doBlockUser() this.doBlockUser()
} else { } else {
this.showConfirmBlock() this.showingConfirmBlock = true
}
} }
}, },
doBlockUser () { doBlockUser () {
@ -91,6 +91,7 @@ const AccountActions = {
return this.$store.getters.mergedConfig.modalOnRemoveUserFromFollowers return this.$store.getters.mergedConfig.modalOnRemoveUserFromFollowers
}, },
...mapState({ ...mapState({
blockExpirationSupported: state => state.instance.blockExpiration,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
}) })
} }

View file

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

View file

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

View file

@ -107,9 +107,9 @@
.play-icon { .play-icon {
position: absolute; position: absolute;
font-size: 64px; font-size: 4.5em;
top: calc(50% - 32px); top: calc(50% - 2.25rem);
left: calc(50% - 32px); left: calc(50% - 2.25rem);
color: rgb(255 255 255 / 75%); color: rgb(255 255 255 / 75%);
text-shadow: 0 0 2px rgb(0 0 0 / 40%); text-shadow: 0 0 2px rgb(0 0 0 / 40%);
@ -177,7 +177,8 @@
.text { .text {
flex: 2; flex: 2;
margin: 8px; margin: 8px;
word-break: break-all; overflow-wrap: break-word;
text-wrap: pretty;
h1 { h1 {
font-size: 1rem; 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 <button
v-if="remove" v-if="remove"
class="button-default attachment-button" class="button-default attachment-button -transparent"
@click.prevent="onRemove" @click.prevent="onRemove"
> >
<FAIcon icon="trash-alt" /> <FAIcon icon="trash-alt" />
@ -81,7 +81,7 @@
> >
<button <button
v-if="type === 'flash' && flashLoaded" v-if="type === 'flash' && flashLoaded"
class="button-default attachment-button" class="button-default attachment-button -transparent"
:title="$t('status.attachment_stop_flash')" :title="$t('status.attachment_stop_flash')"
@click.prevent="stopFlash" @click.prevent="stopFlash"
> >
@ -89,7 +89,7 @@
</button> </button>
<button <button
v-if="attachment.description && size !== 'small' && !edit && type !== 'unknown'" 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')" :title="$t('status.show_attachment_description')"
@click.prevent="toggleDescription" @click.prevent="toggleDescription"
> >
@ -97,7 +97,7 @@
</button> </button>
<button <button
v-if="!useModal && type !== 'unknown'" v-if="!useModal && type !== 'unknown'"
class="button-default attachment-button" class="button-default attachment-button -transparent"
:title="$t('status.show_attachment_in_modal')" :title="$t('status.show_attachment_in_modal')"
@click.prevent="openModalForce" @click.prevent="openModalForce"
> >
@ -105,7 +105,7 @@
</button> </button>
<button <button
v-if="nsfw && hideNsfwLocal" v-if="nsfw && hideNsfwLocal"
class="button-default attachment-button" class="button-default attachment-button -transparent"
:title="$t('status.hide_attachment')" :title="$t('status.hide_attachment')"
@click.prevent="toggleHidden" @click.prevent="toggleHidden"
> >
@ -113,7 +113,7 @@
</button> </button>
<button <button
v-if="shiftUp" v-if="shiftUp"
class="button-default attachment-button" class="button-default attachment-button -transparent"
:title="$t('status.move_up')" :title="$t('status.move_up')"
@click.prevent="onShiftUp" @click.prevent="onShiftUp"
> >
@ -121,7 +121,7 @@
</button> </button>
<button <button
v-if="shiftDn" v-if="shiftDn"
class="button-default attachment-button" class="button-default attachment-button -transparent"
:title="$t('status.move_down')" :title="$t('status.move_down')"
@click.prevent="onShiftDn" @click.prevent="onShiftDn"
> >
@ -129,7 +129,7 @@
</button> </button>
<button <button
v-if="remove" v-if="remove"
class="button-default attachment-button" class="button-default attachment-button -transparent"
:title="$t('status.remove_attachment')" :title="$t('status.remove_attachment')"
@click.prevent="onRemove" @click.prevent="onRemove"
> >

View file

@ -2,7 +2,8 @@ import { h, resolveComponent } from 'vue'
import LoginForm from '../login_form/login_form.vue' import LoginForm from '../login_form/login_form.vue'
import MFARecoveryForm from '../mfa_form/recovery_form.vue' import MFARecoveryForm from '../mfa_form/recovery_form.vue'
import MFATOTPForm from '../mfa_form/totp_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 = { const AuthForm = {
name: 'AuthForm', name: 'AuthForm',
@ -15,7 +16,7 @@ const AuthForm = {
if (this.requiredRecovery) { return 'MFARecoveryForm' } if (this.requiredRecovery) { return 'MFARecoveryForm' }
return 'LoginForm' return 'LoginForm'
}, },
...mapGetters('authFlow', ['requiredTOTP', 'requiredRecovery']) ...mapState(useAuthFlowStore, ['requiredTOTP', 'requiredRecovery'])
}, },
components: { components: {
MFARecoveryForm, MFARecoveryForm,

View file

@ -1,12 +1,9 @@
import { mapState } from 'vuex'
import BasicUserCard from '../basic_user_card/basic_user_card.vue' import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const BlockCard = { const BlockCard = {
props: ['userId'], props: ['userId'],
data () {
return {
progress: false
}
},
computed: { computed: {
user () { user () {
return this.$store.getters.findUser(this.userId) return this.$store.getters.findUser(this.userId)
@ -16,23 +13,32 @@ const BlockCard = {
}, },
blocked () { blocked () {
return this.relationship.blocking 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: { components: {
BasicUserCard BasicUserCard
}, },
methods: { methods: {
unblockUser () { unblockUser () {
this.progress = true this.$store.dispatch('unblockUser', this.user.id)
this.$store.dispatch('unblockUser', this.user.id).then(() => {
this.progress = false
})
}, },
blockUser () { blockUser () {
this.progress = true if (this.blockExpirationSupported) {
this.$store.dispatch('blockUser', this.user.id).then(() => { this.$refs.timedBlockDialog.optionallyPrompt()
this.progress = false } else {
}) this.$store.dispatch('blockUser', this.user.id)
}
} }
} }
} }

View file

@ -1,33 +1,35 @@
<template> <template>
<basic-user-card :user="user"> <basic-user-card :user="user">
<div class="block-card-content-container"> <div class="block-card-content-container">
<span
v-if="blocked && blockExpiryAvailable"
class="alert neutral"
>
{{ blockExpiry }}
</span>
{{ ' ' }}
<button <button
v-if="blocked" v-if="blocked"
class="btn button-default" class="btn button-default"
:disabled="progress"
@click="unblockUser" @click="unblockUser"
> >
<template v-if="progress">
{{ $t('user_card.unblock_progress') }}
</template>
<template v-else>
{{ $t('user_card.unblock') }} {{ $t('user_card.unblock') }}
</template>
</button> </button>
<button <button
v-else v-else
class="btn button-default" class="btn button-default"
:disabled="progress"
@click="blockUser" @click="blockUser"
> >
<template v-if="progress">
{{ $t('user_card.block_progress') }}
</template>
<template v-else>
{{ $t('user_card.block') }} {{ $t('user_card.block') }}
</template>
</button> </button>
</div> </div>
<teleport to="#modal">
<UserTimedFilterModal
ref="timedBlockDialog"
:user="user"
:is-mute="false"
/>
</teleport>
</basic-user-card> </basic-user-card>
</template> </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 // normal: '' // normal state is implicitly added, it is always included
toggled: '.toggled', toggled: '.toggled',
focused: ':focus-within', focused: ':focus-within',
pressed: ':focus:active', pressed: ':active',
hover: ':is(:hover, :focus-visible):not(:disabled)', hover: ':is(:hover, :focus-visible):not(:disabled)',
disabled: ':disabled' disabled: ':disabled'
}, },
@ -18,7 +18,8 @@ export default {
variants: { variants: {
// Variants save on computation time since adding new variant just adds one more "set". // 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 // 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. // 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. // This (currently) is further multipled by number of places where component can exist.
}, },
@ -51,6 +52,38 @@ export default {
roundness: 3 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'], state: ['hover'],
directives: { directives: {

View file

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

View file

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

View file

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

View file

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

View file

@ -19,14 +19,14 @@
/> />
<div <div
class="input color-input-field" class="input color-input-field"
:class="{ disabled: !present || disabled }" :class="{ disabled: !present || disabled, unstyled }"
> >
<input <input
:id="name + '-t'" :id="name + '-t'"
class="textColor unstyled" class="textColor unstyled"
:class="{ disabled: !present || disabled }" :class="{ disabled: !present || disabled }"
type="text" type="text"
:value="modelValue || fallback" :value="modelValue ?? fallback"
:disabled="!present || disabled" :disabled="!present || disabled"
@input="updateValue($event.target.value)" @input="updateValue($event.target.value)"
> >
@ -92,6 +92,11 @@ export default {
required: true, required: true,
type: String type: String
}, },
// use unstyled, uh, style
unstyled: {
required: false,
type: Boolean
},
// Color value, should be required but vue cannot tell the difference // Color value, should be required but vue cannot tell the difference
// between "property missing" and "property set to undefined" // between "property missing" and "property set to undefined"
modelValue: { 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> <template>
<div <div
:id="'component-preview-' + randomSeed"
class="ComponentPreview" class="ComponentPreview"
:class="{ '-shadow-controls': shadowControl }" :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 <label
v-show="shadowControl" v-show="shadowControl"
role="heading" role="heading"
@ -74,7 +69,6 @@
<div <div
class="preview-block" class="preview-block"
:class="previewClass" :class="previewClass"
:style="style"
> >
{{ $t('settings.style.themes3.editor.test_string') }} {{ $t('settings.style.themes3.editor.test_string') }}
</div> </div>
@ -116,208 +110,5 @@
</div> </div>
</template> </template>
<script> <script src="./component_preview.js" />
import Checkbox from 'src/components/checkbox/checkbox.vue' <style src="./component_preview.scss" lang="scss" />
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>

View file

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

View file

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

View file

@ -18,36 +18,6 @@
<span v-text="user.screen_name_ui" /> <span v-text="user.screen_name_ui" />
</template> </template>
</i18n-t> </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> </confirm-modal>
</template> </template>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -140,13 +140,13 @@ const EmojiPicker = {
}, },
updateEmojiSize () { updateEmojiSize () {
const css = window.getComputedStyle(this.$refs.popover.$el) 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 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 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,.]+/, '')) const emojiSizeValue = Number(emojiSize.replace(/[^0-9,.]+/, ''))
let fontSizeMultiplier let fontSizeMultiplier

View file

@ -64,8 +64,7 @@
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
flex-flow: row nowrap; flex-flow: row nowrap;
overflow-x: auto; overflow: auto hidden;
overflow-y: hidden;
} }
.additional-tabs { .additional-tabs {
@ -153,7 +152,15 @@
transition: mask-size 150ms; transition: mask-size 150ms;
mask-size: 100% 20px, 100% 20px, auto; mask-size: 100% 20px, 100% 20px, auto;
// Autoprefixed seem to ignore this one, and also syntax is different // 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; 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; mask-composite: exclude;
&.scrolled { &.scrolled {
@ -197,8 +204,7 @@
&-group { &-group {
display: grid; display: grid;
grid-template-columns: repeat(var(--__amount), 1fr); grid-template-columns: repeat(var(--__amount), 1fr);
align-items: center; place-items: center center;
justify-items: center;
justify-content: center; justify-content: center;
grid-template-rows: repeat(1, auto); grid-template-rows: repeat(1, auto);

View file

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

View file

@ -101,10 +101,7 @@
.gallery-row-inner { .gallery-row-inner {
position: absolute; position: absolute;
top: 0; inset: 0;
left: 0;
right: 0;
bottom: 0;
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
align-content: stretch; align-content: stretch;
@ -132,7 +129,7 @@
.gallery-item { .gallery-item {
margin: 0; margin: 0;
height: 200px; height: 15em;
} }
} }
} }
@ -160,7 +157,15 @@
linear-gradient(to top, white, white); linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */ /* 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; 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; mask-composite: exclude;
} }
} }

View file

@ -1,5 +1,4 @@
import Cropper from 'cropperjs' import 'cropperjs' // This adds all of the cropperjs's components into DOM
import 'cropperjs/dist/cropper.css'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faCircleNotch faCircleNotch
@ -11,86 +10,47 @@ library.add(
const ImageCropper = { const ImageCropper = {
props: { props: {
trigger: { // Mime-types to accept, i.e. which filetypes to accept (.gif, .png, etc.)
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
}
}
},
mimes: { mimes: {
type: String, type: String,
default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon' default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon'
}, },
saveButtonLabel: { // Fixed aspect-ratio for selection box
type: String aspectRatio: {
}, type: Number
saveWithoutCroppingButtonlabel: {
type: String
},
cancelButtonLabel: {
type: String
} }
}, },
data () { data () {
return { return {
cropper: undefined,
dataUrl: undefined, dataUrl: undefined,
filename: 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')
} }
}, },
emits: [
'submit', // cropping complete or uncropped image returned
'close', // cropper is closed
],
methods: { methods: {
destroy () { destroy () {
if (this.cropper) {
this.cropper.destroy()
}
this.$refs.input.value = '' this.$refs.input.value = ''
this.dataUrl = undefined this.dataUrl = undefined
this.$emit('close') this.$emit('close')
}, },
submit (cropping = true) { submit (cropping = true) {
this.submitting = true let cropperPromise
this.submitHandler(cropping && this.cropper, this.file) if (cropping) {
.then(() => this.destroy()) cropperPromise = this.$refs.cropperSelection.$toCanvas()
.finally(() => { } else {
this.submitting = false cropperPromise = Promise.resolve()
}
cropperPromise.then(canvas => {
this.$emit('submit', { canvas, file: this.file })
}) })
}, },
pickImage () { pickImage () {
this.$refs.input.click() 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 () { readFile () {
const fileInput = this.$refs.input const fileInput = this.$refs.input
if (fileInput.files != null && fileInput.files[0] != null) { if (fileInput.files != null && fileInput.files[0] != null) {
@ -103,26 +63,37 @@ const ImageCropper = {
reader.readAsDataURL(this.file) reader.readAsDataURL(this.file)
this.$emit('changed', this.file, reader) 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 () { 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 // listen for input file changes
const fileInput = this.$refs.input const fileInput = this.$refs.input
fileInput.addEventListener('change', this.readFile) fileInput.addEventListener('change', this.readFile)
}, },
beforeUnmount: function () { beforeUnmount: function () {
// remove the event listeners
const trigger = this.getTriggerDOM()
if (trigger) {
trigger.removeEventListener('click', this.pickImage)
}
const fileInput = this.$refs.input const fileInput = this.$refs.input
fileInput.removeEventListener('change', this.readFile) fileInput.removeEventListener('change', this.readFile)
} }

View file

@ -1,42 +1,53 @@
<template> <template>
<div class="image-cropper"> <div class="image-cropper">
<div v-if="dataUrl"> <div class="image">
<div class="image-cropper-image-container"> <cropper-canvas
<img ref="cropperCanvas"
ref="img" background
:src="dataUrl" class="image-cropper-canvas"
alt="" height="100%"
@load.stop="createCropper"
> >
</div> <cropper-image
<div class="image-cropper-buttons-wrapper"> v-if="dataUrl"
<button ref="cropperImage"
class="button-default btn" :src="dataUrl"
type="button" alt="Picture"
:disabled="submitting" class="image-cropper-image"
@click="submit()" translatable
v-text="saveText" scalable
/> />
<button <cropper-shade hidden />
class="button-default btn" <cropper-handle
type="button" action="select"
:disabled="submitting" plain
@click="destroy"
v-text="cancelText"
/> />
<button <cropper-selection
class="button-default btn" ref="cropperSelection"
type="button" initial-coverage="0.9"
:disabled="submitting" :aspect-ratio="aspectRatio"
@click="submit(false)" movable
v-text="saveWithoutCroppingText" resizable
@change="onCropperSelectionChange"
>
<cropper-grid
role="grid"
covered
/> />
<FAIcon <cropper-crosshair centered />
v-if="submitting" <cropper-handle
spin action="move"
icon="circle-notch" theme-color="rgba(255, 255, 255, 0.35)"
/> />
</div> <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> </div>
<input <input
ref="input" ref="input"
@ -51,24 +62,24 @@
<style lang="scss"> <style lang="scss">
.image-cropper { .image-cropper {
&-img-input { display: flex;
flex-direction: column;
&-canvas, .image {
height: 100%;
}
& &-img-input {
display: none; display: none;
} }
&-image-container {
position: relative;
img {
display: block;
max-width: 100%;
}
}
&-buttons-wrapper { &-buttons-wrapper {
margin-top: 10px; display: grid;
grid-gap: 0.5em;
grid-template-columns: 1fr 1fr 1fr;
button { 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> <template>
<div class="interface-language-switcher"> <div class="interface-language-switcher">
<label> <label>
{{ promptText }} <slot />
<ProfileSettingIndicator :is-profile="profile" />
</label> </label>
<ul class="setting-list"> <ul class="setting-list">
<li <li
@ -44,64 +45,7 @@
</div> </div>
</template> </template>
<script> <script src="./interface_language_switcher.js"></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>
<style lang="scss"> <style lang="scss">
.interface-language-switcher { .interface-language-switcher {

View file

@ -68,10 +68,13 @@
margin: 0.5em 0 0; margin: 0.5em 0 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
word-break: break-word; overflow-wrap: break-word;
text-wrap: pretty;
line-height: 1.2em; 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); 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 { mapState } from 'vuex'
import { mapStores } from 'pinia' import { mapStores, mapActions, mapState as mapPiniaState } from 'pinia'
import oauthApi from '../../services/new_api/oauth.js' import oauthApi from '../../services/new_api/oauth.js'
import { useOAuthStore } from 'src/stores/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 { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faTimes faTimes
@ -25,13 +26,10 @@ const LoginForm = {
instance: state => state.instance, instance: state => state.instance,
loggingIn: state => state.users.loggingIn, loggingIn: state => state.users.loggingIn,
}), }),
...mapGetters( ...mapPiniaState(useAuthFlowStore, ['requiredPassword', 'requiredToken', 'requiredMFA'])
'authFlow', ['requiredPassword', 'requiredToken', 'requiredMFA']
)
}, },
methods: { methods: {
...mapMutations('authFlow', ['requireMFA']), ...mapActions(useAuthFlowStore, ['requireMFA', 'login']),
...mapActions({ login: 'authFlow/login' }),
submit () { submit () {
this.isTokenAuth ? this.submitToken() : this.submitPassword() this.isTokenAuth ? this.submitToken() : this.submitPassword()
}, },

View file

@ -93,4 +93,4 @@
<script src="./login_form.js"></script> <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; flex: 0 0 auto;
overflow-y: auto; overflow-y: auto;
min-height: 1em; min-height: 1em;
max-width: 500px; max-width: 35.8em;
max-height: 9.5em; max-height: 9.5em;
word-break: break-all; overflow-wrap: break-word;
text-wrap: pretty;
} }
.modal-image { .modal-image {

View file

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

View file

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

View file

@ -4,11 +4,7 @@ export default {
validInnerComponents: [ validInnerComponents: [
'Text', 'Text',
'Icon', 'Icon',
'Input', 'Border'
'Border',
'ButtonUnstyled',
'Badge',
'Avatar'
], ],
states: { states: {
hover: ':is(:hover, :focus-visible, :has(:focus-visible)):not(.disabled)', 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 mfaApi from '../../services/new_api/mfa.js'
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex' import { mapState } from 'vuex'
import { mapStores } from 'pinia' import { mapStores, mapActions, mapState as mapPiniaState } from 'pinia'
import { useOAuthStore } from 'src/stores/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 { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faTimes faTimes
@ -17,8 +18,8 @@ export default {
error: false error: false
}), }),
computed: { computed: {
...mapGetters({ ...mapPiniaState(useAuthFlowStore, {
authSettings: 'authFlow/settings' authSettings: store => store.settings
}), }),
...mapStores(useOAuthStore), ...mapStores(useOAuthStore),
...mapState({ ...mapState({
@ -26,8 +27,7 @@ export default {
}) })
}, },
methods: { methods: {
...mapMutations('authFlow', ['requireTOTP', 'abortMFA']), ...mapActions(useAuthFlowStore, ['requireTOTP', 'abortMFA', 'login']),
...mapActions({ login: 'authFlow/login' }),
clearError () { this.error = false }, clearError () { this.error = false },
focusOnCodeInput () { focusOnCodeInput () {

View file

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

View file

@ -2,18 +2,7 @@ export default {
name: 'MobileDrawer', name: 'MobileDrawer',
selector: '.mobile-drawer', selector: '.mobile-drawer',
validInnerComponents: [ validInnerComponents: [
'Text', 'MenuItem'
'Link',
'Icon',
'Border',
'Button',
'ButtonUnstyled',
'Input',
'PanelHeader',
'MenuItem',
'Notification',
'Alert',
'UserCard'
], ],
defaultRules: [ defaultRules: [
{ {
@ -21,21 +10,6 @@ export default {
background: '--bg', background: '--bg',
backgroundNoCssColor: 'yes' 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 SideDrawer from '../side_drawer/side_drawer.vue'
import Notifications from '../notifications/notifications.vue' import Notifications from '../notifications/notifications.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.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 { import {
unseenNotificationsFromStore, unseenNotificationsFromStore,
countExtraNotifications countExtraNotifications
} from '../../services/notification_utils/notification_utils' } 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 { mapGetters } from 'vuex'
import { mapState } from 'pinia' import { mapState } from 'pinia'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faTimes, faTimes,
@ -18,7 +23,6 @@ import {
faMinus, faMinus,
faCheckDouble faCheckDouble
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { useAnnouncementsStore } from 'src/stores/announcements'
library.add( library.add(
faTimes, faTimes,
@ -71,10 +75,9 @@ const MobileNav = {
return this.$route.name === 'chat' return this.$route.name === 'chat'
}, },
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']), ...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
...mapGetters(['unreadChatCount']), ...mapState(useServerSideStorageStore, {
chatsPinned () { pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems).has('chats')
return new Set(this.$store.state.serverSideStorage.prefsStorage.collections.pinnedNavItems).has('chats') }),
},
shouldConfirmLogout () { shouldConfirmLogout () {
return this.$store.getters.mergedConfig.modalOnLogout return this.$store.getters.mergedConfig.modalOnLogout
}, },

View file

@ -220,8 +220,7 @@
margin-top: 3.5em; margin-top: 3.5em;
width: 100vw; width: 100vw;
height: calc(100vh - var(--navbar-height)); height: calc(100vh - var(--navbar-height));
overflow-x: hidden; overflow: hidden scroll;
overflow-y: scroll;
.notifications { .notifications {
padding: 0; 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) { .new-status-button:not(.always-show) {
display: none; display: none;
} }

View file

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

View file

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

View file

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

View file

@ -1,12 +1,8 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue' 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 = { const MuteCard = {
props: ['userId'], props: ['userId'],
data () {
return {
progress: false
}
},
computed: { computed: {
user () { user () {
return this.$store.getters.findUser(this.userId) return this.$store.getters.findUser(this.userId)
@ -16,23 +12,26 @@ const MuteCard = {
}, },
muted () { muted () {
return this.relationship.muting 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: { components: {
BasicUserCard BasicUserCard,
UserTimedFilterModal
}, },
methods: { methods: {
unmuteUser () { unmuteUser () {
this.progress = true this.$store.dispatch('unmuteUser', this.userId)
this.$store.dispatch('unmuteUser', this.userId).then(() => {
this.progress = false
})
}, },
muteUser () { muteUser () {
this.progress = true this.$refs.timedMuteDialog.optionallyPrompt()
this.$store.dispatch('muteUser', this.userId).then(() => {
this.progress = false
})
} }
} }
} }

View file

@ -1,33 +1,35 @@
<template> <template>
<basic-user-card :user="user"> <basic-user-card :user="user">
<div class="mute-card-content-container"> <div class="mute-card-content-container">
<span
v-if="muted && muteExpiryAvailable"
class="alert neutral"
>
{{ muteExpiry }}
</span>
{{ ' ' }}
<button <button
v-if="muted" v-if="muted"
class="btn button-default" class="btn button-default"
:disabled="progress"
@click="unmuteUser" @click="unmuteUser"
> >
<template v-if="progress">
{{ $t('user_card.unmute_progress') }}
</template>
<template v-else>
{{ $t('user_card.unmute') }} {{ $t('user_card.unmute') }}
</template>
</button> </button>
<button <button
v-else v-else
class="btn button-default" class="btn button-default"
:disabled="progress"
@click="muteUser" @click="muteUser"
> >
<template v-if="progress">
{{ $t('user_card.mute_progress') }}
</template>
<template v-else>
{{ $t('user_card.mute') }} {{ $t('user_card.mute') }}
</template>
</button> </button>
</div> </div>
<teleport to="#modal">
<UserTimedFilterModal
ref="timedMuteDialog"
:user="user"
:is-mute="true"
/>
</teleport>
</basic-user-card> </basic-user-card>
</template> </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 NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import NavigationPins from 'src/components/navigation/navigation_pins.vue' import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue' import Checkbox from 'src/components/checkbox/checkbox.vue'
import { useAnnouncementsStore } from 'src/stores/announcements' import { useAnnouncementsStore } from 'src/stores/announcements'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faUsers, faUsers,
faGlobe, faGlobe,
faCity,
faBookmark, faBookmark,
faEnvelope, faEnvelope,
faChevronDown, faChevronDown,
@ -29,6 +32,7 @@ import {
library.add( library.add(
faUsers, faUsers,
faGlobe, faGlobe,
faCity,
faBookmark, faBookmark,
faEnvelope, faEnvelope,
faChevronDown, faChevronDown,
@ -76,19 +80,19 @@ const NavPanel = {
this.editMode = !this.editMode this.editMode = !this.editMode
}, },
toggleCollapse () { toggleCollapse () {
this.$store.commit('setPreference', { path: 'simple.collapseNav', value: !this.collapsed }) useServerSideStorageStore().setPreference({ path: 'simple.collapseNav', value: !this.collapsed })
this.$store.dispatch('pushServerSideStorage') useServerSideStorageStore().pushServerSideStorage()
}, },
isPinned (item) { isPinned (item) {
return this.pinnedItems.has(item) return this.pinnedItems.has(item)
}, },
togglePin (item) { togglePin (item) {
if (this.isPinned(item)) { if (this.isPinned(item)) {
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value: item }) useServerSideStorageStore().removeCollectionPreference({ path: 'collections.pinnedNavItems', value: item })
} else { } else {
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value: item }) useServerSideStorageStore().addCollectionPreference({ path: 'collections.pinnedNavItems', value: item })
} }
this.$store.dispatch('pushServerSideStorage') useServerSideStorageStore().pushServerSideStorage()
} }
}, },
computed: { computed: {
@ -96,20 +100,25 @@ const NavPanel = {
unreadAnnouncementCount: 'unreadAnnouncementCount', unreadAnnouncementCount: 'unreadAnnouncementCount',
supportsAnnouncements: store => store.supportsAnnouncements supportsAnnouncements: store => store.supportsAnnouncements
}), }),
...mapPiniaState(useServerSideStorageStore, {
collapsed: store => store.prefsStorage.simple.collapseNav,
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems)
}),
...mapState({ ...mapState({
currentUser: state => state.users.currentUser, currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length, followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private, privateMode: state => state.instance.private,
federating: state => state.instance.federating, federating: state => state.instance.federating,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable, pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems), bookmarkFolders: state => state.instance.pleromaBookmarkFoldersAvailable,
collapsed: state => state.serverSideStorage.prefsStorage.simple.collapseNav, bubbleTimeline: state => state.instance.localBubbleInstances.length > 0
bookmarkFolders: state => state.instance.pleromaBookmarkFoldersAvailable
}), }),
timelinesItems () { timelinesItems () {
return filterNavigation( return filterNavigation(
Object Object
.entries({ ...TIMELINES }) .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 })), .map(([k, v]) => ({ ...v, name: k })),
{ {
hasChats: this.pleromaChatMessagesAvailable, hasChats: this.pleromaChatMessagesAvailable,
@ -117,6 +126,7 @@ const NavPanel = {
isFederating: this.federating, isFederating: this.federating,
isPrivate: this.privateMode, isPrivate: this.privateMode,
currentUser: this.currentUser, currentUser: this.currentUser,
supportsBubbleTimeline: this.bubbleTimeline,
supportsBookmarkFolders: this.bookmarkFolders supportsBookmarkFolders: this.bookmarkFolders
} }
) )
@ -132,6 +142,7 @@ const NavPanel = {
isFederating: this.federating, isFederating: this.federating,
isPrivate: this.privateMode, isPrivate: this.privateMode,
currentUser: this.currentUser, currentUser: this.currentUser,
supportsBubbleTimeline: this.bubbleTimeline,
supportsBookmarkFolders: this.bookmarkFolders 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 }) => { return list.filter(({ criteria, anon, anonRoute }) => {
const set = new Set(criteria || []) const set = new Set(criteria || [])
if (!isFederating && set.has('federating')) return false 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 ((!currentUser || !currentUser.locked) && set.has('lockedUser')) return false
if (!hasChats && set.has('chats')) return false if (!hasChats && set.has('chats')) return false
if (!hasAnnouncements && set.has('announcements')) 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 if (supportsBookmarkFolders && set.has('!supportsBookmarkFolders')) return false
return true return true
}) })
@ -19,11 +29,11 @@ export const getListEntries = store => store.allLists.map(list => ({
iconLetter: list.title[0] 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, name: 'bookmark-folder-' + folder.id,
routeObject: { name: 'bookmark-folder', params: { id: folder.id } }, routeObject: { name: 'bookmark-folder', params: { id: folder.id } },
labelRaw: folder.name, labelRaw: folder.name,
iconEmoji: folder.emoji, iconEmoji: folder.emoji,
iconEmojiUrl: folder.emoji_url, iconEmojiUrl: folder.emoji_url,
iconLetter: folder.name[0] iconLetter: folder.name[0]
})) })) : []

View file

@ -27,6 +27,13 @@ export const TIMELINES = {
label: 'nav.public_tl', label: 'nav.public_tl',
criteria: ['!private'] criteria: ['!private']
}, },
bubble: {
route: 'bubble',
anon: true,
icon: 'city',
label: 'nav.bubble',
criteria: ['!private', 'federating', 'supportsBubbleTimeline']
},
twkn: { twkn: {
route: 'public-external-timeline', route: 'public-external-timeline',
anon: true, anon: true,
@ -34,11 +41,11 @@ export const TIMELINES = {
label: 'nav.twkn', label: 'nav.twkn',
criteria: ['!private', 'federating'] criteria: ['!private', 'federating']
}, },
// bookmarks are still technically a timeline so we should show it in the dropdown
bookmarks: { bookmarks: {
route: 'bookmarks', route: 'bookmarks',
icon: 'bookmark', icon: 'bookmark',
label: 'nav.bookmarks', label: 'nav.bookmarks',
criteria: ['!supportsBookmarkFolders']
}, },
favorites: { favorites: {
routeObject: { name: 'user-profile', query: { tab: 'favorites' } }, routeObject: { name: 'user-profile', query: { tab: 'favorites' } },
@ -53,6 +60,15 @@ export const TIMELINES = {
} }
export const ROOT_ITEMS = { 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: { interactions: {
route: 'interactions', route: 'interactions',
icon: 'bell', 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 OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { faThumbtack } from '@fortawesome/free-solid-svg-icons' 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 { useAnnouncementsStore } from 'src/stores/announcements'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
library.add(faThumbtack) library.add(faThumbtack)
@ -19,11 +21,11 @@ const NavigationEntry = {
}, },
togglePin (value) { togglePin (value) {
if (this.isPinned(value)) { if (this.isPinned(value)) {
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value }) useServerSideStorageStore().removeCollectionPreference({ path: 'collections.pinnedNavItems', value })
} else { } else {
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value }) useServerSideStorageStore().addCollectionPreference({ path: 'collections.pinnedNavItems', value })
} }
this.$store.dispatch('pushServerSideStorage') useServerSideStorageStore().pushServerSideStorage()
} }
}, },
computed: { computed: {
@ -35,9 +37,11 @@ const NavigationEntry = {
}, },
...mapStores(useAnnouncementsStore), ...mapStores(useAnnouncementsStore),
...mapState({ ...mapState({
currentUser: state => state.users.currentUser, currentUser: state => state.users.currentUser
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems) }),
}) ...mapPiniaState(useServerSideStorageStore, {
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems)
}),
} }
} }

View file

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

View file

@ -42,6 +42,7 @@ library.add(
const Notification = { const Notification = {
data () { data () {
return { return {
selecting: false,
statusExpanded: false, statusExpanded: false,
unmuted: false, unmuted: false,
showingApproveConfirmDialog: false, showingApproveConfirmDialog: false,
@ -62,10 +63,35 @@ const Notification = {
UserLink, UserLink,
ConfirmModal ConfirmModal
}, },
mounted () {
document.addEventListener('selectionchange', this.onContentSelect)
},
unmounted () {
document.removeEventListener('selectionchange', this.onContentSelect)
},
methods: { methods: {
toggleStatusExpanded () { toggleStatusExpanded () {
if (!this.expandable) return
this.statusExpanded = !this.statusExpanded 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) { generateUserProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}, },
@ -136,6 +162,9 @@ const Notification = {
const user = this.notification.from_profile const user = this.notification.from_profile
return highlightStyle(highlight[user.screen_name]) return highlightStyle(highlight[user.screen_name])
}, },
expandable () {
return (new Set(['like', 'pleroma:emoji_reaction', 'repeat'])).has(this.notification.type)
},
user () { user () {
return this.$store.getters.findUser(this.notification.from_profile.id) return this.$store.getters.findUser(this.notification.from_profile.id)
}, },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,11 +15,7 @@
&::after { &::after {
content: ""; content: "";
position: absolute; position: absolute;
top: -1px; inset: -1px;
bottom: -1px;
left: -1px;
right: -1px;
z-index: -1px;
box-shadow: var(--_shadow); box-shadow: var(--_shadow);
pointer-events: none; pointer-events: none;
} }
@ -64,11 +60,14 @@
} }
.extra-button { .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); padding-left: calc(var(--__horizontal-gap) - 1px);
border-right: var(--__horizontal-gap) solid transparent; padding-right: var(--__horizontal-gap);
border-top: var(--__horizontal-gap) solid transparent; padding-top: var(--__horizontal-gap);
border-bottom: var(--__horizontal-gap) solid transparent; padding-bottom: var(--__horizontal-gap);
max-width: fit-content;
} }
.main-button { .main-button {

View file

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

View file

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

View file

@ -156,12 +156,13 @@
class="preview-status" class="preview-status"
/> />
</div> </div>
<div class="input inputs-wrapper">
<EmojiInput <EmojiInput
v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)" v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
enable-emoji-picker enable-emoji-picker
:suggest="emojiSuggestor" :suggest="emojiSuggestor"
class="input form-control" class="input form-control subject-input unstyled"
> >
<template #default="inputProps"> <template #default="inputProps">
<input <input
@ -171,7 +172,7 @@
:disabled="posting && !optimisticPosting" :disabled="posting && !optimisticPosting"
v-bind="propsToNative(inputProps)" v-bind="propsToNative(inputProps)"
size="1" size="1"
class="input form-post-subject" class="input form-post-subject unstyled"
> >
</template> </template>
</EmojiInput> </EmojiInput>
@ -180,10 +181,9 @@
v-model="newStatus.status" v-model="newStatus.status"
:suggest="emojiUserSuggestor" :suggest="emojiUserSuggestor"
:placement="emojiPickerPlacement" :placement="emojiPickerPlacement"
class="input form-control main-input" class="input form-control main-input unstyled"
enable-sticker-picker enable-sticker-picker
enable-emoji-picker enable-emoji-picker
hide-emoji-button
:newline-on-ctrl-enter="submitOnEnter" :newline-on-ctrl-enter="submitOnEnter"
@input="onEmojiInputInput" @input="onEmojiInputInput"
@sticker-uploaded="addMediaFile" @sticker-uploaded="addMediaFile"
@ -217,6 +217,7 @@
</p> </p>
</template> </template>
</EmojiInput> </EmojiInput>
</div>
<div <div
v-if="!disableScopeSelector" v-if="!disableScopeSelector"
class="visibility-tray" class="visibility-tray"
@ -236,8 +237,9 @@
> >
<Select <Select
v-model="newStatus.contentType" v-model="newStatus.contentType"
class="input form-control" class="input form-control unstyled"
:attrs="{ 'aria-label': $t('post_status.content_type_selection') }" :attrs="{ 'aria-label': $t('post_status.content_type_selection') }"
unstyled="true"
> >
<option <option
v-for="postFormat in postFormats" v-for="postFormat in postFormats"
@ -285,13 +287,6 @@
@upload-failed="uploadFailed" @upload-failed="uploadFailed"
@all-uploaded="finishedUploadingFiles" @all-uploaded="finishedUploadingFiles"
/> />
<button
class="emoji-icon button-unstyled"
:title="$t('emoji.add_emoji')"
@click="showEmojiPicker"
>
<FAIcon icon="smile-beam" />
</button>
<button <button
v-if="pollsAvailable" v-if="pollsAvailable"
class="poll-icon button-unstyled" class="poll-icon button-unstyled"

View file

@ -418,7 +418,7 @@
margin: 0.6em; margin: 0.6em;
} }
@media all and (max-width: 800px) { @media all and (width <= 800px) {
.registration-form .container { .registration-form .container {
flex-direction: column-reverse; 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 { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.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 { 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 MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.js' import { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.js'
import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue' import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
@ -86,6 +86,24 @@ export default {
required: false, required: false,
type: Boolean, type: Boolean,
default: false 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 // NEVER EVER TOUCH DATA INSIDE RENDER
@ -162,11 +180,14 @@ export default {
item, item,
this.emoji, this.emoji,
({ shortcode, url }) => { ({ shortcode, url }) => {
return <StillImage return <StillImageEmojiPopover
class="emoji img" class="emoji img"
src={url} src={url}
title={`:${shortcode}:`} title={`:${shortcode}:`}
alt={`:${shortcode}:`} alt={`:${shortcode}:`}
shortcode={shortcode}
isLocal={this.isLocal}
/> />
} }
)] )]
@ -281,11 +302,20 @@ export default {
const pass1 = convertHtmlToTree(html).map(processItem) const pass1 = convertHtmlToTree(html).map(processItem)
const pass2 = [...pass1].reverse().map(processItemReverse).reverse() const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
// DO NOT USE SLOTS they cause a re-render feedback loop here. // DO NOT USE SLOTS they cause a re-render feedback loop here.
// slots updated -> rerender -> emit -> update up the tree -> rerender -> ... // slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
// at least until vue3? // at least until vue3?
const result = <span class={['RichContent', this.faint ? '-faint' : '']}> const result =
{ pass2 } <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> </span>
const event = { const event = {

View file

@ -2,6 +2,7 @@
font-family: var(--font); font-family: var(--font);
&.-faint { &.-faint {
color: var(--text);
/* stylelint-disable declaration-no-important */ /* stylelint-disable declaration-no-important */
--text: var(--textFaint) !important; --text: var(--textFaint) !important;
--link: var(--linkFaint) !important; --link: var(--linkFaint) !important;
@ -63,6 +64,11 @@
.img { .img {
display: inline-block; display: inline-block;
// fix vertical alignment of stealable emoji
button {
display: inline-flex;
}
} }
.emoji { .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; text-align: center;
} }
@media all and (max-width: 800px) { @media all and (width <= 800px) {
.search-nav-heading { .search-nav-heading {
.tab-switcher .tabs .tab-wrapper { .tab-switcher .tabs .tab-wrapper {
display: block; 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 ConfirmModal from 'components/confirm_modal/confirm_modal.vue'
import ModifiedIndicator from '../helpers/modified_indicator.vue' import ModifiedIndicator from '../helpers/modified_indicator.vue'
import EmojiEditingPopover from '../helpers/emoji_editing_popover.vue' import EmojiEditingPopover from '../helpers/emoji_editing_popover.vue'
import { useInterfaceStore } from 'src/stores/interface'
const EmojiTab = { const EmojiTab = {
components: { components: {
@ -31,7 +32,10 @@ const EmojiTab = {
newPackName: '', newPackName: '',
deleteModalVisible: false, deleteModalVisible: false,
remotePackInstance: '', remotePackInstance: '',
remotePackDownloadAs: '' remotePackDownloadAs: '',
remotePackURL: '',
remotePackFile: null
} }
}, },
@ -141,9 +145,9 @@ const EmojiTab = {
}) })
}, },
updatePackFiles (newFiles) { updatePackFiles (newFiles, packName) {
this.pack.files = newFiles this.knownPacks[packName].files = newFiles
this.sortPackFiles(this.packName) this.sortPackFiles(packName)
}, },
loadPacksPaginated (listFunction) { loadPacksPaginated (listFunction) {
@ -219,7 +223,7 @@ const EmojiTab = {
.then(data => data.json()) .then(data => data.json())
.then(resp => { .then(resp => {
if (resp === 'ok') { if (resp === 'ok') {
this.$refs.dlPackPopover.hidePopover() this.$refs.downloadPackPopover.hidePopover()
return this.refreshPackList() return this.refreshPackList()
} else { } else {
@ -231,8 +235,49 @@ const EmojiTab = {
this.remotePackDownloadAs = '' 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) { displayError (msg) {
this.$store.useInterfaceStore().pushGlobalNotice({ useInterfaceStore().pushGlobalNotice({
messageKey: 'admin_dash.emoji.error', messageKey: 'admin_dash.emoji.error',
messageArgs: [msg], messageArgs: [msg],
level: 'error' level: 'error'

View file

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

View file

@ -62,6 +62,64 @@
</template> </template>
</Popover> </Popover>
</button> </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> </li>
<h3>{{ $t('admin_dash.emoji.emoji_packs') }}</h3> <h3>{{ $t('admin_dash.emoji.emoji_packs') }}</h3>
@ -240,12 +298,12 @@
v-if="pack.remote !== undefined" v-if="pack.remote !== undefined"
class="button button-default btn" class="button button-default btn"
type="button" type="button"
@click="$refs.dlPackPopover.showPopover" @click="$refs.downloadPackPopover.showPopover"
> >
{{ $t('admin_dash.emoji.download_pack') }} {{ $t('admin_dash.emoji.download_pack') }}
<Popover <Popover
ref="dlPackPopover" ref="downloadPackPopover"
trigger="click" trigger="click"
placement="bottom" placement="bottom"
bound-to-selector=".emoji-tab" bound-to-selector=".emoji-tab"
@ -329,11 +387,12 @@
ref="emojiPopovers" ref="emojiPopovers"
:key="shortcode" :key="shortcode"
placement="top" placement="top"
:title="$t('admin_dash.emoji.editing', [shortcode])" :title="$t(`admin_dash.emoji.${pack.remote === undefined ? 'editing' : 'copying'}`, [shortcode])"
:disabled="pack.remote !== undefined"
:shortcode="shortcode" :shortcode="shortcode"
:file="file" :file="file"
:pack-name="packName" :pack-name="packName"
:remote="pack.remote"
:known-local-packs="knownLocalPacks"
@update-pack-files="updatePackFiles" @update-pack-files="updatePackFiles"
@display-error="displayError" @display-error="displayError"
> >

View file

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

View file

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

View file

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

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