Merge branch 'develop' of https://git.pleroma.social/pleroma/pleroma-fe into fine_grained_moderation_privileges

This commit is contained in:
Ilja 2022-09-24 15:56:27 +02:00
commit 5541d0ec29
178 changed files with 8151 additions and 6498 deletions

View File

@ -1,5 +1,5 @@
{ {
"presets": ["@babel/preset-env"], "presets": ["@babel/preset-env"],
"plugins": ["@babel/plugin-transform-runtime", "lodash", "@vue/babel-plugin-jsx"], "plugins": ["@babel/plugin-transform-runtime", "lodash", "@vue/babel-plugin-jsx"],
"comments": false "comments": true
} }

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ test/e2e/reports
selenium-debug.log selenium-debug.log
.idea/ .idea/
config/local.json config/local.json
static/emoji.json

View File

@ -10,3 +10,5 @@ Contributors of this project.
- shpuld (shpuld@shitposter.club): CSS and styling - shpuld (shpuld@shitposter.club): CSS and styling
- Vincent Guth (https://unsplash.com/photos/XrwVIFy6rTw): Background images. - Vincent Guth (https://unsplash.com/photos/XrwVIFy6rTw): Background images.
- hj (hj@shigusegubu.club): Code - hj (hj@shigusegubu.club): Code
- Sean King (seanking@freespeechextremist.com): Code
- Tusooa Zhu (tusooa@kazv.moe): Code

View File

@ -18,6 +18,9 @@ console.log(
var spinner = ora('building for production...') var spinner = ora('building for production...')
spinner.start() spinner.start()
var updateEmoji = require('./update-emoji').updateEmoji
updateEmoji()
var assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory) var assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory)
rm('-rf', assetsPath) rm('-rf', assetsPath)
mkdir('-p', assetsPath) mkdir('-p', assetsPath)

View File

@ -10,6 +10,9 @@ var webpackConfig = process.env.NODE_ENV === 'testing'
? require('./webpack.prod.conf') ? require('./webpack.prod.conf')
: require('./webpack.dev.conf') : require('./webpack.dev.conf')
var updateEmoji = require('./update-emoji').updateEmoji
updateEmoji()
// default port where dev server listens for incoming traffic // default port where dev server listens for incoming traffic
var port = process.env.PORT || config.dev.port var port = process.env.PORT || config.dev.port
// Define HTTP proxies to your custom API backend // Define HTTP proxies to your custom API backend
@ -29,18 +32,20 @@ var devMiddleware = require('webpack-dev-middleware')(compiler, {
}) })
var hotMiddleware = require('webpack-hot-middleware')(compiler) var hotMiddleware = require('webpack-hot-middleware')(compiler)
// force page reload when html-webpack-plugin template changes
compiler.plugin('compilation', function (compilation) {
compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
// FIXME: This supposed to reload whole page when index.html is changed,
// however now it reloads entire page on every breath, i suppose the order
// of plugins changed or something. It's a minor thing and douesn't hurt
// disabling it, constant reloads hurt much more
// hotMiddleware.publish({ action: 'reload' }) // FIXME: The statement below gives error about hooks being required in webpack 5.
// cb() // force page reload when html-webpack-plugin template changes
}) // compiler.plugin('compilation', function (compilation) {
}) // compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
// // FIXME: This supposed to reload whole page when index.html is changed,
// // however now it reloads entire page on every breath, i suppose the order
// // of plugins changed or something. It's a minor thing and douesn't hurt
// // disabling it, constant reloads hurt much more
// // hotMiddleware.publish({ action: 'reload' })
// // cb()
// })
// })
// proxy api requests // proxy api requests
Object.keys(proxyTable).forEach(function (context) { Object.keys(proxyTable).forEach(function (context) {
@ -48,7 +53,7 @@ Object.keys(proxyTable).forEach(function (context) {
if (typeof options === 'string') { if (typeof options === 'string') {
options = { target: options } options = { target: options }
} }
app.use(proxyMiddleware(context, options)) app.use(proxyMiddleware.createProxyMiddleware(context, options))
}) })
// handle fallback for HTML5 history API // handle fallback for HTML5 history API

27
build/update-emoji.js Normal file
View File

@ -0,0 +1,27 @@
module.exports = {
updateEmoji () {
const emojis = require('@kazvmoe-infra/unicode-emoji-json/data-by-group')
const fs = require('fs')
Object.keys(emojis)
.map(k => {
emojis[k].map(e => {
delete e.unicode_version
delete e.emoji_version
delete e.skin_tone_support_unicode_version
})
})
const res = {}
Object.keys(emojis)
.map(k => {
const groupId = k.replace('&', 'and').replace(/ /g, '-').toLowerCase()
res[groupId] = emojis[k]
})
console.info('Updating emojis...')
fs.writeFileSync('static/emoji.json', JSON.stringify(res))
console.info('Done.')
}
}

View File

@ -2,7 +2,7 @@ var path = require('path')
var config = require('../config') var config = require('../config')
var utils = require('./utils') var utils = require('./utils')
var projectRoot = path.resolve(__dirname, '../') var projectRoot = path.resolve(__dirname, '../')
var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin') var ServiceWorkerWebpackPlugin = require('serviceworker-webpack5-plugin')
var CopyPlugin = require('copy-webpack-plugin'); var CopyPlugin = require('copy-webpack-plugin');
var { VueLoaderPlugin } = require('vue-loader') var { VueLoaderPlugin } = require('vue-loader')
var ESLintPlugin = require('eslint-webpack-plugin'); var ESLintPlugin = require('eslint-webpack-plugin');
@ -24,7 +24,8 @@ module.exports = {
output: { output: {
path: config.build.assetsRoot, path: config.build.assetsRoot,
publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath, publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath,
filename: '[name].js' filename: '[name].js',
chunkFilename: '[name].js'
}, },
optimization: { optimization: {
splitChunks: { splitChunks: {
@ -42,6 +43,10 @@ module.exports = {
'assets': path.resolve(__dirname, '../src/assets'), 'assets': path.resolve(__dirname, '../src/assets'),
'components': path.resolve(__dirname, '../src/components'), 'components': path.resolve(__dirname, '../src/components'),
'vue-i18n': 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js' 'vue-i18n': 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js'
},
fallback: {
'querystring': require.resolve('querystring-es3'),
'url': require.resolve('url/')
} }
}, },
module: { module: {
@ -78,22 +83,16 @@ module.exports = {
}, },
{ {
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
use: { type: 'asset',
loader: 'url-loader', generator: {
options: { filename: utils.assetsPath('img/[name].[hash:7][ext]')
limit: 10000,
name: utils.assetsPath('img/[name].[hash:7].[ext]')
}
} }
}, },
{ {
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
use: { type: 'asset',
loader: 'url-loader', generator: {
options: { filename: utils.assetsPath('fonts/[name].[hash:7][ext]')
limit: 10000,
name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
}
} }
}, },
{ {
@ -117,9 +116,8 @@ module.exports = {
new CopyPlugin({ new CopyPlugin({
patterns: [ patterns: [
{ {
from: "node_modules/@ruffle-rs/ruffle/*", from: "node_modules/@ruffle-rs/ruffle/**/*",
to: "static/ruffle", to: "static/ruffle/[name][ext]"
flatten: true
}, },
], ],
options: { options: {

View File

@ -16,7 +16,7 @@ module.exports = merge(baseWebpackConfig, {
}, },
mode: 'development', mode: 'development',
// eval-source-map is faster for development // eval-source-map is faster for development
devtool: '#eval-source-map', devtool: 'eval-source-map',
plugins: [ plugins: [
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env': config.dev.env, 'process.env': config.dev.env,

View File

@ -5,6 +5,7 @@ var webpack = require('webpack')
var merge = require('webpack-merge') var merge = require('webpack-merge')
var baseWebpackConfig = require('./webpack.base.conf') var baseWebpackConfig = require('./webpack.base.conf')
var MiniCssExtractPlugin = require('mini-css-extract-plugin') var MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin")
var HtmlWebpackPlugin = require('html-webpack-plugin') var HtmlWebpackPlugin = require('html-webpack-plugin')
var env = process.env.NODE_ENV === 'testing' var env = process.env.NODE_ENV === 'testing'
? require('../config/test.env') ? require('../config/test.env')
@ -19,12 +20,16 @@ var webpackConfig = merge(baseWebpackConfig, {
module: { module: {
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, extract: true }) rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, extract: true })
}, },
devtool: config.build.productionSourceMap ? '#source-map' : false, devtool: config.build.productionSourceMap ? 'source-map' : false,
optimization: { optimization: {
minimize: true, minimize: true,
splitChunks: { splitChunks: {
chunks: 'all' chunks: 'all'
} },
minimizer: [
`...`,
new CssMinimizerPlugin()
]
}, },
output: { output: {
path: config.build.assetsRoot, path: config.build.assetsRoot,
@ -60,9 +65,7 @@ var webpackConfig = merge(baseWebpackConfig, {
ignoreCustomComments: [/server-generated-meta/] ignoreCustomComments: [/server-generated-meta/]
// more options: // more options:
// https://github.com/kangax/html-minifier#options-quick-reference // https://github.com/kangax/html-minifier#options-quick-reference
}, }
// necessary to consistently work with multiple chunks via CommonsChunkPlugin
chunksSortMode: 'dependency'
}), }),
// split vendor js into its own file // split vendor js into its own file
// extract webpack runtime and module manifest to its own file in order to // extract webpack runtime and module manifest to its own file in order to

View File

@ -18,13 +18,14 @@
"dependencies": { "dependencies": {
"@babel/runtime": "7.18.9", "@babel/runtime": "7.18.9",
"@chenfengyuan/vue-qrcode": "2.0.0", "@chenfengyuan/vue-qrcode": "2.0.0",
"@fortawesome/fontawesome-svg-core": "6.1.2", "@fortawesome/fontawesome-svg-core": "6.2.0",
"@fortawesome/free-regular-svg-icons": "6.1.2", "@fortawesome/free-regular-svg-icons": "6.2.0",
"@fortawesome/free-solid-svg-icons": "6.1.2", "@fortawesome/free-solid-svg-icons": "6.2.0",
"@fortawesome/vue-fontawesome": "3.0.1", "@fortawesome/vue-fontawesome": "3.0.1",
"@kazvmoe-infra/pinch-zoom-element": "1.2.0", "@kazvmoe-infra/pinch-zoom-element": "1.2.0",
"@ruffle-rs/ruffle": "^0.1.0-nightly.2022.7.12", "@kazvmoe-infra/unicode-emoji-json": "^0.4.0",
"@vuelidate/core": "2.0.0-alpha.43", "@ruffle-rs/ruffle": "0.1.0-nightly.2022.7.12",
"@vuelidate/core": "2.0.0-alpha.44",
"@vuelidate/validators": "2.0.0-alpha.31", "@vuelidate/validators": "2.0.0-alpha.31",
"body-scroll-lock": "3.1.5", "body-scroll-lock": "3.1.5",
"chromatism": "3.0.0", "chromatism": "3.0.0",
@ -32,95 +33,95 @@
"cropperjs": "1.5.12", "cropperjs": "1.5.12",
"diff": "3.5.0", "diff": "3.5.0",
"escape-html": "1.0.3", "escape-html": "1.0.3",
"js-cookie": "^3.0.1", "js-cookie": "3.0.1",
"localforage": "1.10.0", "localforage": "1.10.0",
"parse-link-header": "1.0.1", "lozad": "^1.16.0",
"parse-link-header": "2.0.0",
"phoenix": "1.6.2", "phoenix": "1.6.2",
"punycode.js": "2.1.0", "punycode.js": "2.1.0",
"qrcode": "1", "qrcode": "1.5.0",
"utf8": "^3.0.0", "querystring-es3": "0.2.1",
"vue": "3.2.37", "url": "0.11.0",
"vue-i18n": "9.2.0", "utf8": "3.0.0",
"vue-router": "4.1.3", "vue": "3.2.38",
"vue-template-compiler": "2.7.8", "vue-i18n": "9.2.2",
"vue-router": "4.1.5",
"vue-template-compiler": "2.7.10",
"vuex": "4.0.2" "vuex": "4.0.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.18.9", "@babel/core": "7.18.13",
"@babel/plugin-transform-runtime": "7.18.9",
"@babel/preset-env": "7.18.9",
"@babel/register": "7.18.9",
"@babel/eslint-parser": "7.18.9", "@babel/eslint-parser": "7.18.9",
"@intlify/vue-i18n-loader": "^5.0.0", "@babel/plugin-transform-runtime": "7.18.10",
"@babel/preset-env": "7.18.10",
"@babel/register": "7.18.9",
"@intlify/vue-i18n-loader": "5.0.0",
"@ungap/event-target": "0.2.3", "@ungap/event-target": "0.2.3",
"@vue/babel-helper-vue-jsx-merge-props": "1.2.1", "@vue/babel-helper-vue-jsx-merge-props": "1.4.0",
"@vue/babel-plugin-jsx": "1.1.1", "@vue/babel-plugin-jsx": "1.1.1",
"@vue/compiler-sfc": "3.2.37", "@vue/compiler-sfc": "3.2.38",
"@vue/test-utils": "2.0.2", "@vue/test-utils": "2.0.2",
"autoprefixer": "6.7.7", "autoprefixer": "10.4.8",
"babel-loader": "8.2.5", "babel-loader": "8.2.5",
"babel-plugin-lodash": "3.3.4", "babel-plugin-lodash": "3.3.4",
"chai": "3.5.0", "chai": "4.3.6",
"chalk": "1.1.3", "chalk": "1.1.3",
"chromedriver": "103.0.0", "chromedriver": "104.0.0",
"connect-history-api-fallback": "1.6.0", "connect-history-api-fallback": "2.0.0",
"copy-webpack-plugin": "6.4.1", "copy-webpack-plugin": "11.0.0",
"cross-spawn": "4.0.2", "cross-spawn": "7.0.3",
"css-loader": "0.28.11", "css-loader": "6.7.1",
"css-minimizer-webpack-plugin": "4.0.0",
"custom-event-polyfill": "1.0.7", "custom-event-polyfill": "1.0.7",
"eslint": "8.20.0", "eslint": "8.23.0",
"eslint-config-standard": "17.0.0", "eslint-config-standard": "17.0.0",
"eslint-formatter-friendly": "7.0.0", "eslint-formatter-friendly": "7.0.0",
"eslint-webpack-plugin": "2.7.0",
"eslint-plugin-import": "2.26.0", "eslint-plugin-import": "2.26.0",
"eslint-plugin-n": "15.2.4", "eslint-plugin-n": "15.2.5",
"eslint-plugin-promise": "6.0.0", "eslint-plugin-promise": "6.0.1",
"eslint-plugin-vue": "9.3.0", "eslint-plugin-vue": "9.4.0",
"eslint-webpack-plugin": "3.2.0",
"eventsource-polyfill": "0.9.6", "eventsource-polyfill": "0.9.6",
"express": "4.18.1", "express": "4.18.1",
"file-loader": "3.0.1",
"function-bind": "1.1.1", "function-bind": "1.1.1",
"html-webpack-plugin": "3.2.0", "html-webpack-plugin": "5.5.0",
"http-proxy-middleware": "0.21.0", "http-proxy-middleware": "2.0.6",
"inject-loader": "2.0.1",
"iso-639-1": "2.1.15", "iso-639-1": "2.1.15",
"isparta-loader": "2.0.0",
"json-loader": "0.5.7", "json-loader": "0.5.7",
"karma": "6.4.0", "karma": "6.4.0",
"karma-coverage": "1.1.2", "karma-coverage": "2.2.0",
"karma-firefox-launcher": "1.3.0", "karma-firefox-launcher": "2.1.2",
"karma-mocha": "2.0.1", "karma-mocha": "2.0.1",
"karma-mocha-reporter": "2.2.5", "karma-mocha-reporter": "2.2.5",
"karma-sinon-chai": "2.0.2", "karma-sinon-chai": "2.0.2",
"karma-sourcemap-loader": "0.3.8", "karma-sourcemap-loader": "0.3.8",
"karma-spec-reporter": "0.0.34", "karma-spec-reporter": "0.0.34",
"karma-webpack": "4.0.2", "karma-webpack": "5.0.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"lolex": "1.6.0", "lolex": "1.6.0",
"mini-css-extract-plugin": "0.12.0", "mini-css-extract-plugin": "2.6.1",
"mocha": "3.5.3", "mocha": "10.0.0",
"nightwatch": "0.9.21", "nightwatch": "2.3.3",
"opn": "4.0.2", "opn": "5.5.0",
"ora": "0.4.1", "ora": "0.4.1",
"postcss-loader": "3.0.0", "postcss": "8.4.16",
"raw-loader": "0.5.1", "postcss-loader": "7.0.1",
"sass": "1.54.0", "sass": "1.54.8",
"sass-loader": "7.3.1", "sass-loader": "13.0.2",
"selenium-server": "2.53.1", "selenium-server": "2.53.1",
"semver": "5.7.1", "semver": "7.3.7",
"serviceworker-webpack-plugin": "1.0.1", "serviceworker-webpack5-plugin": "2.0.0",
"shelljs": "0.8.5", "shelljs": "0.8.5",
"sinon": "2.4.1", "sinon": "14.0.0",
"sinon-chai": "2.14.0", "sinon-chai": "3.7.0",
"stylelint": "13.13.1", "stylelint": "13.13.1",
"stylelint-config-standard": "20.0.0", "stylelint-config-standard": "20.0.0",
"stylelint-rscss": "0.4.0", "stylelint-rscss": "0.4.0",
"url-loader": "1.1.2", "vue-loader": "17.0.0",
"vue-loader": "^16.0.0",
"vue-style-loader": "4.1.3", "vue-style-loader": "4.1.3",
"webpack": "4.46.0", "webpack": "5.74.0",
"webpack-dev-middleware": "3.7.3", "webpack-dev-middleware": "3.7.3",
"webpack-hot-middleware": "2.25.1", "webpack-hot-middleware": "2.25.2",
"webpack-merge": "0.20.0" "webpack-merge": "0.20.0"
}, },
"engines": { "engines": {

View File

@ -10,7 +10,9 @@ import MobilePostStatusButton from './components/mobile_post_status_button/mobil
import MobileNav from './components/mobile_nav/mobile_nav.vue' import MobileNav from './components/mobile_nav/mobile_nav.vue'
import DesktopNav from './components/desktop_nav/desktop_nav.vue' import DesktopNav from './components/desktop_nav/desktop_nav.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue' import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
import EditStatusModal from './components/edit_status_modal/edit_status_modal.vue'
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 GlobalNoticeList from './components/global_notice_list/global_notice_list.vue' import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
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'
@ -32,8 +34,11 @@ export default {
MobileNav, MobileNav,
DesktopNav, DesktopNav,
SettingsModal: defineAsyncComponent(() => import('./components/settings_modal/settings_modal.vue')), SettingsModal: defineAsyncComponent(() => import('./components/settings_modal/settings_modal.vue')),
UpdateNotification: defineAsyncComponent(() => import('./components/update_notification/update_notification.vue')),
UserReportingModal, UserReportingModal,
PostStatusModal, PostStatusModal,
EditStatusModal,
StatusHistoryModal,
GlobalNoticeList GlobalNoticeList
}, },
data: () => ({ data: () => ({
@ -59,6 +64,13 @@ export default {
'-' + this.layoutType '-' + this.layoutType
] ]
}, },
navClasses () {
const { navbarColumnStretch } = this.$store.getters.mergedConfig
return [
'-' + this.layoutType,
...(navbarColumnStretch ? ['-column-stretch'] : [])
]
},
currentUser () { return this.$store.state.users.currentUser }, currentUser () { return this.$store.state.users.currentUser },
userBackground () { return this.currentUser.background_image }, userBackground () { return this.currentUser.background_image },
instanceBackground () { instanceBackground () {
@ -84,11 +96,16 @@ export default {
isChats () { isChats () {
return this.$route.name === 'chat' || this.$route.name === 'chats' return this.$route.name === 'chat' || this.$route.name === 'chats'
}, },
isListEdit () {
return this.$route.name === 'lists-edit'
},
newPostButtonShown () { newPostButtonShown () {
if (this.isChats) return false if (this.isChats) return false
if (this.isListEdit) return false
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile' return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
}, },
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
editingAvailable () { return this.$store.state.instance.editingAvailable },
shoutboxPosition () { shoutboxPosition () {
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false
}, },

View File

@ -5,12 +5,12 @@
--navbar-height: 3.5rem; --navbar-height: 3.5rem;
--post-line-height: 1.4; --post-line-height: 1.4;
// Z-Index stuff // Z-Index stuff
--ZI_media_modal: 90000; --ZI_media_modal: 9000;
--ZI_modals_popovers: 85000; --ZI_modals_popovers: 8500;
--ZI_modals: 80000; --ZI_modals: 8000;
--ZI_navbar_popovers: 75000; --ZI_navbar_popovers: 7500;
--ZI_navbar: 70000; --ZI_navbar: 7000;
--ZI_popovers: 60000; --ZI_popovers: 6000;
} }
html { html {
@ -117,12 +117,28 @@ h4 {
margin: 0; margin: 0;
} }
.iconLetter {
display: inline-block;
text-align: center;
font-weight: 1000;
}
i[class*=icon-], i[class*=icon-],
.svg-inline--fa { .svg-inline--fa,
.iconLetter {
color: $fallback--icon; color: $fallback--icon;
color: var(--icon, $fallback--icon); color: var(--icon, $fallback--icon);
} }
.button-unstyled:hover,
a:hover {
> i[class*=icon-],
> .svg-inline--fa,
> .iconLetter {
color: var(--text);
}
}
nav { nav {
z-index: var(--ZI_navbar); z-index: var(--ZI_navbar);
color: var(--topBarText); color: var(--topBarText);
@ -141,6 +157,11 @@ nav {
grid-area: sidebar; grid-area: sidebar;
} }
#modal {
position: absolute;
z-index: var(--ZI_modals);
}
.column.-scrollable { .column.-scrollable {
top: var(--navbar-height); top: var(--navbar-height);
position: sticky; position: sticky;
@ -182,13 +203,18 @@ nav {
.app-layout { .app-layout {
--miniColumn: 25rem; --miniColumn: 25rem;
--maxiColumn: minmax(var(--miniColumn), 45rem); --maxiColumn: 45rem;
--columnGap: 1em; --columnGap: 1em;
--status-margin: 0.75em; --status-margin: 0.75em;
--effectiveSidebarColumnWidth: minmax(var(--miniColumn), var(--sidebarColumnWidth, var(--miniColumn)));
--effectiveNotifsColumnWidth: minmax(var(--miniColumn), var(--notifsColumnWidth, var(--miniColumn)));
--effectiveContentColumnWidth: minmax(var(--miniColumn), var(--contentColumnWidth, var(--maxiColumn)));
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: var(--miniColumn) var(--maxiColumn); grid-template-columns:
var(--effectiveSidebarColumnWidth)
var(--effectiveContentColumnWidth);
grid-template-areas: "sidebar content"; grid-template-areas: "sidebar content";
grid-template-rows: 1fr; grid-template-rows: 1fr;
box-sizing: border-box; box-sizing: border-box;
@ -282,15 +308,24 @@ nav {
} }
&.-reverse:not(.-wide):not(.-mobile) { &.-reverse:not(.-wide):not(.-mobile) {
grid-template-columns: var(--maxiColumn) var(--miniColumn); grid-template-columns:
var(--effectiveContentColumnWidth)
var(--effectiveSidebarColumnWidth);
grid-template-areas: "content sidebar"; grid-template-areas: "content sidebar";
} }
&.-wide { &.-wide {
grid-template-columns: var(--miniColumn) var(--maxiColumn) var(--miniColumn); grid-template-columns:
var(--effectiveSidebarColumnWidth)
var(--effectiveContentColumnWidth)
var(--effectiveNotifsColumnWidth);
grid-template-areas: "sidebar content notifs"; grid-template-areas: "sidebar content notifs";
&.-reverse { &.-reverse {
grid-template-columns:
var(--effectiveNotifsColumnWidth)
var(--effectiveContentColumnWidth)
var(--effectiveSidebarColumnWidth);
grid-template-areas: "notifs content sidebar"; grid-template-areas: "notifs content sidebar";
} }
} }
@ -746,17 +781,23 @@ option {
} }
.fa-scale-110 { .fa-scale-110 {
&.svg-inline--fa { &.svg-inline--fa,
&.iconLetter {
font-size: 1.1em; font-size: 1.1em;
} }
} }
.fa-old-padding { .fa-old-padding {
&.svg-inline--fa { &.iconLetter,
&.svg-inline--fa, &-layer {
padding: 0 0.3em; padding: 0 0.3em;
} }
} }
.veryfaint {
opacity: 0.25;
}
.login-hint { .login-hint {
text-align: center; text-align: center;

View File

@ -8,7 +8,10 @@
class="app-bg-wrapper" class="app-bg-wrapper"
/> />
<MobileNav v-if="layoutType === 'mobile'" /> <MobileNav v-if="layoutType === 'mobile'" />
<DesktopNav v-else /> <DesktopNav
v-else
:class="navClasses"
/>
<Notifications v-if="currentUser" /> <Notifications v-if="currentUser" />
<div <div
id="content" id="content"
@ -33,7 +36,7 @@
<div <div
id="main-scroller" id="main-scroller"
class="column main" class="column main"
:class="{ '-full-height': isChats }" :class="{ '-full-height': isChats || isListEdit }"
> >
<div <div
v-if="!currentUser" v-if="!currentUser"
@ -64,7 +67,10 @@
<MobilePostStatusButton /> <MobilePostStatusButton />
<UserReportingModal /> <UserReportingModal />
<PostStatusModal /> <PostStatusModal />
<EditStatusModal v-if="editingAvailable" />
<StatusHistoryModal v-if="editingAvailable" />
<SettingsModal /> <SettingsModal />
<UpdateNotification />
<div id="modal" /> <div id="modal" />
<GlobalNoticeList /> <GlobalNoticeList />
<div id="popovers" /> <div id="popovers" />

17
src/_mixins.scss Normal file
View File

@ -0,0 +1,17 @@
@mixin unfocused-style {
@content;
&:focus:not(:focus-visible):not(:hover) {
@content;
}
}
@mixin focused-style {
&:hover, &:focus {
@content;
}
&:focus-visible {
@content;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -12,7 +12,7 @@ import { windowWidth, windowHeight } from '../services/window_utils/window_utils
import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js' import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js' import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import { applyTheme } from '../services/style_setter/style_setter.js' import { applyTheme, applyConfig } from '../services/style_setter/style_setter.js'
import FaviconService from '../services/favicon_service/favicon_service.js' import FaviconService from '../services/favicon_service/favicon_service.js'
let staticInitialResults = null let staticInitialResults = null
@ -251,6 +251,7 @@ const getNodeInfo = async ({ store }) => {
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: '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') })
store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled }) store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
@ -360,6 +361,8 @@ const afterStoreSetup = async ({ store, i18n }) => {
console.error('Failed to load any theme!') console.error('Failed to load any theme!')
} }
applyConfig(store.state.config)
// Now we can try getting the server settings and logging in // Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized // Most of these are preloaded into the index.html so blocking is minimized
await Promise.all([ await Promise.all([

View File

@ -20,6 +20,10 @@ import ShoutPanel from 'components/shout_panel/shout_panel.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue' import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import About from 'components/about/about.vue' import About from 'components/about/about.vue'
import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue' import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
import Lists from 'components/lists/lists.vue'
import ListsTimeline from 'components/lists_timeline/lists_timeline.vue'
import ListsEdit from 'components/lists_edit/lists_edit.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
export default (store) => { export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => { const validateAuthenticatedRoute = (to, from, next) => {
@ -58,7 +62,7 @@ export default (store) => {
component: RemoteUserResolver, component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute beforeEnter: validateAuthenticatedRoute
}, },
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile }, { name: 'external-user-profile', path: '/users/$:id', component: UserProfile },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute }, { name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute }, { name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
{ name: 'registration', path: '/registration', component: Registration }, { name: 'registration', path: '/registration', component: Registration },
@ -72,7 +76,13 @@ export default (store) => {
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) }, { name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute }, { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About }, { name: 'about', path: '/about', component: About },
{ name: 'user-profile', path: '/:_(users)?/:name', component: UserProfile } { name: 'user-profile', path: '/users/:name', component: UserProfile },
{ name: 'legacy-user-profile', path: '/:name', component: UserProfile },
{ name: 'lists', path: '/lists', component: Lists },
{ name: 'lists-timeline', path: '/lists/:id', component: ListsTimeline },
{ name: 'lists-edit', path: '/lists/:id/edit', component: ListsEdit },
{ name: 'lists-new', path: '/lists/new', component: ListsEdit },
{ name: 'edit-navigation', path: '/nav-edit', component: NavPanel, props: () => ({ forceExpand: true, forceEditMode: true }), beforeEnter: validateAuthenticatedRoute }
] ]
if (store.state.instance.pleromaChatMessagesAvailable) { if (store.state.instance.pleromaChatMessagesAvailable) {

View File

@ -1,6 +1,7 @@
import { mapState } from 'vuex' import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue' 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 { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faEllipsisV faEllipsisV
@ -19,7 +20,8 @@ const AccountActions = {
}, },
components: { components: {
ProgressButton, ProgressButton,
Popover Popover,
UserListMenu
}, },
methods: { methods: {
showRepeats () { showRepeats () {
@ -34,6 +36,9 @@ const AccountActions = {
unblockUser () { unblockUser () {
this.$store.dispatch('unblockUser', this.user.id) this.$store.dispatch('unblockUser', this.user.id)
}, },
removeUserFromFollowers () {
this.$store.dispatch('removeUserFromFollowers', this.user.id)
},
reportUser () { reportUser () {
this.$store.dispatch('openUserReportingModal', { userId: this.user.id }) this.$store.dispatch('openUserReportingModal', { userId: this.user.id })
}, },

View File

@ -28,6 +28,14 @@
class="dropdown-divider" class="dropdown-divider"
/> />
</template> </template>
<UserListMenu :user="user" />
<button
v-if="relationship.followed_by"
class="btn button-default btn-block dropdown-item"
@click="removeUserFromFollowers"
>
{{ $t('user_card.remove_follower') }}
</button>
<button <button
v-if="relationship.blocking" v-if="relationship.blocking"
class="btn button-default btn-block dropdown-item" class="btn button-default btn-block dropdown-item"

View File

@ -129,6 +129,9 @@ const Attachment = {
...mapGetters(['mergedConfig']) ...mapGetters(['mergedConfig'])
}, },
watch: { watch: {
'attachment.description' (newVal) {
this.localDescription = newVal
},
localDescription (newVal) { localDescription (newVal) {
this.onEdit(newVal) this.onEdit(newVal)
} }

View File

@ -1,5 +1,6 @@
import UserPopover from '../user_popover/user_popover.vue' import UserPopover from '../user_popover/user_popover.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx' import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -10,7 +11,8 @@ const BasicUserCard = {
components: { components: {
UserPopover, UserPopover,
UserAvatar, UserAvatar,
RichContent RichContent,
UserLink
}, },
methods: { methods: {
userProfileLink (user) { userProfileLink (user) {

View File

@ -30,12 +30,10 @@
/> />
</div> </div>
<div> <div>
<router-link <user-link
class="basic-user-card-screen-name" class="basic-user-card-screen-name"
:to="userProfileLink(user)" :user="user"
> />
@{{ user.screen_name_ui }}
</router-link>
</div> </div>
<slot /> <slot />
</div> </div>

View File

@ -57,6 +57,7 @@ const Chat = {
}, },
unmounted () { unmounted () {
window.removeEventListener('scroll', this.handleScroll) window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('resize', this.handleResize)
if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false) if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
this.$store.dispatch('clearCurrentChat') this.$store.dispatch('clearCurrentChat')
}, },
@ -135,7 +136,7 @@ const Chat = {
}, },
// "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport // "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
handleResize (opts = {}) { handleResize (opts = {}) {
const { expand = false, delayed = false } = opts const { delayed = false } = opts
if (delayed) { if (delayed) {
setTimeout(() => { setTimeout(() => {
@ -146,10 +147,10 @@ const Chat = {
this.$nextTick(() => { this.$nextTick(() => {
const { offsetHeight = undefined } = getScrollPosition() const { offsetHeight = undefined } = getScrollPosition()
const diff = this.lastScrollPosition.offsetHeight - offsetHeight const diff = offsetHeight - this.lastScrollPosition.offsetHeight
if (diff !== 0 || (!this.bottomedOut() && expand)) { if (diff !== 0 && !this.bottomedOut()) {
this.$nextTick(() => { this.$nextTick(() => {
window.scrollTo({ top: window.scrollY + diff }) window.scrollBy({ top: -Math.trunc(diff) })
}) })
} }
this.lastScrollPosition = getScrollPosition() this.lastScrollPosition = getScrollPosition()
@ -187,6 +188,7 @@ const Chat = {
}, 5000) }, 5000)
}, },
handleScroll: _.throttle(function () { handleScroll: _.throttle(function () {
this.lastScrollPosition = getScrollPosition()
if (!this.currentChat) { return } if (!this.currentChat) { return }
if (this.reachedTop()) { if (this.reachedTop()) {

View File

@ -1,6 +1,10 @@
import { reduce, filter, findIndex, clone, get } from 'lodash' import { reduce, filter, findIndex, clone, get } from 'lodash'
import Status from '../status/status.vue' import Status from '../status/status.vue'
import ThreadTree from '../thread_tree/thread_tree.vue' import ThreadTree from '../thread_tree/thread_tree.vue'
import { WSConnectionStatus } from '../../services/api/api.service.js'
import { mapGetters, mapState } from 'vuex'
import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -77,6 +81,9 @@ const conversation = {
const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2 const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1 return maxDepth >= 1 ? maxDepth : 1
}, },
streamingEnabled () {
return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
},
displayStyle () { displayStyle () {
return this.$store.getters.mergedConfig.conversationDisplay return this.$store.getters.mergedConfig.conversationDisplay
}, },
@ -339,11 +346,17 @@ const conversation = {
}, },
maybeHighlight () { maybeHighlight () {
return this.isExpanded ? this.highlight : null return this.isExpanded ? this.highlight : null
} },
...mapGetters(['mergedConfig']),
...mapState({
mastoUserSocketStatus: state => state.api.mastoUserSocketStatus
})
}, },
components: { components: {
Status, Status,
ThreadTree ThreadTree,
QuickFilterSettings,
QuickViewSettings
}, },
watch: { watch: {
statusId (newVal, oldVal) { statusId (newVal, oldVal) {
@ -395,6 +408,11 @@ const conversation = {
setHighlight (id) { setHighlight (id) {
if (!id) return if (!id) return
this.highlight = id this.highlight = id
if (!this.streamingEnabled) {
this.$store.dispatch('fetchStatus', id)
}
this.$store.dispatch('fetchFavsAndRepeats', id) this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id) this.$store.dispatch('fetchEmojiReactionsBy', id)
}, },

View File

@ -17,6 +17,14 @@
> >
{{ $t('timeline.collapse') }} {{ $t('timeline.collapse') }}
</button> </button>
<QuickFilterSettings
v-if="!collapsable"
:conversation="true"
/>
<QuickViewSettings
v-if="!collapsable"
:conversation="true"
/>
</div> </div>
<div class="conversation-body panel-body"> <div class="conversation-body panel-body">
<div <div

View File

@ -23,6 +23,26 @@
max-width: 980px; max-width: 980px;
} }
&.-column-stretch .inner-nav {
--miniColumn: 25rem;
--maxiColumn: 45rem;
--columnGap: 1em;
max-width: calc(
var(--sidebarColumnWidth, var(--miniColumn)) +
var(--contentColumnWidth, var(--maxiColumn)) +
var(--columnGap)
);
}
&.-column-stretch.-wide .inner-nav {
max-width: calc(
var(--sidebarColumnWidth, var(--miniColumn)) +
var(--contentColumnWidth, var(--maxiColumn)) +
var(--notifsColumnWidth, var(--miniColumn)) +
var(--columnGap)
);
}
&.-logoLeft .inner-nav { &.-logoLeft .inner-nav {
grid-template-columns: auto 2fr 2fr; grid-template-columns: auto 2fr 2fr;
grid-template-areas: "logo sitename actions"; grid-template-areas: "logo sitename actions";
@ -117,4 +137,8 @@
text-align: right; text-align: right;
} }
} }
.spacer {
width: 1em;
}
} }

View File

@ -61,6 +61,7 @@
:title="$t('nav.administration')" :title="$t('nav.administration')"
/> />
</a> </a>
<span class="spacer" />
<button <button
v-if="currentUser" v-if="currentUser"
class="button-unstyled nav-icon" class="button-unstyled nav-icon"

View File

@ -0,0 +1,75 @@
import PostStatusForm from '../post_status_form/post_status_form.vue'
import Modal from '../modal/modal.vue'
import statusPosterService from '../../services/status_poster/status_poster.service.js'
import get from 'lodash/get'
const EditStatusModal = {
components: {
PostStatusForm,
Modal
},
data () {
return {
resettingForm: false
}
},
computed: {
isLoggedIn () {
return !!this.$store.state.users.currentUser
},
modalActivated () {
return this.$store.state.editStatus.modalActivated
},
isFormVisible () {
return this.isLoggedIn && !this.resettingForm && this.modalActivated
},
params () {
return this.$store.state.editStatus.params || {}
}
},
watch: {
params (newVal, oldVal) {
if (get(newVal, 'statusId') !== get(oldVal, 'statusId')) {
this.resettingForm = true
this.$nextTick(() => {
this.resettingForm = false
})
}
},
isFormVisible (val) {
if (val) {
this.$nextTick(() => this.$el && this.$el.querySelector('textarea').focus())
}
}
},
methods: {
doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) {
const params = {
store: this.$store,
statusId: this.$store.state.editStatus.params.statusId,
status,
spoilerText,
sensitive,
poll,
media,
contentType
}
return statusPosterService.editStatus(params)
.then((data) => {
return data
})
.catch((err) => {
console.error('Error editing status', err)
return {
error: err.message
}
})
},
closeModal () {
this.$store.dispatch('closeEditStatusModal')
}
}
}
export default EditStatusModal

View File

@ -0,0 +1,48 @@
<template>
<Modal
v-if="isFormVisible"
class="edit-form-modal-view"
@backdropClicked="closeModal"
>
<div class="edit-form-modal-panel panel">
<div class="panel-heading">
{{ $t('post_status.edit_status') }}
</div>
<PostStatusForm
class="panel-body"
v-bind="params"
:post-handler="doEditStatus"
:disable-polls="true"
:disable-visibility-selector="true"
@posted="closeModal"
/>
</div>
</Modal>
</template>
<script src="./edit_status_modal.js"></script>
<style lang="scss">
.modal-view.edit-form-modal-view {
align-items: flex-start;
}
.edit-form-modal-panel {
flex-shrink: 0;
margin-top: 25%;
margin-bottom: 2em;
width: 100%;
max-width: 700px;
@media (orientation: landscape) {
margin-top: 8%;
}
.form-bottom-left {
max-width: 6.5em;
.emoji-icon {
justify-content: right;
}
}
}
</style>

View File

@ -1,8 +1,9 @@
import Completion from '../../services/completion/completion.js' import Completion from '../../services/completion/completion.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue' import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { take } from 'lodash' import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { ensureFinalFallback } from '../../i18n/languages.js'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faSmileBeam faSmileBeam
@ -120,7 +121,8 @@ const EmojiInput = {
} }
}, },
components: { components: {
EmojiPicker EmojiPicker,
UnicodeDomainIndicator
}, },
computed: { computed: {
padEmoji () { padEmoji () {
@ -141,6 +143,51 @@ const EmojiInput = {
const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {} const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
return word return word
} }
},
languages () {
return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
},
maybeLocalizedEmojiNamesAndKeywords () {
return emoji => {
const names = [emoji.displayText]
const keywords = []
if (emoji.displayTextI18n) {
names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args))
}
if (emoji.annotations) {
this.languages.forEach(lang => {
names.push(emoji.annotations[lang]?.name)
keywords.push(...(emoji.annotations[lang]?.keywords || []))
})
}
return {
names: names.filter(k => k),
keywords: keywords.filter(k => k)
}
}
},
maybeLocalizedEmojiName () {
return emoji => {
if (!emoji.annotations) {
return emoji.displayText
}
if (emoji.displayTextI18n) {
return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
}
for (const lang of this.languages) {
if (emoji.annotations[lang]?.name) {
return emoji.annotations[lang].name
}
}
return emoji.displayText
}
} }
}, },
mounted () { mounted () {
@ -179,7 +226,7 @@ const EmojiInput = {
const firstchar = newWord.charAt(0) const firstchar = newWord.charAt(0)
this.suggestions = [] this.suggestions = []
if (newWord === firstchar) return if (newWord === firstchar) return
const matchedSuggestions = await this.suggest(newWord) const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords)
// Async: cancel if textAtCaret has changed during wait // Async: cancel if textAtCaret has changed during wait
if (this.textAtCaret !== newWord) return if (this.textAtCaret !== newWord) return
if (matchedSuggestions.length <= 0) return if (matchedSuggestions.length <= 0) return
@ -205,7 +252,6 @@ const EmojiInput = {
}, },
triggerShowPicker () { triggerShowPicker () {
this.showPicker = true this.showPicker = true
this.$refs.picker.startEmojiLoad()
this.$nextTick(() => { this.$nextTick(() => {
this.scrollIntoView() this.scrollIntoView()
this.focusPickerInput() this.focusPickerInput()

View File

@ -19,6 +19,7 @@
v-if="enableEmojiPicker" v-if="enableEmojiPicker"
ref="picker" ref="picker"
:class="{ hide: !showPicker }" :class="{ hide: !showPicker }"
:showing="showPicker"
:enable-sticker-picker="enableStickerPicker" :enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel" class="emoji-picker-panel"
@emoji="insert" @emoji="insert"
@ -50,7 +51,21 @@
<span v-else>{{ suggestion.replacement }}</span> <span v-else>{{ suggestion.replacement }}</span>
</span> </span>
<div class="label"> <div class="label">
<span class="displayText">{{ suggestion.displayText }}</span> <span
v-if="suggestion.user"
class="displayText"
>
{{ suggestion.displayText }}<UnicodeDomainIndicator
:user="suggestion.user"
:at="false"
/>
</span>
<span
v-if="!suggestion.user"
class="displayText"
>
{{ maybeLocalizedEmojiName(suggestion) }}
</span>
<span class="detailText">{{ suggestion.detailText }}</span> <span class="detailText">{{ suggestion.detailText }}</span>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
* suggest - generates a suggestor function to be used by emoji-input * suggest - generates a suggestor function to be used by emoji-input
* data: object providing source information for specific types of suggestions: * data: object providing source information for specific types of suggestions:
* data.emoji - optional, an array of all emoji available i.e. * data.emoji - optional, an array of all emoji available i.e.
* (state.instance.emoji + state.instance.customEmoji) * (getters.standardEmojiList + state.instance.customEmoji)
* data.users - optional, an array of all known users * data.users - optional, an array of all known users
* updateUsersList - optional, a function to search and append to users * updateUsersList - optional, a function to search and append to users
* *
@ -13,10 +13,10 @@
export default data => { export default data => {
const emojiCurry = suggestEmoji(data.emoji) const emojiCurry = suggestEmoji(data.emoji)
const usersCurry = data.store && suggestUsers(data.store) const usersCurry = data.store && suggestUsers(data.store)
return input => { return (input, nameKeywordLocalizer) => {
const firstChar = input[0] const firstChar = input[0]
if (firstChar === ':' && data.emoji) { if (firstChar === ':' && data.emoji) {
return emojiCurry(input) return emojiCurry(input, nameKeywordLocalizer)
} }
if (firstChar === '@' && usersCurry) { if (firstChar === '@' && usersCurry) {
return usersCurry(input) return usersCurry(input)
@ -25,34 +25,34 @@ export default data => {
} }
} }
export const suggestEmoji = emojis => input => { export const suggestEmoji = emojis => (input, nameKeywordLocalizer) => {
const noPrefix = input.toLowerCase().substr(1) const noPrefix = input.toLowerCase().substr(1)
return emojis return emojis
.filter(({ displayText }) => displayText.toLowerCase().match(noPrefix)) .map(emoji => ({ ...emoji, ...nameKeywordLocalizer(emoji) }))
.sort((a, b) => { .filter((emoji) => (emoji.names.concat(emoji.keywords)).filter(kw => kw.toLowerCase().match(noPrefix)).length)
let aScore = 0 .map(k => {
let bScore = 0 let score = 0
// An exact match always wins // An exact match always wins
aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0 score += Math.max(...k.names.map(name => name.toLowerCase() === noPrefix ? 200 : 0), 0)
bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0
// Prioritize custom emoji a lot // Prioritize custom emoji a lot
aScore += a.imageUrl ? 100 : 0 score += k.imageUrl ? 100 : 0
bScore += b.imageUrl ? 100 : 0
// Prioritize prefix matches somewhat // Prioritize prefix matches somewhat
aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0 score += Math.max(...k.names.map(kw => kw.toLowerCase().startsWith(noPrefix) ? 10 : 0), 0)
bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
// Sort by length // Sort by length
aScore -= a.displayText.length score -= k.displayText.length
bScore -= b.displayText.length
k.score = score
return k
})
.sort((a, b) => {
// Break ties alphabetically // Break ties alphabetically
const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5 const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5
return bScore - aScore + alphabetically return b.score - a.score + alphabetically
}) })
} }
@ -116,11 +116,12 @@ export const suggestUsers = ({ dispatch, state }) => {
return diff + nameAlphabetically + screenNameAlphabetically return diff + nameAlphabetically + screenNameAlphabetically
/* eslint-disable camelcase */ /* eslint-disable camelcase */
}).map(({ screen_name, screen_name_ui, name, profile_image_url_original }) => ({ }).map((user) => ({
displayText: screen_name_ui, user,
detailText: name, displayText: user.screen_name_ui,
imageUrl: profile_image_url_original, detailText: user.name,
replacement: '@' + screen_name + ' ' imageUrl: user.profile_image_url_original,
replacement: '@' + user.screen_name + ' '
})) }))
/* eslint-enable camelcase */ /* eslint-enable camelcase */

View File

@ -1,33 +1,76 @@
import { defineAsyncComponent } from 'vue' import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from '../checkbox/checkbox.vue'
import StillImage from '../still-image/still-image.vue'
import { ensureFinalFallback } from '../../i18n/languages.js'
import lozad from 'lozad'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faBoxOpen, faBoxOpen,
faStickyNote, faStickyNote,
faSmileBeam faSmileBeam,
faSmile,
faUser,
faPaw,
faIceCream,
faBus,
faBasketballBall,
faLightbulb,
faCode,
faFlag
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { trim } from 'lodash' import { debounce, trim } from 'lodash'
library.add( library.add(
faBoxOpen, faBoxOpen,
faStickyNote, faStickyNote,
faSmileBeam faSmileBeam,
faSmile,
faUser,
faPaw,
faIceCream,
faBus,
faBasketballBall,
faLightbulb,
faCode,
faFlag
) )
// At widest, approximately 20 emoji are visible in a row, const UNICODE_EMOJI_GROUP_ICON = {
// loading 3 rows, could be overkill for narrow picker 'smileys-and-emotion': 'smile',
const LOAD_EMOJI_BY = 60 'people-and-body': 'user',
'animals-and-nature': 'paw',
'food-and-drink': 'ice-cream',
'travel-and-places': 'bus',
activities: 'basketball-ball',
objects: 'lightbulb',
symbols: 'code',
flags: 'flag'
}
// When to start loading new batch emoji, in pixels const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => {
const LOAD_EMOJI_MARGIN = 64 const res = [emoji.displayText, nameLocalizer(emoji)]
if (emoji.annotations) {
languages.forEach(lang => {
const keywords = emoji.annotations[lang]?.keywords || []
const name = emoji.annotations[lang]?.name
res.push(...(keywords.concat([name]).filter(k => k)))
})
}
return res
}
const filterByKeyword = (list, keyword = '') => { const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => {
if (keyword === '') return list if (keyword === '') return list
const keywordLowercase = keyword.toLowerCase() const keywordLowercase = keyword.toLowerCase()
const orderedEmojiList = [] const orderedEmojiList = []
for (const emoji of list) { for (const emoji of list) {
const indexOfKeyword = emoji.displayText.toLowerCase().indexOf(keywordLowercase) const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer)
.map(k => k.toLowerCase().indexOf(keywordLowercase))
.filter(k => k > -1)
const indexOfKeyword = indices.length ? Math.min(...indices) : -1
if (indexOfKeyword > -1) { if (indexOfKeyword > -1) {
if (!Array.isArray(orderedEmojiList[indexOfKeyword])) { if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
orderedEmojiList[indexOfKeyword] = [] orderedEmojiList[indexOfKeyword] = []
@ -44,6 +87,10 @@ const EmojiPicker = {
required: false, required: false,
type: Boolean, type: Boolean,
default: false default: false
},
showing: {
required: true,
type: Boolean
} }
}, },
data () { data () {
@ -53,16 +100,26 @@ const EmojiPicker = {
showingStickers: false, showingStickers: false,
groupsScrolledClass: 'scrolled-top', groupsScrolledClass: 'scrolled-top',
keepOpen: false, keepOpen: false,
customEmojiBufferSlice: LOAD_EMOJI_BY,
customEmojiTimeout: null, customEmojiTimeout: null,
customEmojiLoadAllConfirmed: false // Lazy-load only after the first time `showing` becomes true.
contentLoaded: false,
groupRefs: {},
emojiRefs: {},
filteredEmojiGroups: []
} }
}, },
components: { components: {
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')), StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
Checkbox Checkbox,
StillImage
}, },
methods: { methods: {
setGroupRef (name) {
return el => { this.groupRefs[name] = el }
},
setEmojiRef (name) {
return el => { this.emojiRefs[name] = el }
},
onStickerUploaded (e) { onStickerUploaded (e) {
this.$emit('sticker-uploaded', e) this.$emit('sticker-uploaded', e)
}, },
@ -77,10 +134,38 @@ const EmojiPicker = {
const target = (e && e.target) || this.$refs['emoji-groups'] const target = (e && e.target) || this.$refs['emoji-groups']
this.updateScrolledClass(target) this.updateScrolledClass(target)
this.scrolledGroup(target) this.scrolledGroup(target)
this.triggerLoadMore(target) },
scrolledGroup (target) {
const top = target.scrollTop + 5
this.$nextTick(() => {
this.allEmojiGroups.forEach(group => {
const ref = this.groupRefs['group-' + group.id]
if (ref && ref.offsetTop <= top) {
this.activeGroup = group.id
}
})
this.scrollHeader()
})
},
scrollHeader () {
// Scroll the active tab's header into view
const headerRef = this.groupRefs['group-header-' + this.activeGroup]
const left = headerRef.offsetLeft
const right = left + headerRef.offsetWidth
const headerCont = this.$refs.header
const currentScroll = headerCont.scrollLeft
const currentScrollRight = currentScroll + headerCont.clientWidth
const setScroll = s => { headerCont.scrollLeft = s }
const margin = 7 // .emoji-tabs-item: padding
if (left - margin < currentScroll) {
setScroll(left - margin)
} else if (right + margin > currentScrollRight) {
setScroll(right + margin - headerCont.clientWidth)
}
}, },
highlight (key) { highlight (key) {
const ref = this.$refs['group-' + key] const ref = this.groupRefs['group-' + key]
const top = ref.offsetTop const top = ref.offsetTop
this.setShowStickers(false) this.setShowStickers(false)
this.activeGroup = key this.activeGroup = key
@ -97,73 +182,90 @@ const EmojiPicker = {
this.groupsScrolledClass = 'scrolled-middle' this.groupsScrolledClass = 'scrolled-middle'
} }
}, },
triggerLoadMore (target) {
const ref = this.$refs['group-end-custom']
if (!ref) return
const bottom = ref.offsetTop + ref.offsetHeight
const scrollerBottom = target.scrollTop + target.clientHeight
const scrollerTop = target.scrollTop
const scrollerMax = target.scrollHeight
// Loads more emoji when they come into view
const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN
// Always load when at the very top in case there's no scroll space yet
const atTop = scrollerTop < 5
// Don't load when looking at unicode category or at the very bottom
const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax
if (!bottomAboveViewport && (approachingBottom || atTop)) {
this.loadEmoji()
}
},
scrolledGroup (target) {
const top = target.scrollTop + 5
this.$nextTick(() => {
this.emojisView.forEach(group => {
const ref = this.$refs['group-' + group.id]
if (ref.offsetTop <= top) {
this.activeGroup = group.id
}
})
})
},
loadEmoji () {
const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length
if (allLoaded) {
return
}
this.customEmojiBufferSlice += LOAD_EMOJI_BY
},
startEmojiLoad (forceUpdate = false) {
if (!forceUpdate) {
this.keyword = ''
}
this.$nextTick(() => {
this.$refs['emoji-groups'].scrollTop = 0
})
const bufferSize = this.customEmojiBuffer.length
const bufferPrefilledAll = bufferSize === this.filteredEmoji.length
if (bufferPrefilledAll && !forceUpdate) {
return
}
this.customEmojiBufferSlice = LOAD_EMOJI_BY
},
toggleStickers () { toggleStickers () {
this.showingStickers = !this.showingStickers this.showingStickers = !this.showingStickers
}, },
setShowStickers (value) { setShowStickers (value) {
this.showingStickers = value this.showingStickers = value
},
filterByKeyword (list, keyword) {
return filterByKeyword(list, keyword, this.languages, this.maybeLocalizedEmojiName)
},
initializeLazyLoad () {
this.destroyLazyLoad()
this.$nextTick(() => {
this.$lozad = lozad('.still-image.emoji-picker-emoji', {
load: el => {
const name = el.getAttribute('data-emoji-name')
const vn = this.emojiRefs[name]
if (!vn) {
return
}
vn.loadLazy()
}
})
this.$lozad.observe()
})
},
waitForDomAndInitializeLazyLoad () {
this.$nextTick(() => this.initializeLazyLoad())
},
destroyLazyLoad () {
if (this.$lozad) {
if (this.$lozad.observer) {
this.$lozad.observer.disconnect()
}
if (this.$lozad.mutationObserver) {
this.$lozad.mutationObserver.disconnect()
}
}
},
onShowing () {
const oldContentLoaded = this.contentLoaded
this.contentLoaded = true
this.waitForDomAndInitializeLazyLoad()
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
if (!oldContentLoaded) {
this.$nextTick(() => {
if (this.defaultGroup) {
this.highlight(this.defaultGroup)
}
})
}
},
getFilteredEmojiGroups () {
return this.allEmojiGroups
.map(group => ({
...group,
emojis: this.filterByKeyword(group.emojis, trim(this.keyword))
}))
.filter(group => group.emojis.length > 0)
} }
}, },
watch: { watch: {
keyword () { keyword () {
this.customEmojiLoadAllConfirmed = false
this.onScroll() this.onScroll()
this.startEmojiLoad(true) this.debouncedHandleKeywordChange()
},
allCustomGroups () {
this.waitForDomAndInitializeLazyLoad()
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
},
showing (val) {
if (val) {
this.onShowing()
}
} }
}, },
mounted () {
if (this.showing) {
this.onShowing()
}
},
destroyed () {
this.destroyLazyLoad()
},
computed: { computed: {
activeGroupView () { activeGroupView () {
return this.showingStickers ? '' : this.activeGroup return this.showingStickers ? '' : this.activeGroup
@ -174,39 +276,55 @@ const EmojiPicker = {
} }
return 0 return 0
}, },
filteredEmoji () { allCustomGroups () {
return filterByKeyword( return this.$store.getters.groupedCustomEmojis
this.$store.state.instance.customEmoji || [],
trim(this.keyword)
)
}, },
customEmojiBuffer () { defaultGroup () {
return this.filteredEmoji.slice(0, this.customEmojiBufferSlice) return Object.keys(this.allCustomGroups)[0]
}, },
emojis () { unicodeEmojiGroups () {
const standardEmojis = this.$store.state.instance.emoji || [] return this.$store.getters.standardEmojiGroupList.map(group => ({
const customEmojis = this.customEmojiBuffer id: `standard-${group.id}`,
text: this.$t(`emoji.unicode_groups.${group.id}`),
return [ icon: UNICODE_EMOJI_GROUP_ICON[group.id],
{ emojis: group.emojis
id: 'custom', }))
text: this.$t('emoji.custom'),
icon: 'smile-beam',
emojis: customEmojis
},
{
id: 'standard',
text: this.$t('emoji.unicode'),
icon: 'box-open',
emojis: filterByKeyword(standardEmojis, trim(this.keyword))
}
]
}, },
emojisView () { allEmojiGroups () {
return this.emojis.filter(value => value.emojis.length > 0) return Object.entries(this.allCustomGroups)
.map(([_, v]) => v)
.concat(this.unicodeEmojiGroups)
}, },
stickerPickerEnabled () { stickerPickerEnabled () {
return (this.$store.state.instance.stickers || []).length !== 0 return (this.$store.state.instance.stickers || []).length !== 0
},
debouncedHandleKeywordChange () {
return debounce(() => {
this.waitForDomAndInitializeLazyLoad()
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
}, 500)
},
languages () {
return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
},
maybeLocalizedEmojiName () {
return emoji => {
if (!emoji.annotations) {
return emoji.displayText
}
if (emoji.displayTextI18n) {
return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
}
for (const lang of this.languages) {
if (emoji.annotations[lang]?.name) {
return emoji.annotations[lang].name
}
}
return emoji.displayText
}
} }
} }
} }

View File

@ -1,5 +1,10 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
$emoji-picker-header-height: 36px;
$emoji-picker-header-picture-width: 32px;
$emoji-picker-header-picture-height: 32px;
$emoji-picker-emoji-size: 32px;
.emoji-picker { .emoji-picker {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -19,6 +24,23 @@
--lightText: var(--popoverLightText, $fallback--lightText); --lightText: var(--popoverLightText, $fallback--lightText);
--icon: var(--popoverIcon, $fallback--icon); --icon: var(--popoverIcon, $fallback--icon);
&-header-image {
display: inline-flex;
justify-content: center;
align-items: center;
width: $emoji-picker-header-picture-width;
max-width: $emoji-picker-header-picture-width;
height: $emoji-picker-header-picture-height;
max-height: $emoji-picker-header-picture-height;
.still-image {
max-width: 100%;
max-height: 100%;
height: 100%;
width: 100%;
object-fit: contain;
}
}
.keep-open, .keep-open,
.too-many-emoji { .too-many-emoji {
padding: 7px; padding: 7px;
@ -37,7 +59,6 @@
.heading { .heading {
display: flex; display: flex;
height: 32px;
padding: 10px 7px 5px; padding: 10px 7px 5px;
} }
@ -50,6 +71,10 @@
.emoji-tabs { .emoji-tabs {
flex-grow: 1; flex-grow: 1;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
overflow-x: auto;
} }
.emoji-groups { .emoji-groups {
@ -57,6 +82,8 @@
} }
.additional-tabs { .additional-tabs {
display: flex;
flex: 1;
border-left: 1px solid; border-left: 1px solid;
border-left-color: $fallback--icon; border-left-color: $fallback--icon;
border-left-color: var(--icon, $fallback--icon); border-left-color: var(--icon, $fallback--icon);
@ -66,15 +93,20 @@
.additional-tabs, .additional-tabs,
.emoji-tabs { .emoji-tabs {
display: block;
min-width: 0;
flex-basis: auto; flex-basis: auto;
flex-shrink: 1; display: flex;
align-content: center;
&-item { &-item {
padding: 0 7px; padding: 0 7px;
cursor: pointer; cursor: pointer;
font-size: 1.85em; font-size: 1.85em;
width: $emoji-picker-header-picture-width;
max-width: $emoji-picker-header-picture-width;
height: $emoji-picker-header-picture-height;
max-height: $emoji-picker-header-picture-height;
display: flex;
align-items: center;
&.disabled { &.disabled {
opacity: 0.5; opacity: 0.5;
@ -164,22 +196,26 @@
} }
&-item { &-item {
width: 32px; width: $emoji-picker-emoji-size;
height: 32px; height: $emoji-picker-emoji-size;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
font-size: 32px; line-height: $emoji-picker-emoji-size;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: 4px; margin: 4px;
cursor: pointer; cursor: pointer;
img { .emoji-picker-emoji.-custom {
object-fit: contain; object-fit: contain;
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
} }
.emoji-picker-emoji.-unicode {
font-size: 24px;
overflow: hidden;
}
} }
} }

View File

@ -1,19 +1,34 @@
<template> <template>
<div class="emoji-picker panel panel-default panel-body"> <div
class="emoji-picker panel panel-default panel-body"
>
<div class="heading"> <div class="heading">
<span class="emoji-tabs"> <span
ref="header"
class="emoji-tabs"
>
<span <span
v-for="group in emojis" v-for="group in filteredEmojiGroups"
:ref="setGroupRef('group-header-' + group.id)"
:key="group.id" :key="group.id"
class="emoji-tabs-item" class="emoji-tabs-item"
:class="{ :class="{
active: activeGroupView === group.id, active: activeGroupView === group.id
disabled: group.emojis.length === 0
}" }"
:title="group.text" :title="group.text"
@click.prevent="highlight(group.id)" @click.prevent="highlight(group.id)"
> >
<span
v-if="group.image"
class="emoji-picker-header-image"
>
<still-image
:alt="group.text"
:src="group.image"
/>
</span>
<FAIcon <FAIcon
v-else
:icon="group.icon" :icon="group.icon"
fixed-width fixed-width
/> />
@ -36,7 +51,10 @@
</span> </span>
</span> </span>
</div> </div>
<div class="content"> <div
v-if="contentLoaded"
class="content"
>
<div <div
class="emoji-content" class="emoji-content"
:class="{hidden: showingStickers}" :class="{hidden: showingStickers}"
@ -57,12 +75,12 @@
@scroll="onScroll" @scroll="onScroll"
> >
<div <div
v-for="group in emojisView" v-for="group in filteredEmojiGroups"
:key="group.id" :key="group.id"
class="emoji-group" class="emoji-group"
> >
<h6 <h6
:ref="'group-' + group.id" :ref="setGroupRef('group-' + group.id)"
class="emoji-group-title" class="emoji-group-title"
> >
{{ group.text }} {{ group.text }}
@ -70,17 +88,23 @@
<span <span
v-for="emoji in group.emojis" v-for="emoji in group.emojis"
:key="group.id + emoji.displayText" :key="group.id + emoji.displayText"
:title="emoji.displayText" :title="maybeLocalizedEmojiName(emoji)"
class="emoji-item" class="emoji-item"
@click.stop.prevent="onEmoji(emoji)" @click.stop.prevent="onEmoji(emoji)"
> >
<span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span> <span
<img v-if="!emoji.imageUrl"
class="emoji-picker-emoji -unicode"
>{{ emoji.replacement }}</span>
<still-image
v-else v-else
:src="emoji.imageUrl" :ref="setEmojiRef(group.id + emoji.displayText)"
> class="emoji-picker-emoji -custom"
:data-src="emoji.imageUrl"
:data-emoji-name="group.id + emoji.displayText"
/>
</span> </span>
<span :ref="'group-end-' + group.id" /> <span :ref="setGroupRef('group-end-' + group.id)" />
</div> </div>
</div> </div>
<div class="keep-open"> <div class="keep-open">

View File

@ -6,7 +6,10 @@ import {
faEyeSlash, faEyeSlash,
faThumbtack, faThumbtack,
faShareAlt, faShareAlt,
faExternalLinkAlt faExternalLinkAlt,
faHistory,
faPlus,
faTimes
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { import {
faBookmark as faBookmarkReg, faBookmark as faBookmarkReg,
@ -21,13 +24,27 @@ library.add(
faThumbtack, faThumbtack,
faShareAlt, faShareAlt,
faExternalLinkAlt, faExternalLinkAlt,
faFlag faFlag,
faHistory,
faPlus,
faTimes
) )
const ExtraButtons = { const ExtraButtons = {
props: ['status'], props: ['status'],
components: { Popover }, components: { Popover },
data () {
return {
expanded: false
}
},
methods: { methods: {
onShow () {
this.expanded = true
},
onClose () {
this.expanded = false
},
deleteStatus () { deleteStatus () {
const confirmed = window.confirm(this.$t('status.delete_confirm')) const confirmed = window.confirm(this.$t('status.delete_confirm'))
if (confirmed) { if (confirmed) {
@ -71,6 +88,25 @@ const ExtraButtons = {
}, },
reportStatus () { reportStatus () {
this.$store.dispatch('openUserReportingModal', { userId: this.status.user.id, statusIds: [this.status.id] }) this.$store.dispatch('openUserReportingModal', { userId: this.status.user.id, statusIds: [this.status.id] })
},
editStatus () {
this.$store.dispatch('fetchStatusSource', { id: this.status.id })
.then(data => this.$store.dispatch('openEditStatusModal', {
statusId: this.status.id,
subject: data.spoiler_text,
statusText: data.text,
statusIsSensitive: this.status.nsfw,
statusPoll: this.status.poll,
statusFiles: [...this.status.attachments],
visibility: this.status.visibility,
statusContentType: data.content_type
}))
},
showStatusHistory () {
const originalStatus = { ...this.status }
const stripFieldsList = ['attachments', 'created_at', 'emojis', 'text', 'raw_html', 'nsfw', 'poll', 'summary', 'summary_raw_html']
stripFieldsList.forEach(p => delete originalStatus[p])
this.$store.dispatch('openStatusHistoryModal', originalStatus)
} }
}, },
computed: { computed: {
@ -93,7 +129,11 @@ const ExtraButtons = {
}, },
statusLink () { statusLink () {
return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}` return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}`
} },
isEdited () {
return this.status.edited_at !== null
},
editingAvailable () { return this.$store.state.instance.editingAvailable }
} }
} }

View File

@ -6,6 +6,8 @@
:offset="{ y: 5 }" :offset="{ y: 5 }"
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
remove-padding remove-padding
@show="onShow"
@close="onClose"
> >
<template #content="{close}"> <template #content="{close}">
<div class="dropdown-menu"> <div class="dropdown-menu">
@ -75,6 +77,28 @@
/><span>{{ $t("status.unbookmark") }}</span> /><span>{{ $t("status.unbookmark") }}</span>
</button> </button>
</template> </template>
<button
v-if="ownStatus && editingAvailable"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="editStatus"
@click="close"
>
<FAIcon
fixed-width
icon="pen"
/><span>{{ $t("status.edit") }}</span>
</button>
<button
v-if="isEdited && editingAvailable"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="showStatusHistory"
@click="close"
>
<FAIcon
fixed-width
icon="history"
/><span>{{ $t("status.status_history") }}</span>
</button>
<button <button
v-if="canDelete" v-if="canDelete"
class="button-default dropdown-item dropdown-item-icon" class="button-default dropdown-item dropdown-item-icon"
@ -122,10 +146,24 @@
</template> </template>
<template #trigger> <template #trigger>
<span class="button-unstyled popover-trigger"> <span class="button-unstyled popover-trigger">
<FAIcon <FALayers class="fa-old-padding-layer">
class="fa-scale-110 fa-old-padding" <FAIcon
icon="ellipsis-h" class="fa-scale-110 "
/> icon="ellipsis-h"
/>
<FAIcon
v-show="!expanded"
class="focus-marker"
transform="shrink-6 up-8 right-16"
icon="plus"
/>
<FAIcon
v-show="expanded"
class="focus-marker"
transform="shrink-6 up-8 right-16"
icon="times"
/>
</FALayers>
</span> </span>
</template> </template>
</Popover> </Popover>
@ -135,6 +173,7 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
@import '../../_mixins.scss';
.ExtraButtons { .ExtraButtons {
/* override of popover internal stuff */ /* override of popover internal stuff */
@ -151,6 +190,21 @@
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
}
.popover-trigger-button {
@include unfocused-style {
.focus-marker {
visibility: hidden;
}
}
@include focused-style {
.focus-marker {
visibility: visible;
}
}
} }
} }
</style> </style>

View File

@ -1,13 +1,21 @@
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { faStar } from '@fortawesome/free-solid-svg-icons' import {
faStar,
faPlus,
faMinus,
faCheck
} from '@fortawesome/free-solid-svg-icons'
import { import {
faStar as faStarRegular faStar as faStarRegular
} from '@fortawesome/free-regular-svg-icons' } from '@fortawesome/free-regular-svg-icons'
library.add( library.add(
faStar, faStar,
faStarRegular faStarRegular,
faPlus,
faMinus,
faCheck
) )
const FavoriteButton = { const FavoriteButton = {

View File

@ -7,11 +7,31 @@
:title="$t('tool_tip.favorite')" :title="$t('tool_tip.favorite')"
@click.prevent="favorite()" @click.prevent="favorite()"
> >
<FAIcon <FALayers class="fa-scale-110 fa-old-padding-layer">
class="fa-scale-110 fa-old-padding" <FAIcon
:icon="[status.favorited ? 'fas' : 'far', 'star']" class="fa-scale-110"
:spin="animated" :icon="[status.favorited ? 'fas' : 'far', 'star']"
/> :spin="animated"
/>
<FAIcon
v-if="status.favorited"
class="active-marker"
transform="shrink-6 up-9 right-12"
icon="check"
/>
<FAIcon
v-if="!status.favorited"
class="focus-marker"
transform="shrink-6 up-9 right-12"
icon="plus"
/>
<FAIcon
v-else
class="focus-marker"
transform="shrink-6 up-9 right-12"
icon="minus"
/>
</FALayers>
</button> </button>
<span v-else> <span v-else>
<FAIcon <FAIcon
@ -33,6 +53,7 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
@import '../../_mixins.scss';
.FavoriteButton { .FavoriteButton {
display: flex; display: flex;
@ -57,6 +78,26 @@
color: $fallback--cOrange; color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange); color: var(--cOrange, $fallback--cOrange);
} }
@include unfocused-style {
.focus-marker {
visibility: hidden;
}
.active-marker {
visibility: visible;
}
}
@include focused-style {
.focus-marker {
visibility: visible;
}
.active-marker {
visibility: hidden;
}
}
} }
} }
</style> </style>

View File

@ -1,6 +1,7 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue' import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue' import RemoteFollow from '../remote_follow/remote_follow.vue'
import FollowButton from '../follow_button/follow_button.vue' import FollowButton from '../follow_button/follow_button.vue'
import RemoveFollowerButton from '../remove_follower_button/remove_follower_button.vue'
const FollowCard = { const FollowCard = {
props: [ props: [
@ -10,7 +11,8 @@ const FollowCard = {
components: { components: {
BasicUserCard, BasicUserCard,
RemoteFollow, RemoteFollow,
FollowButton FollowButton,
RemoveFollowerButton
}, },
computed: { computed: {
isMe () { isMe () {

View File

@ -22,6 +22,11 @@
class="follow-card-follow-button" class="follow-card-follow-button"
:user="user" :user="user"
/> />
<RemoveFollowerButton
v-if="noFollowsYou && relationship.followed_by"
:relationship="relationship"
class="follow-card-button"
/>
</template> </template>
</div> </div>
</basic-user-card> </basic-user-card>
@ -40,6 +45,12 @@
line-height: 1.5em; line-height: 1.5em;
} }
&-button {
margin-top: 0.5em;
padding: 0 1.5em;
margin-left: 1em;
}
&-follow-button { &-follow-button {
margin-top: 0.5em; margin-top: 0.5em;
margin-left: auto; margin-left: auto;

View File

@ -29,10 +29,10 @@
.global-notice-list { .global-notice-list {
position: fixed; position: fixed;
top: 50px; top: calc(var(--navbar-height) + 0.5em);
width: 100%; width: 100%;
pointer-events: none; pointer-events: none;
z-index: var(--ZI_popovers); z-index: var(--ZI_navbar_popovers);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;

View File

@ -5,6 +5,8 @@ const tabModeDict = {
mentions: ['mention'], mentions: ['mention'],
'likes+repeats': ['repeat', 'like'], 'likes+repeats': ['repeat', 'like'],
follows: ['follow'], follows: ['follow'],
reactions: ['pleroma:emoji_reaction'],
reports: ['pleroma:report'],
moves: ['move'] moves: ['move']
} }
@ -12,7 +14,8 @@ const Interactions = {
data () { data () {
return { return {
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move, allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
filterMode: tabModeDict.mentions filterMode: tabModeDict.mentions,
canSeeReports: ['moderator', 'admin'].includes(this.$store.state.users.currentUser.role)
} }
}, },
methods: { methods: {

View File

@ -21,6 +21,15 @@
key="follows" key="follows"
:label="$t('interactions.follows')" :label="$t('interactions.follows')"
/> />
<span
key="reactions"
:label="$t('interactions.emoji_reactions')"
/>
<span
v-if="canSeeReports"
key="reports"
:label="$t('interactions.reports')"
/>
<span <span
v-if="!allowFollowingMove" v-if="!allowFollowingMove"
key="moves" key="moves"

View File

@ -0,0 +1,27 @@
import ListsCard from '../lists_card/lists_card.vue'
const Lists = {
data () {
return {
isNew: false
}
},
components: {
ListsCard
},
computed: {
lists () {
return this.$store.state.lists.allLists
}
},
methods: {
cancelNewList () {
this.isNew = false
},
newList () {
this.isNew = true
}
}
}
export default Lists

View File

@ -0,0 +1,33 @@
<template>
<div class="Lists panel panel-default">
<div class="panel-heading">
<div class="title">
{{ $t('lists.lists') }}
</div>
<router-link
:to="{ name: 'lists-new' }"
class="button-default btn new-list-button"
>
{{ $t("lists.new") }}
</router-link>
</div>
<div class="panel-body">
<ListsCard
v-for="list in lists.slice().reverse()"
:key="list"
:list="list"
class="list-item"
/>
</div>
</div>
</template>
<script src="./lists.js"></script>
<style lang="scss">
.Lists {
.new-list-button {
padding: 0 0.5em;
}
}
</style>

View File

@ -0,0 +1,16 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEllipsisH
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEllipsisH
)
const ListsCard = {
props: [
'list'
]
}
export default ListsCard

View File

@ -0,0 +1,51 @@
<template>
<div class="list-card">
<router-link
:to="{ name: 'lists-timeline', params: { id: list.id } }"
class="list-name"
>
{{ list.title }}
</router-link>
<router-link
:to="{ name: 'lists-edit', params: { id: list.id } }"
class="button-list-edit"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="ellipsis-h"
/>
</router-link>
</div>
</template>
<script src="./lists_card.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.list-card {
display: flex;
}
.list-name,
.button-list-edit {
margin: 0;
padding: 1em;
color: $fallback--link;
color: var(--link, $fallback--link);
&:hover {
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--link;
color: var(--selectedMenuText, $fallback--link);
--faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
}
}
.list-name {
flex-grow: 1;
}
</style>

View File

@ -0,0 +1,145 @@
import { mapState, mapGetters } from 'vuex'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import ListsUserSearch from '../lists_user_search/lists_user_search.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSearch,
faChevronLeft
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSearch,
faChevronLeft
)
const ListsNew = {
components: {
BasicUserCard,
UserAvatar,
ListsUserSearch,
TabSwitcher,
PanelLoading
},
data () {
return {
title: '',
titleDraft: '',
membersUserIds: [],
removedUserIds: new Set([]), // users we added for members, to undo
searchUserIds: [],
addedUserIds: new Set([]), // users we added from search, to undo
searchLoading: false,
reallyDelete: false
}
},
created () {
if (!this.id) return
this.$store.dispatch('fetchList', { listId: this.id })
.then(() => {
this.title = this.findListTitle(this.id)
this.titleDraft = this.title
})
this.$store.dispatch('fetchListAccounts', { listId: this.id })
.then(() => {
this.membersUserIds = this.findListAccounts(this.id)
this.membersUserIds.forEach(userId => {
this.$store.dispatch('fetchUserIfMissing', userId)
})
})
},
computed: {
id () {
return this.$route.params.id
},
membersUsers () {
return [...this.membersUserIds, ...this.addedUserIds]
.map(userId => this.findUser(userId)).filter(user => user)
},
searchUsers () {
return this.searchUserIds.map(userId => this.findUser(userId)).filter(user => user)
},
...mapState({
currentUser: state => state.users.currentUser
}),
...mapGetters(['findUser', 'findListTitle', 'findListAccounts'])
},
methods: {
onInput () {
this.search(this.query)
},
toggleRemoveMember (user) {
if (this.removedUserIds.has(user.id)) {
this.id && this.addUser(user)
this.removedUserIds.delete(user.id)
} else {
this.id && this.removeUser(user.id)
this.removedUserIds.add(user.id)
}
},
toggleAddFromSearch (user) {
if (this.addedUserIds.has(user.id)) {
this.id && this.removeUser(user.id)
this.addedUserIds.delete(user.id)
} else {
this.id && this.addUser(user)
this.addedUserIds.add(user.id)
}
},
isRemoved (user) {
return this.removedUserIds.has(user.id)
},
isAdded (user) {
return this.addedUserIds.has(user.id)
},
addUser (user) {
this.$store.dispatch('addListAccount', { accountId: this.user.id, listId: this.id })
},
removeUser (userId) {
this.$store.dispatch('removeListAccount', { accountId: this.user.id, listId: this.id })
},
onSearchLoading (results) {
this.searchLoading = true
},
onSearchLoadingDone (results) {
this.searchLoading = false
},
onSearchResults (results) {
this.searchLoading = false
this.searchUserIds = results
},
updateListTitle () {
this.$store.dispatch('setList', { listId: this.id, title: this.titleDraft })
.then(() => {
this.title = this.findListTitle(this.id)
})
},
createList () {
this.$store.dispatch('createList', { title: this.titleDraft })
.then((list) => {
return this
.$store
.dispatch('setListAccounts', { listId: list.id, accountIds: [...this.addedUserIds] })
.then(() => list.id)
})
.then((listId) => {
this.$router.push({ name: 'lists-timeline', params: { id: listId } })
})
.catch((e) => {
this.$store.dispatch('pushGlobalNotice', {
messageKey: 'lists.error',
messageArgs: [e.message],
level: 'error'
})
})
},
deleteList () {
this.$store.dispatch('deleteList', { listId: this.id })
this.$router.push({ name: 'lists' })
}
}
}
export default ListsNew

View File

@ -0,0 +1,228 @@
<template>
<div class="panel-default panel ListEdit">
<div
ref="header"
class="panel-heading list-edit-heading"
>
<button
class="button-unstyled go-back-button"
@click="$router.back"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
</button>
<div class="title">
<i18n-t
v-if="id"
keypath="lists.editing_list"
>
<template #listTitle>
{{ title }}
</template>
</i18n-t>
<i18n-t
v-else
keypath="lists.creating_list"
/>
</div>
</div>
<div class="panel-body">
<div class="input-wrap">
<label for="list-edit-title">{{ $t('lists.title') }}</label>
{{ ' ' }}
<input
id="list-edit-title"
ref="title"
v-model="titleDraft"
>
<button
v-if="id"
class="btn button-default follow-button"
@click="updateListTitle"
>
{{ $t('lists.update_title') }}
</button>
</div>
<tab-switcher
class="list-member-management"
:scrollable-tabs="true"
>
<div
v-if="id || addedUserIds.size > 0"
:label="$t('lists.manage_members')"
class="members-list"
>
<div class="users-list">
<div
v-for="user in membersUsers"
:key="user.id"
class="member"
>
<BasicUserCard
:user="user"
>
<button
class="btn button-default follow-button"
@click="toggleRemoveMember(user)"
>
{{ isRemoved(user) ? $t('general.undo') : $t('lists.remove_from_list') }}
</button>
</BasicUserCard>
</div>
</div>
</div>
<div
class="search-list"
:label="$t('lists.add_members')"
>
<ListsUserSearch
@results="onSearchResults"
@loading="onSearchLoading"
@loadingDone="onSearchLoadingDone"
/>
<div
v-if="searchLoading"
class="loading"
>
<PanelLoading />
</div>
<div
v-else
class="users-list"
>
<div
v-for="user in searchUsers"
:key="user.id"
class="member"
>
<BasicUserCard
:user="user"
>
<span
v-if="membersUserIds.includes(user.id)"
>
{{ $t('lists.is_in_list') }}
</span>
<button
v-if="!membersUserIds.includes(user.id)"
class="btn button-default follow-button"
@click="toggleAddFromSearch(user)"
>
{{ isAdded(user) ? $t('general.undo') : $t('lists.add_to_list') }}
</button>
<button
v-else
class="btn button-default follow-button"
@click="toggleRemoveMember(user)"
>
{{ isRemoved(user) ? $t('general.undo') : $t('lists.remove_from_list') }}
</button>
</BasicUserCard>
</div>
</div>
</div>
</tab-switcher>
</div>
<div class="panel-footer">
<span class="spacer" />
<button
v-if="!id"
class="btn button-default footer-button"
@click="createList"
>
{{ $t('lists.create') }}
</button>
<button
v-else-if="!reallyDelete"
class="btn button-default footer-button"
@click="reallyDelete = true"
>
{{ $t('lists.delete') }}
</button>
<template v-else>
{{ $t('lists.really_delete') }}
<button
class="btn button-default footer-button"
@click="deleteList"
>
{{ $t('general.yes') }}
</button>
<button
class="btn button-default footer-button"
@click="reallyDelete = false"
>
{{ $t('general.no') }}
</button>
</template>
</div>
</div>
</template>
<script src="./lists_edit.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.ListEdit {
--panel-body-padding: 0.5em;
height: calc(100vh - var(--navbar-height));
overflow: hidden;
display: flex;
flex-direction: column;
.list-edit-heading {
grid-template-columns: auto minmax(50%, 1fr);
}
.panel-body {
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.list-member-management {
flex: 1 0 auto;
}
.search-icon {
margin-right: 0.3em;
}
.users-list {
padding-bottom: 0.7rem;
overflow-y: auto;
}
& .search-list,
& .members-list {
overflow: hidden;
flex-direction: column;
min-height: 0;
}
.go-back-button {
text-align: center;
line-height: 1;
height: 100%;
align-self: start;
width: var(--__panel-heading-height-inner);
}
.btn {
margin: 0 0.5em;
}
.panel-footer {
grid-template-columns: minmax(10%, 1fr);
.footer-button {
min-width: 9em;
}
}
}
</style>

View File

@ -0,0 +1,22 @@
import { mapState } from 'vuex'
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import { getListEntries } from 'src/components/navigation/filter.js'
export const ListsMenuContent = {
props: [
'showPin'
],
components: {
NavigationEntry
},
computed: {
...mapState({
lists: getListEntries,
currentUser: state => state.users.currentUser,
privateMode: state => state.instance.private,
federating: state => state.instance.federating
})
}
}
export default ListsMenuContent

View File

@ -0,0 +1,12 @@
<template>
<ul>
<NavigationEntry
v-for="item in lists"
:key="item.name"
:show-pin="showPin"
:item="item"
/>
</ul>
</template>
<script src="./lists_menu_content.js"></script>

View File

@ -0,0 +1,36 @@
import Timeline from '../timeline/timeline.vue'
const ListsTimeline = {
data () {
return {
listId: null
}
},
components: {
Timeline
},
computed: {
timeline () { return this.$store.state.statuses.timelines.list }
},
watch: {
$route: function (route) {
if (route.name === 'lists-timeline' && route.params.id !== this.listId) {
this.listId = route.params.id
this.$store.dispatch('stopFetchingTimeline', 'list')
this.$store.commit('clearTimeline', { timeline: 'list' })
this.$store.dispatch('fetchList', { listId: this.listId })
this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId })
}
}
},
created () {
this.listId = this.$route.params.id
this.$store.dispatch('fetchList', { listId: this.listId })
this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId })
},
unmounted () {
this.$store.dispatch('stopFetchingTimeline', 'list')
this.$store.commit('clearTimeline', { timeline: 'list' })
}
}
export default ListsTimeline

View File

@ -0,0 +1,10 @@
<template>
<Timeline
title="list.name"
:timeline="timeline"
:list-id="listId"
timeline-name="list"
/>
</template>
<script src="./lists_timeline.js"></script>

View File

@ -0,0 +1,51 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSearch,
faChevronLeft
} from '@fortawesome/free-solid-svg-icons'
import { debounce } from 'lodash'
import Checkbox from '../checkbox/checkbox.vue'
library.add(
faSearch,
faChevronLeft
)
const ListsUserSearch = {
components: {
Checkbox
},
emits: ['loading', 'loadingDone', 'results'],
data () {
return {
loading: false,
query: '',
followingOnly: true
}
},
methods: {
onInput: debounce(function () {
this.search(this.query)
}, 2000),
search (query) {
if (!query) {
this.loading = false
return
}
this.loading = true
this.$emit('loading')
this.userIds = []
this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts', following: this.followingOnly })
.then(data => {
this.$emit('results', data.accounts.map(a => a.id))
})
.finally(() => {
this.loading = false
this.$emit('loadingDone')
})
}
}
}
export default ListsUserSearch

View File

@ -0,0 +1,47 @@
<template>
<div class="ListsUserSearch">
<div class="input-wrap">
<div class="input-search">
<FAIcon
class="search-icon fa-scale-110 fa-old-padding"
icon="search"
/>
</div>
<input
ref="search"
v-model="query"
:placeholder="$t('lists.search')"
@input="onInput"
>
</div>
<div class="input-wrap">
<Checkbox
v-model="followingOnly"
@change="onInput"
>
{{ $t('lists.following_only') }}
</Checkbox>
</div>
</div>
</template>
<script src="./lists_user_search.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.ListsUserSearch {
.input-wrap {
display: flex;
margin: 0.7em 0.5em 0.7em 0.5em;
input {
width: 100%;
}
}
.search-icon {
margin-right: 0.3em;
}
}
</style>

View File

@ -2,6 +2,7 @@ import generateProfileLink from 'src/services/user_profile_link_generator/user_p
import { mapGetters, mapState } from 'vuex' import { mapGetters, mapState } from 'vuex'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { defineAsyncComponent } from 'vue' import { defineAsyncComponent } from 'vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -16,6 +17,7 @@ const MentionLink = {
name: 'MentionLink', name: 'MentionLink',
components: { components: {
UserAvatar, UserAvatar,
UnicodeDomainIndicator,
UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue')) UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue'))
}, },
props: { props: {

View File

@ -47,6 +47,9 @@
class="serverName" class="serverName"
:class="{ '-faded': shouldFadeDomain }" :class="{ '-faded': shouldFadeDomain }"
v-html="'@' + serverName" v-html="'@' + serverName"
/><UnicodeDomainIndicator
v-if="shouldShowFullUserName"
:user="user"
/> />
</span> </span>
<span <span

View File

@ -2,6 +2,7 @@ import SideDrawer from '../side_drawer/side_drawer.vue'
import Notifications from '../notifications/notifications.vue' import Notifications from '../notifications/notifications.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service' 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 { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -19,7 +20,8 @@ library.add(
const MobileNav = { const MobileNav = {
components: { components: {
SideDrawer, SideDrawer,
Notifications Notifications,
NavigationPins
}, },
data: () => ({ data: () => ({
notificationsCloseGesture: undefined, notificationsCloseGesture: undefined,
@ -47,7 +49,10 @@ const MobileNav = {
isChat () { isChat () {
return this.$route.name === 'chat' return this.$route.name === 'chat'
}, },
...mapGetters(['unreadChatCount']) ...mapGetters(['unreadChatCount']),
chatsPinned () {
return new Set(this.$store.state.serverSideStorage.prefsStorage.collections.pinnedNavItems).has('chats')
}
}, },
methods: { methods: {
toggleMobileSidebar () { toggleMobileSidebar () {

View File

@ -17,20 +17,12 @@
icon="bars" icon="bars"
/> />
<div <div
v-if="unreadChatCount" v-if="unreadChatCount && !chatsPinned"
class="alert-dot" class="alert-dot"
/> />
</button> </button>
<router-link <NavigationPins class="pins" />
v-if="!hideSitename" </div> <div class="item right">
class="site-name"
:to="{ name: 'root' }"
active-class="home"
>
{{ sitename }}
</router-link>
</div>
<div class="item right">
<button <button
v-if="currentUser" v-if="currentUser"
class="button-unstyled mobile-nav-button" class="button-unstyled mobile-nav-button"
@ -94,6 +86,7 @@
grid-template-columns: 2fr auto; grid-template-columns: 2fr auto;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
a { a {
color: var(--topBarLink, $fallback--link); color: var(--topBarLink, $fallback--link);
} }
@ -178,13 +171,20 @@
} }
} }
.pins {
flex: 1;
.pinned-item {
flex-grow: 1;
}
}
.mobile-notifications { .mobile-notifications {
margin-top: 50px; margin-top: 50px;
width: 100vw; width: 100vw;
height: calc(100vh - var(--navbar-height)); height: calc(100vh - var(--navbar-height));
overflow-x: hidden; overflow-x: hidden;
overflow-y: scroll; overflow-y: scroll;
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
background-color: $fallback--bg; background-color: $fallback--bg;
@ -194,14 +194,17 @@
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
.panel { .panel {
border-radius: 0; border-radius: 0;
margin: 0; margin: 0;
box-shadow: none; box-shadow: none;
} }
.panel:after {
.panel::after {
border-radius: 0; border-radius: 0;
} }
.panel .panel-heading { .panel .panel-heading {
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;

View File

@ -10,7 +10,8 @@ library.add(
const HIDDEN_FOR_PAGES = new Set([ const HIDDEN_FOR_PAGES = new Set([
'chats', 'chats',
'chat' 'chat',
'lists-edit'
]) ])
const MobilePostStatusButton = { const MobilePostStatusButton = {

View File

@ -1,5 +1,10 @@
import TimelineMenuContent from '../timeline_menu/timeline_menu_content.vue' import ListsMenuContent from 'src/components/lists_menu/lists_menu_content.vue'
import { mapState, mapGetters } from 'vuex' import { mapState, mapGetters } from 'vuex'
import { TIMELINES, ROOT_ITEMS } from 'src/components/navigation/navigation.js'
import { filterNavigation } from 'src/components/navigation/filter.js'
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -12,7 +17,8 @@ import {
faComments, faComments,
faBell, faBell,
faInfoCircle, faInfoCircle,
faStream faStream,
faList
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
@ -25,26 +31,52 @@ library.add(
faComments, faComments,
faBell, faBell,
faInfoCircle, faInfoCircle,
faStream faStream,
faList
) )
const NavPanel = { const NavPanel = {
props: ['forceExpand', 'forceEditMode'],
created () { created () {
if (this.currentUser && this.currentUser.locked) {
this.$store.dispatch('startFetchingFollowRequests')
}
}, },
components: { components: {
TimelineMenuContent ListsMenuContent,
NavigationEntry,
NavigationPins,
Checkbox
}, },
data () { data () {
return { return {
showTimelines: false editMode: false,
showTimelines: false,
showLists: false,
timelinesList: Object.entries(TIMELINES).map(([k, v]) => ({ ...v, name: k })),
rootList: Object.entries(ROOT_ITEMS).map(([k, v]) => ({ ...v, name: k }))
} }
}, },
methods: { methods: {
toggleTimelines () { toggleTimelines () {
this.showTimelines = !this.showTimelines this.showTimelines = !this.showTimelines
},
toggleLists () {
this.showLists = !this.showLists
},
toggleEditMode () {
this.editMode = !this.editMode
},
toggleCollapse () {
this.$store.commit('setPreference', { path: 'simple.collapseNav', value: !this.collapsed })
this.$store.dispatch('pushServerSideStorage')
},
isPinned (item) {
return this.pinnedItems.has(item)
},
togglePin (item) {
if (this.isPinned(item)) {
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value: item })
} else {
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value: item })
}
this.$store.dispatch('pushServerSideStorage')
} }
}, },
computed: { computed: {
@ -53,8 +85,36 @@ const NavPanel = {
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),
collapsed: state => state.serverSideStorage.prefsStorage.simple.collapseNav
}), }),
timelinesItems () {
return filterNavigation(
Object
.entries({ ...TIMELINES })
.map(([k, v]) => ({ ...v, name: k })),
{
hasChats: this.pleromaChatMessagesAvailable,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser
}
)
},
rootItems () {
return filterNavigation(
Object
.entries({ ...ROOT_ITEMS })
.map(([k, v]) => ({ ...v, name: k })),
{
hasChats: this.pleromaChatMessagesAvailable,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser
}
)
},
...mapGetters(['unreadChatCount']) ...mapGetters(['unreadChatCount'])
} }
} }

View File

@ -1,90 +1,99 @@
<template> <template>
<div class="NavPanel"> <div class="NavPanel">
<div class="panel panel-default"> <div class="panel panel-default">
<ul> <div
<li v-if="currentUser || !privateMode"> v-if="!forceExpand"
<button class="panel-heading nav-panel-heading"
class="button-unstyled menu-item" >
@click="toggleTimelines" <NavigationPins :limit="6" />
> <div class="spacer" />
<FAIcon <button
fixed-width class="button-unstyled"
class="fa-scale-110" @click="toggleCollapse"
icon="stream" >
/>{{ $t("nav.timelines") }} <FAIcon
<FAIcon class="timelines-chevron"
class="timelines-chevron" fixed-width
fixed-width :icon="collapsed ? 'chevron-down' : 'chevron-up'"
:icon="showTimelines ? 'chevron-up' : 'chevron-down'" />
</button>
</div>
<ul
v-if="!collapsed || forceExpand"
class="panel-body"
>
<NavigationEntry
v-if="currentUser || !privateMode"
:show-pin="false"
:item="{ icon: 'stream', label: 'nav.timelines' }"
:aria-expanded="showTimelines ? 'true' : 'false'"
@click="toggleTimelines"
>
<FAIcon
class="timelines-chevron"
fixed-width
:icon="showTimelines ? 'chevron-up' : 'chevron-down'"
/>
</NavigationEntry>
<div
v-show="showTimelines"
class="timelines-background"
>
<div class="timelines">
<NavigationEntry
v-for="item in timelinesItems"
:key="item.name"
:show-pin="editMode || forceEditMode"
:item="item"
/> />
</button>
<div
v-show="showTimelines"
class="timelines-background"
>
<TimelineMenuContent class="timelines" />
</div> </div>
</li> </div>
<li v-if="currentUser"> <NavigationEntry
v-if="currentUser"
:show-pin="false"
:item="{ icon: 'list', label: 'nav.lists' }"
:aria-expanded="showLists ? 'true' : 'false'"
@click="toggleLists"
>
<router-link <router-link
class="menu-item" :title="$t('lists.manage_lists')"
:to="{ name: 'interactions', params: { username: currentUser.screen_name } }" class="extra-button"
:to="{ name: 'lists' }"
@click.stop
> >
<FAIcon <FAIcon
class="extra-button"
fixed-width fixed-width
class="fa-scale-110" icon="wrench"
icon="bell" />
/>{{ $t("nav.interactions") }}
</router-link> </router-link>
</li> <FAIcon
<li v-if="currentUser && pleromaChatMessagesAvailable"> class="timelines-chevron"
<router-link fixed-width
class="menu-item" :icon="showLists ? 'chevron-up' : 'chevron-down'"
:to="{ name: 'chats', params: { username: currentUser.screen_name } }" />
> </NavigationEntry>
<div <div
v-if="unreadChatCount" v-show="showLists"
class="badge badge-notification" class="timelines-background"
> >
{{ unreadChatCount }} <ListsMenuContent
</div> :show-pin="editMode || forceEditMode"
<FAIcon class="timelines"
fixed-width />
class="fa-scale-110" </div>
icon="comments" <NavigationEntry
/>{{ $t("nav.chats") }} v-for="item in rootItems"
</router-link> :key="item.name"
</li> :show-pin="editMode || forceEditMode"
<li v-if="currentUser && currentUser.locked"> :item="item"
<router-link />
class="menu-item" <NavigationEntry
:to="{ name: 'friend-requests' }" v-if="!forceEditMode && currentUser"
> :show-pin="false"
<FAIcon :item="{ label: editMode ? $t('nav.edit_finish') : $t('nav.edit_pinned'), icon: editMode ? 'check' : 'wrench' }"
fixed-width @click="toggleEditMode"
class="fa-scale-110" />
icon="user-plus"
/>{{ $t("nav.friend_requests") }}
<span
v-if="followRequestCount > 0"
class="badge badge-notification"
>
{{ followRequestCount }}
</span>
</router-link>
</li>
<li>
<router-link
class="menu-item"
:to="{ name: 'about' }"
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="info-circle"
/>{{ $t("nav.about") }}
</router-link>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -112,7 +121,6 @@
border-bottom: 1px solid; border-bottom: 1px solid;
border-color: $fallback--border; border-color: $fallback--border;
border-color: var(--border, $fallback--border); border-color: var(--border, $fallback--border);
padding: 0;
} }
> li { > li {
@ -135,46 +143,9 @@
border: none; border: none;
} }
.menu-item {
display: block;
box-sizing: border-box;
height: 3.5em;
line-height: 3.5em;
padding: 0 1em;
width: 100%;
color: $fallback--link;
color: var(--link, $fallback--link);
&:hover {
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--link;
color: var(--selectedMenuText, $fallback--link);
--faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
--icon: var(--selectedMenuIcon, $fallback--icon);
}
&.router-link-active {
font-weight: bolder;
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--text;
color: var(--selectedMenuText, $fallback--text);
--faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
--icon: var(--selectedMenuIcon, $fallback--icon);
&:hover {
text-decoration: underline;
}
}
}
.timelines-chevron { .timelines-chevron {
margin-left: 0.8em; margin-left: 0.8em;
margin-right: 0.8em;
font-size: 1.1em; font-size: 1.1em;
} }
@ -182,7 +153,7 @@
padding: 0 0 0 0.6em; padding: 0 0 0 0.6em;
background-color: $fallback--lightBg; background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg); background-color: var(--selectedMenu, $fallback--lightBg);
border-top: 1px solid; border-bottom: 1px solid;
border-color: $fallback--border; border-color: $fallback--border;
border-color: var(--border, $fallback--border); border-color: var(--border, $fallback--border);
} }
@ -192,14 +163,9 @@
background-color: var(--bg, $fallback--bg); background-color: var(--bg, $fallback--bg);
} }
.fa-scale-110 { .nav-panel-heading {
margin-right: 0.8em; // breaks without a unit
} --panel-heading-height-padding: 0em;
.badge {
position: absolute;
right: 0.6rem;
top: 1.25em;
} }
} }
</style> </style>

View File

@ -0,0 +1,18 @@
export const filterNavigation = (list = [], { hasChats, isFederating, isPrivate, currentUser }) => {
return list.filter(({ criteria, anon, anonRoute }) => {
const set = new Set(criteria || [])
if (!isFederating && set.has('federating')) return false
if (isPrivate && set.has('!private')) return false
if (!currentUser && !(anon || anonRoute)) return false
if ((!currentUser || !currentUser.locked) && set.has('lockedUser')) return false
if (!hasChats && set.has('chats')) return false
return true
})
}
export const getListEntries = state => state.lists.allLists.map(list => ({
name: 'list-' + list.id,
routeObject: { name: 'lists-timeline', params: { id: list.id } },
labelRaw: list.title,
iconLetter: list.title[0]
}))

View File

@ -0,0 +1,75 @@
export const USERNAME_ROUTES = new Set([
'bookmarks',
'dms',
'interactions',
'notifications',
'chat',
'chats',
'user-profile'
])
export const TIMELINES = {
home: {
route: 'friends',
icon: 'home',
label: 'nav.home_timeline',
criteria: ['!private']
},
public: {
route: 'public-timeline',
anon: true,
icon: 'users',
label: 'nav.public_tl',
criteria: ['!private']
},
twkn: {
route: 'public-external-timeline',
anon: true,
icon: 'globe',
label: 'nav.twkn',
criteria: ['!private', 'federating']
},
bookmarks: {
route: 'bookmarks',
icon: 'bookmark',
label: 'nav.bookmarks'
},
favorites: {
routeObject: { name: 'user-profile', query: { tab: 'favorites' } },
icon: 'star',
label: 'user_card.favorites'
},
dms: {
route: 'dms',
icon: 'envelope',
label: 'nav.dms'
}
}
export const ROOT_ITEMS = {
interactions: {
route: 'interactions',
icon: 'bell',
label: 'nav.interactions'
},
chats: {
route: 'chats',
icon: 'comments',
label: 'nav.chats',
badgeGetter: 'unreadChatCount',
criteria: ['chats']
},
friendRequests: {
route: 'friend-requests',
icon: 'user-plus',
label: 'nav.friend_requests',
criteria: ['lockedUser'],
badgeGetter: 'followRequestCount'
},
about: {
route: 'about',
anon: true,
icon: 'info-circle',
label: 'nav.about'
}
}

View File

@ -0,0 +1,51 @@
import { mapState } from 'vuex'
import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
import OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faThumbtack } from '@fortawesome/free-solid-svg-icons'
library.add(faThumbtack)
const NavigationEntry = {
props: ['item', 'showPin'],
components: {
OptionalRouterLink
},
methods: {
isPinned (value) {
return this.pinnedItems.has(value)
},
togglePin (value) {
if (this.isPinned(value)) {
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value })
} else {
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value })
}
this.$store.dispatch('pushServerSideStorage')
}
},
computed: {
routeTo () {
if (!this.item.route && !this.item.routeObject) return null
let route
if (this.item.routeObject) {
route = this.item.routeObject
} else {
route = { name: (this.item.anon || this.currentUser) ? this.item.route : this.item.anonRoute }
}
if (USERNAME_ROUTES.has(route.name)) {
route.params = { username: this.currentUser.screen_name, name: this.currentUser.screen_name }
}
return route
},
getters () {
return this.$store.getters
},
...mapState({
currentUser: state => state.users.currentUser,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
})
}
}
export default NavigationEntry

View File

@ -0,0 +1,133 @@
<template>
<OptionalRouterLink
v-slot="{ isActive, href, navigate } = {}"
ass="ass"
:to="routeTo"
>
<li
class="NavigationEntry menu-item"
:class="{ '-active': isActive }"
v-bind="$attrs"
>
<component
:is="routeTo ? 'a' : 'button'"
class="main-link button-unstyled"
:href="href"
@click="navigate"
>
<span>
<FAIcon
v-if="item.icon"
fixed-width
class="fa-scale-110 menu-icon"
:icon="item.icon"
/>
</span>
<span
v-if="item.iconLetter"
class="icon iconLetter fa-scale-110 menu-icon"
>{{ item.iconLetter }}
</span>
<span class="label">
{{ item.labelRaw || $t(item.label) }}
</span>
</component>
<slot />
<div
v-if="item.badgeGetter && getters[item.badgeGetter]"
class="badge badge-notification"
>
{{ getters[item.badgeGetter] }}
</div>
<button
v-if="showPin && currentUser"
type="button"
class="button-unstyled extra-button"
:title="$t(isPinned ? 'general.unpin' : 'general.pin' )"
:aria-pressed="!!isPinned"
@click.stop.prevent="togglePin(item.name)"
>
<FAIcon
v-if="showPin && currentUser"
fixed-width
class="fa-scale-110"
:class="{ 'veryfaint': !isPinned(item.name) }"
:transform="!isPinned(item.name) ? 'rotate-45' : ''"
icon="thumbtack"
/>
</button>
</li>
</OptionalRouterLink>
</template>
<script src="./navigation_entry.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.NavigationEntry {
display: flex;
box-sizing: border-box;
align-items: baseline;
height: 3.5em;
line-height: 3.5em;
padding: 0 1em;
width: 100%;
color: $fallback--link;
color: var(--link, $fallback--link);
.timelines-chevron {
margin-right: 0;
}
.main-link {
flex: 1;
}
.menu-icon {
margin-right: 0.8em;
}
.extra-button {
width: 3em;
text-align: center;
&:last-child {
margin-right: -0.8em;
}
}
&:hover {
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--link;
color: var(--selectedMenuText, $fallback--link);
--faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
.menu-icon {
--icon: var(--text, $fallback--icon);
}
}
&.-active {
font-weight: bolder;
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--text;
color: var(--selectedMenuText, $fallback--text);
--faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
.menu-icon {
--icon: var(--text, $fallback--icon);
}
&:hover {
text-decoration: underline;
}
}
}
</style>

View File

@ -0,0 +1,88 @@
import { mapState } from 'vuex'
import { TIMELINES, ROOT_ITEMS, USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
import { getListEntries, filterNavigation } from 'src/components/navigation/filter.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faUsers,
faGlobe,
faBookmark,
faEnvelope,
faComments,
faBell,
faInfoCircle,
faStream,
faList
} from '@fortawesome/free-solid-svg-icons'
library.add(
faUsers,
faGlobe,
faBookmark,
faEnvelope,
faComments,
faBell,
faInfoCircle,
faStream,
faList
)
const NavPanel = {
props: ['limit'],
methods: {
getRouteTo (item) {
if (item.routeObject) {
return item.routeObject
}
const route = { name: (item.anon || this.currentUser) ? item.route : item.anonRoute }
if (USERNAME_ROUTES.has(route.name)) {
route.params = { username: this.currentUser.screen_name }
}
return route
}
},
computed: {
getters () {
return this.$store.getters
},
...mapState({
lists: getListEntries,
currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private,
federating: state => state.instance.federating,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
}),
pinnedList () {
if (!this.currentUser) {
return [
{ ...TIMELINES.public, name: 'public' },
{ ...TIMELINES.twkn, name: 'twkn' },
{ ...ROOT_ITEMS.about, name: 'about' }
]
}
return filterNavigation(
[
...Object
.entries({ ...TIMELINES })
.filter(([k]) => this.pinnedItems.has(k))
.map(([k, v]) => ({ ...v, name: k })),
...this.lists.filter((k) => this.pinnedItems.has(k.name)),
...Object
.entries({ ...ROOT_ITEMS })
.filter(([k]) => this.pinnedItems.has(k))
.map(([k, v]) => ({ ...v, name: k }))
],
{
hasChats: this.pleromaChatMessagesAvailable,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser
}
).slice(0, this.limit)
}
}
}
export default NavPanel

View File

@ -0,0 +1,76 @@
<template>
<span class="NavigationPins">
<router-link
v-for="item in pinnedList"
:key="item.name"
class="pinned-item"
:to="getRouteTo(item)"
:title="item.labelRaw || $t(item.label)"
>
<FAIcon
v-if="item.icon"
fixed-width
:icon="item.icon"
/>
<span
v-if="item.iconLetter"
class="iconLetter fa-scale-110 fa-old-padding"
>{{ item.iconLetter }}</span>
<div
v-if="item.badgeGetter && getters[item.badgeGetter]"
class="alert-dot"
/>
</router-link>
</span>
</template>
<script src="./navigation_pins.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.NavigationPins {
display: flex;
flex-wrap: wrap;
overflow: hidden;
height: 100%;
.alert-dot {
border-radius: 100%;
height: 0.5em;
width: 0.5em;
position: absolute;
right: calc(50% - 0.25em);
top: calc(50% - 0.25em);
margin-left: 6px;
margin-top: -6px;
background-color: $fallback--cRed;
background-color: var(--badgeNotification, $fallback--cRed);
}
.pinned-item {
position: relative;
flex: 1 0 3em;
min-width: 2em;
text-align: center;
overflow: visible;
box-sizing: border-box;
height: 100%;
& .svg-inline--fa,
& .iconLetter {
margin: 0;
}
&.router-link-active {
color: $fallback--text;
color: var(--selectedMenuText, $fallback--text);
border-bottom: 4px solid;
& .svg-inline--fa,
& .iconLetter {
color: inherit;
}
}
}
}
</style>

View File

@ -4,6 +4,8 @@ import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue' import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue' import Timeago from '../timeago/timeago.vue'
import Report from '../report/report.vue'
import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx' import RichContent from 'src/components/rich_content/rich_content.jsx'
import UserPopover from '../user_popover/user_popover.vue' import UserPopover from '../user_popover/user_popover.vue'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js' import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
@ -47,8 +49,10 @@ const Notification = {
UserCard, UserCard,
Timeago, Timeago,
Status, Status,
Report,
RichContent, RichContent,
UserPopover UserPopover,
UserLink
}, },
methods: { methods: {
toggleUserExpanded () { toggleUserExpanded () {

View File

@ -11,9 +11,10 @@
class="Notification container -muted" class="Notification container -muted"
> >
<small> <small>
<router-link :to="userProfileLink"> <user-link
{{ notification.from_profile.screen_name_ui }} :user="notification.from_profile"
</router-link> :at="false"
/>
</small> </small>
<button <button
class="button-unstyled unmute" class="button-unstyled unmute"
@ -121,6 +122,9 @@
</i18n-t> </i18n-t>
</small> </small>
</span> </span>
<span v-if="notification.type === 'pleroma:report'">
<small>{{ $t('notifications.submitted_report') }}</small>
</span>
<span v-if="notification.type === 'poll'"> <span v-if="notification.type === 'poll'">
<FAIcon <FAIcon
class="type-icon" class="type-icon"
@ -171,12 +175,10 @@
v-if="notification.type === 'follow' || notification.type === 'follow_request'" v-if="notification.type === 'follow' || notification.type === 'follow_request'"
class="follow-text" class="follow-text"
> >
<router-link <user-link
:to="userProfileLink"
class="follow-name" class="follow-name"
> :user="notification.from_profile"
@{{ notification.from_profile.screen_name_ui }} />
</router-link>
<div <div
v-if="notification.type === 'follow_request'" v-if="notification.type === 'follow_request'"
style="white-space: nowrap;" style="white-space: nowrap;"
@ -207,10 +209,14 @@
v-else-if="notification.type === 'move'" v-else-if="notification.type === 'move'"
class="move-text" class="move-text"
> >
<router-link :to="targetUserProfileLink"> <user-link
@{{ notification.target.screen_name_ui }} :user="notification.target"
</router-link> />
</div> </div>
<Report
v-else-if="notification.type === 'pleroma:report'"
:report-id="notification.report.id"
/>
<template v-else> <template v-else>
<StatusContent <StatusContent
class="faint" class="faint"

View File

@ -59,8 +59,10 @@
height: 32px; height: 32px;
} }
--link: var(--faintLink); .faint {
--text: var(--faint); --link: var(--faintLink);
--text: var(--faint);
}
} }
.follow-request-accept { .follow-request-accept {

View File

@ -0,0 +1,23 @@
<template>
<!-- eslint-disable vue/no-multiple-template-root -->
<router-link
v-if="to"
v-slot="props"
:to="to"
custom
>
<slot
v-bind="props"
/>
</router-link>
<slot
v-else
v-bind="{}"
/>
</template>
<script>
export default {
props: ['to']
}
</script>

View File

@ -4,7 +4,7 @@ const Popover = {
// Action to trigger popover: either 'hover' or 'click' // Action to trigger popover: either 'hover' or 'click'
trigger: String, trigger: String,
// Either 'top' or 'bottom' // 'top', 'bottom', 'left', 'right'
placement: String, placement: String,
// Takes object with properties 'x' and 'y', values of these can be // Takes object with properties 'x' and 'y', values of these can be
@ -84,6 +84,8 @@ const Popover = {
const anchorStyle = getComputedStyle(anchorEl) const anchorStyle = getComputedStyle(anchorEl)
const topPadding = parseFloat(anchorStyle.paddingTop) const topPadding = parseFloat(anchorStyle.paddingTop)
const bottomPadding = parseFloat(anchorStyle.paddingBottom) const bottomPadding = parseFloat(anchorStyle.paddingBottom)
const rightPadding = parseFloat(anchorStyle.paddingRight)
const leftPadding = parseFloat(anchorStyle.paddingLeft)
// Screen position of the origin point for popover = center of the anchor // Screen position of the origin point for popover = center of the anchor
const origin = { const origin = {
@ -170,7 +172,7 @@ const Popover = {
if (overlayCenter) { if (overlayCenter) {
translateX = origin.x + horizOffset translateX = origin.x + horizOffset
translateY = origin.y + vertOffset translateY = origin.y + vertOffset
} else { } else if (this.placement !== 'right' && this.placement !== 'left') {
// Default to whatever user wished with placement prop // Default to whatever user wished with placement prop
let usingTop = this.placement !== 'bottom' let usingTop = this.placement !== 'bottom'
@ -189,6 +191,25 @@ const Popover = {
const xOffset = (this.offset && this.offset.x) || 0 const xOffset = (this.offset && this.offset.x) || 0
translateX = origin.x + horizOffset + xOffset translateX = origin.x + horizOffset + xOffset
} else {
// Default to whatever user wished with placement prop
let usingRight = this.placement !== 'left'
// Handle special cases, first force to displaying on top if there's not space on bottom,
// regardless of what placement value was. Then check if there's not space on top, and
// force to bottom, again regardless of what placement value was.
const rightBoundary = origin.x - anchorWidth * 0.5 + (this.removePadding ? rightPadding : 0)
const leftBoundary = origin.x + anchorWidth * 0.5 - (this.removePadding ? leftPadding : 0)
if (leftBoundary + content.offsetWidth > xBounds.max) usingRight = true
if (rightBoundary - content.offsetWidth < xBounds.min) usingRight = false
const xOffset = (this.offset && this.offset.x) || 0
translateX = usingRight
? rightBoundary - xOffset - content.offsetWidth
: leftBoundary + xOffset
const yOffset = (this.offset && this.offset.y) || 0
translateY = origin.y + vertOffset + yOffset
} }
this.styles = { this.styles = {

View File

@ -126,6 +126,13 @@
} }
} }
&.-has-submenu {
.chevron-icon {
margin-right: 0.25rem;
margin-left: 2rem;
}
}
&:active, &:hover { &:active, &:hover {
background-color: $fallback--lightBg; background-color: $fallback--lightBg;
background-color: var(--selectedMenuPopover, $fallback--lightBg); background-color: var(--selectedMenuPopover, $fallback--lightBg);

View File

@ -55,6 +55,14 @@ const pxStringToNumber = (str) => {
const PostStatusForm = { const PostStatusForm = {
props: [ props: [
'statusId',
'statusText',
'statusIsSensitive',
'statusPoll',
'statusFiles',
'statusMediaDescriptions',
'statusScope',
'statusContentType',
'replyTo', 'replyTo',
'repliedUser', 'repliedUser',
'attentions', 'attentions',
@ -62,6 +70,7 @@ const PostStatusForm = {
'subject', 'subject',
'disableSubject', 'disableSubject',
'disableScopeSelector', 'disableScopeSelector',
'disableVisibilitySelector',
'disableNotice', 'disableNotice',
'disableLockWarning', 'disableLockWarning',
'disablePolls', 'disablePolls',
@ -125,22 +134,38 @@ const PostStatusForm = {
const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig
let statusParams = {
spoilerText: this.subject || '',
status: statusText,
nsfw: !!sensitiveByDefault,
files: [],
poll: {},
mediaDescriptions: {},
visibility: scope,
contentType
}
if (this.statusId) {
const statusContentType = this.statusContentType || contentType
statusParams = {
spoilerText: this.subject || '',
status: this.statusText || '',
nsfw: this.statusIsSensitive || !!sensitiveByDefault,
files: this.statusFiles || [],
poll: this.statusPoll || {},
mediaDescriptions: this.statusMediaDescriptions || {},
visibility: this.statusScope || scope,
contentType: statusContentType
}
}
return { return {
dropFiles: [], dropFiles: [],
uploadingFiles: false, uploadingFiles: false,
error: null, error: null,
posting: false, posting: false,
highlighted: 0, highlighted: 0,
newStatus: { newStatus: statusParams,
spoilerText: this.subject || '',
status: statusText,
nsfw: !!sensitiveByDefault,
files: [],
poll: {},
mediaDescriptions: {},
visibility: scope,
contentType
},
caret: 0, caret: 0,
pollFormVisible: false, pollFormVisible: false,
showDropIcon: 'hide', showDropIcon: 'hide',
@ -164,7 +189,7 @@ const PostStatusForm = {
emojiUserSuggestor () { emojiUserSuggestor () {
return suggestor({ return suggestor({
emoji: [ emoji: [
...this.$store.state.instance.emoji, ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji ...this.$store.state.instance.customEmoji
], ],
store: this.$store store: this.$store
@ -173,13 +198,13 @@ const PostStatusForm = {
emojiSuggestor () { emojiSuggestor () {
return suggestor({ return suggestor({
emoji: [ emoji: [
...this.$store.state.instance.emoji, ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji ...this.$store.state.instance.customEmoji
] ]
}) })
}, },
emoji () { emoji () {
return this.$store.state.instance.emoji || [] return this.$store.getters.standardEmojiList || []
}, },
customEmoji () { customEmoji () {
return this.$store.state.instance.customEmoji || [] return this.$store.state.instance.customEmoji || []
@ -236,6 +261,9 @@ const PostStatusForm = {
uploadFileLimitReached () { uploadFileLimitReached () {
return this.newStatus.files.length >= this.fileLimit return this.newStatus.files.length >= this.fileLimit
}, },
isEdit () {
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
},
...mapGetters(['mergedConfig']), ...mapGetters(['mergedConfig']),
...mapState({ ...mapState({
mobileLayout: state => state.interface.mobileLayout mobileLayout: state => state.interface.mobileLayout

View File

@ -66,6 +66,13 @@
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span> <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p> </p>
<div
v-if="isEdit"
class="visibility-notice edit-warning"
>
<p>{{ $t('post_status.edit_remote_warning') }}</p>
<p>{{ $t('post_status.edit_unsupported_warning') }}</p>
</div>
<div <div
v-if="!disablePreview" v-if="!disablePreview"
class="preview-heading faint" class="preview-heading faint"
@ -170,6 +177,7 @@
class="visibility-tray" class="visibility-tray"
> >
<scope-selector <scope-selector
v-if="!disableVisibilitySelector"
:show-all="showAllScopes" :show-all="showAllScopes"
:user-default="userDefaultScope" :user-default="userDefaultScope"
:original-scope="copyMessageScope" :original-scope="copyMessageScope"
@ -410,6 +418,16 @@
align-items: baseline; align-items: baseline;
} }
.visibility-notice.edit-warning {
> :first-child {
margin-top: 0;
}
> :last-child {
margin-bottom: 0;
}
}
.media-upload-icon, .poll-icon, .emoji-icon { .media-upload-icon, .poll-icon, .emoji-icon {
font-size: 1.85em; font-size: 1.85em;
line-height: 1.1; line-height: 1.1;

View File

@ -9,7 +9,10 @@ library.add(
faWrench faWrench
) )
const TimelineQuickSettings = { const QuickFilterSettings = {
props: {
conversation: Boolean
},
components: { components: {
Popover Popover
}, },
@ -64,4 +67,4 @@ const TimelineQuickSettings = {
} }
} }
export default TimelineQuickSettings export default QuickFilterSettings

View File

@ -1,13 +1,14 @@
<template> <template>
<Popover <Popover
trigger="click" trigger="click"
class="TimelineQuickSettings" class="QuickFilterSettings"
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
> >
<template #content> <template #content>
<div class="dropdown-menu"> <div class="dropdown-menu">
<div v-if="loggedIn"> <div v-if="loggedIn">
<button <button
v-if="!conversation"
class="button-default dropdown-item" class="button-default dropdown-item"
@click="replyVisibilityAll = true" @click="replyVisibilityAll = true"
> >
@ -17,6 +18,7 @@
/>{{ $t('settings.reply_visibility_all') }} />{{ $t('settings.reply_visibility_all') }}
</button> </button>
<button <button
v-if="!conversation"
class="button-default dropdown-item" class="button-default dropdown-item"
@click="replyVisibilityFollowing = true" @click="replyVisibilityFollowing = true"
> >
@ -26,6 +28,7 @@
/>{{ $t('settings.reply_visibility_following_short') }} />{{ $t('settings.reply_visibility_following_short') }}
</button> </button>
<button <button
v-if="!conversation"
class="button-default dropdown-item" class="button-default dropdown-item"
@click="replyVisibilitySelf = true" @click="replyVisibilitySelf = true"
> >
@ -35,6 +38,7 @@
/>{{ $t('settings.reply_visibility_self_short') }} />{{ $t('settings.reply_visibility_self_short') }}
</button> </button>
<div <div
v-if="!conversation"
role="separator" role="separator"
class="dropdown-divider" class="dropdown-divider"
/> />
@ -70,13 +74,7 @@
class="button-default dropdown-item dropdown-item-icon" class="button-default dropdown-item dropdown-item-icon"
@click="openTab('filtering')" @click="openTab('filtering')"
> >
<FAIcon icon="font" />{{ $t('settings.word_filter') }} <FAIcon icon="font" />{{ $t('settings.word_filter_and_more') }}
</button>
<button
class="button-default dropdown-item dropdown-item-icon"
@click="openTab('general')"
>
<FAIcon icon="wrench" />{{ $t('settings.more_settings') }}
</button> </button>
</div> </div>
</template> </template>
@ -88,11 +86,11 @@
</Popover> </Popover>
</template> </template>
<script src="./timeline_quick_settings.js"></script> <script src="./quick_filter_settings.js"></script>
<style lang="scss"> <style lang="scss">
.TimelineQuickSettings { .QuickFilterSettings {
> button { > button {
line-height: 100%; line-height: 100%;

View File

@ -0,0 +1,69 @@
import Popover from '../popover/popover.vue'
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faList, faFolderTree, faBars, faWrench } from '@fortawesome/free-solid-svg-icons'
library.add(
faList,
faFolderTree,
faBars,
faWrench
)
const QuickViewSettings = {
props: {
conversation: Boolean
},
components: {
Popover
},
methods: {
setConversationDisplay (visibility) {
this.$store.dispatch('setOption', { name: 'conversationDisplay', value: visibility })
},
openTab (tab) {
this.$store.dispatch('openSettingsModalTab', tab)
}
},
computed: {
...mapGetters(['mergedConfig']),
loggedIn () {
return !!this.$store.state.users.currentUser
},
conversationDisplay: {
get () { return this.mergedConfig.conversationDisplay },
set (newVal) { this.setConversationDisplay(newVal) }
},
autoUpdate: {
get () { return this.mergedConfig.streaming },
set () {
const value = !this.autoUpdate
this.$store.dispatch('setOption', { name: 'streaming', value })
}
},
collapseWithSubjects: {
get () { return this.mergedConfig.collapseMessageWithSubject },
set () {
const value = !this.collapseWithSubjects
this.$store.dispatch('setOption', { name: 'collapseMessageWithSubject', value })
}
},
showUserAvatars: {
get () { return this.mergedConfig.mentionLinkShowAvatar },
set () {
const value = !this.showUserAvatars
console.log(value)
this.$store.dispatch('setOption', { name: 'mentionLinkShowAvatar', value })
}
},
muteBotStatuses: {
get () { return this.mergedConfig.muteBotStatuses },
set () {
const value = !this.muteBotStatuses
this.$store.dispatch('setOption', { name: 'muteBotStatuses', value })
}
}
}
}
export default QuickViewSettings

View File

@ -0,0 +1,94 @@
<template>
<Popover
trigger="click"
class="QuickViewSettings"
:bound-to="{ x: 'container' }"
>
<template #content>
<div class="dropdown-menu">
<button
class="button-default dropdown-item"
@click="conversationDisplay = 'tree'"
>
<span
class="menu-checkbox -radio"
:class="{ 'menu-checkbox-checked': conversationDisplay === 'tree' }"
/><FAIcon icon="folder-tree" /> {{ $t('settings.conversation_display_tree_quick') }}
</button>
<button
class="button-default dropdown-item"
@click="conversationDisplay = 'linear'"
>
<span
class="menu-checkbox -radio"
:class="{ 'menu-checkbox-checked': conversationDisplay === 'linear' }"
/><FAIcon icon="list" /> {{ $t('settings.conversation_display_linear_quick') }}
</button>
<div
role="separator"
class="dropdown-divider"
/>
<button
class="button-default dropdown-item"
@click="showUserAvatars = !showUserAvatars"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': showUserAvatars }"
/>{{ $t('settings.mention_link_show_avatar_quick') }}
</button>
<button
v-if="!conversation"
class="button-default dropdown-item"
@click="autoUpdate = !autoUpdate"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': autoUpdate }"
/>{{ $t('settings.auto_update') }}
</button>
<button
v-if="!conversation"
class="button-default dropdown-item"
@click="collapseWithSubjects = !collapseWithSubjects"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': collapseWithSubjects }"
/>{{ $t('settings.collapse_subject') }}
</button>
<button
class="button-default dropdown-item dropdown-item-icon"
@click="openTab('general')"
>
<FAIcon icon="wrench" />{{ $t('settings.more_settings') }}
</button>
</div>
</template>
<template #trigger>
<button class="button-unstyled">
<FAIcon icon="bars" />
</button>
</template>
</Popover>
</template>
<script src="./quick_view_settings.js"></script>
<style lang="scss">
.QuickViewSettings {
> button {
line-height: 100%;
height: 100%;
width: var(--__panel-heading-height-inner);
text-align: center;
svg {
font-size: 1.2em;
}
}
}
</style>

View File

@ -1,15 +1,21 @@
import Popover from '../popover/popover.vue' import Popover from '../popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons'
import { faSmileBeam } from '@fortawesome/free-regular-svg-icons' import { faSmileBeam } from '@fortawesome/free-regular-svg-icons'
import { trim } from 'lodash' import { trim } from 'lodash'
library.add(faSmileBeam) library.add(
faPlus,
faTimes,
faSmileBeam
)
const ReactButton = { const ReactButton = {
props: ['status'], props: ['status'],
data () { data () {
return { return {
filterWord: '' filterWord: '',
expanded: false
} }
}, },
components: { components: {
@ -25,6 +31,13 @@ const ReactButton = {
} }
close() close()
}, },
onShow () {
this.expanded = true
this.focusInput()
},
onClose () {
this.expanded = false
},
focusInput () { focusInput () {
this.$nextTick(() => { this.$nextTick(() => {
const input = this.$el.querySelector('input') const input = this.$el.querySelector('input')
@ -46,7 +59,7 @@ const ReactButton = {
if (this.filterWord !== '') { if (this.filterWord !== '') {
const filterWordLowercase = trim(this.filterWord.toLowerCase()) const filterWordLowercase = trim(this.filterWord.toLowerCase())
const orderedEmojiList = [] const orderedEmojiList = []
for (const emoji of this.$store.state.instance.emoji) { for (const emoji of this.$store.getters.standardEmojiList) {
if (emoji.replacement === this.filterWord) return [emoji] if (emoji.replacement === this.filterWord) return [emoji]
const indexOfFilterWord = emoji.displayText.toLowerCase().indexOf(filterWordLowercase) const indexOfFilterWord = emoji.displayText.toLowerCase().indexOf(filterWordLowercase)
@ -59,7 +72,7 @@ const ReactButton = {
} }
return orderedEmojiList.flat() return orderedEmojiList.flat()
} }
return this.$store.state.instance.emoji || [] return this.$store.getters.standardEmojiList || []
}, },
mergedConfig () { mergedConfig () {
return this.$store.getters.mergedConfig return this.$store.getters.mergedConfig

View File

@ -7,7 +7,8 @@
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
remove-padding remove-padding
popover-class="ReactButton popover-default" popover-class="ReactButton popover-default"
@show="focusInput" @show="onShow"
@close="onClose"
> >
<template #content="{close}"> <template #content="{close}">
<div class="reaction-picker-filter"> <div class="reaction-picker-filter">
@ -46,10 +47,24 @@
class="button-unstyled popover-trigger" class="button-unstyled popover-trigger"
:title="$t('tool_tip.add_reaction')" :title="$t('tool_tip.add_reaction')"
> >
<FAIcon <FALayers>
class="fa-scale-110 fa-old-padding" <FAIcon
:icon="['far', 'smile-beam']" class="fa-scale-110 fa-old-padding"
/> :icon="['far', 'smile-beam']"
/>
<FAIcon
v-show="!expanded"
class="focus-marker"
transform="shrink-6 up-9 right-17"
icon="plus"
/>
<FAIcon
v-show="expanded"
class="focus-marker"
transform="shrink-6 up-9 right-17"
icon="times"
/>
</FALayers>
</span> </span>
</template> </template>
</Popover> </Popover>
@ -59,6 +74,7 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
@import '../../_mixins.scss';
.ReactButton { .ReactButton {
.reaction-picker-filter { .reaction-picker-filter {
@ -125,6 +141,21 @@
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
}
.popover-trigger-button {
@include unfocused-style {
.focus-marker {
visibility: hidden;
}
}
@include focused-style {
.focus-marker {
visibility: visible;
}
}
} }
} }

View File

@ -0,0 +1,25 @@
export default {
props: ['relationship'],
data () {
return {
inProgress: false
}
},
computed: {
label () {
if (this.inProgress) {
return this.$t('user_card.follow_progress')
} else {
return this.$t('user_card.remove_follower')
}
}
},
methods: {
onClick () {
this.inProgress = true
this.$store.dispatch('removeUserFromFollowers', this.relationship.id).then(() => {
this.inProgress = false
})
}
}
}

View File

@ -0,0 +1,13 @@
<template>
<button
class="btn button-default follow-button"
:class="{ toggled: inProgress }"
:disabled="inProgress"
:title="$t('user_card.remove_follower')"
@click="onClick"
>
{{ label }}
</button>
</template>
<script src="./remove_follower_button.js"></script>

View File

@ -1,7 +1,15 @@
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { faReply } from '@fortawesome/free-solid-svg-icons' import {
faReply,
faPlus,
faTimes
} from '@fortawesome/free-solid-svg-icons'
library.add(faReply) library.add(
faReply,
faPlus,
faTimes
)
const ReplyButton = { const ReplyButton = {
name: 'ReplyButton', name: 'ReplyButton',

View File

@ -7,10 +7,24 @@
:title="$t('tool_tip.reply')" :title="$t('tool_tip.reply')"
@click.prevent="$emit('toggle')" @click.prevent="$emit('toggle')"
> >
<FAIcon <FALayers class="fa-old-padding-layer">
class="fa-scale-110 fa-old-padding" <FAIcon
icon="reply" class="fa-scale-110"
/> icon="reply"
/>
<FAIcon
v-if="!replying"
class="focus-marker"
transform="shrink-6 up-8 right-11"
icon="plus"
/>
<FAIcon
v-else
class="focus-marker"
transform="shrink-6 up-8 right-11"
icon="times"
/>
</FALayers>
</button> </button>
<span v-else> <span v-else>
<FAIcon <FAIcon
@ -32,6 +46,7 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
@import '../../_mixins.scss';
.ReplyButton { .ReplyButton {
display: flex; display: flex;
@ -52,6 +67,18 @@
color: $fallback--cBlue; color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue); color: var(--cBlue, $fallback--cBlue);
} }
@include unfocused-style {
.focus-marker {
visibility: hidden;
}
}
@include focused-style {
.focus-marker {
visibility: visible;
}
}
} }
} }

View File

@ -0,0 +1,34 @@
import Select from '../select/select.vue'
import StatusContent from '../status_content/status_content.vue'
import Timeago from '../timeago/timeago.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const Report = {
props: [
'reportId'
],
components: {
Select,
StatusContent,
Timeago
},
computed: {
report () {
return this.$store.state.reports.reports[this.reportId] || {}
},
state: {
get: function () { return this.report.state },
set: function (val) { this.setReportState(val) }
}
},
methods: {
generateUserProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
},
setReportState (state) {
return this.$store.dispatch('setReportState', { id: this.report.id, state })
}
}
}
export default Report

View File

@ -0,0 +1,43 @@
@import '../../_variables.scss';
.Report {
.report-content {
margin: 0.5em 0 1em;
}
.report-state {
margin: 0.5em 0 1em;
}
.reported-status {
border: 1px solid $fallback--faint;
border-color: var(--faint, $fallback--faint);
border-radius: $fallback--inputRadius;
border-radius: var(--inputRadius, $fallback--inputRadius);
color: $fallback--text;
color: var(--text, $fallback--text);
display: block;
padding: 0.5em;
margin: 0.5em 0;
.status-content {
pointer-events: none;
}
.reported-status-heading {
display: flex;
width: 100%;
justify-content: space-between;
margin-bottom: 0.2em;
}
.reported-status-name {
font-weight: bold;
}
}
.note {
width: 100%;
margin-bottom: 0.5em;
}
}

View File

@ -0,0 +1,74 @@
<template>
<div class="Report">
<div class="reported-user">
<span>{{ $t('report.reported_user') }}</span>
<router-link :to="generateUserProfileLink(report.acct)">
@{{ report.acct.screen_name }}
</router-link>
</div>
<div class="reporter">
<span>{{ $t('report.reporter') }}</span>
<router-link :to="generateUserProfileLink(report.actor)">
@{{ report.actor.screen_name }}
</router-link>
</div>
<div class="report-state">
<span>{{ $t('report.state') }}</span>
<Select
:id="report-state"
v-model="state"
class="form-control"
>
<option
v-for="state in ['open', 'closed', 'resolved']"
:key="state"
:value="state"
>
{{ $t('report.state_' + state) }}
</option>
</Select>
</div>
<RichContent
class="report-content"
:html="report.content"
:emoji="[]"
/>
<div v-if="report.statuses.length">
<small>{{ $t('report.reported_statuses') }}</small>
<router-link
v-for="status in report.statuses"
:key="status.id"
:to="{ name: 'conversation', params: { id: status.id } }"
class="reported-status"
>
<div class="reported-status-heading">
<span class="reported-status-name">{{ status.user.name }}</span>
<Timeago
:time="status.created_at"
:auto-update="240"
class="faint"
/>
</div>
<status-content :status="status" />
</router-link>
</div>
<div v-if="report.notes.length">
<small>{{ $t('report.notes') }}</small>
<div
v-for="note in report.notes"
:key="note.id"
class="note"
>
<span>{{ note.content }}</span>
<Timeago
:time="note.created_at"
:auto-update="240"
class="faint"
/>
</div>
</div>
</div>
</template>
<script src="./report.js"></script>
<style src="./report.scss" lang="scss"></style>

View File

@ -1,7 +1,17 @@
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { faRetweet } from '@fortawesome/free-solid-svg-icons' import {
faRetweet,
faPlus,
faMinus,
faCheck
} from '@fortawesome/free-solid-svg-icons'
library.add(faRetweet) library.add(
faRetweet,
faPlus,
faMinus,
faCheck
)
const RetweetButton = { const RetweetButton = {
props: ['status', 'loggedIn', 'visibility'], props: ['status', 'loggedIn', 'visibility'],

View File

@ -7,11 +7,31 @@
:title="$t('tool_tip.repeat')" :title="$t('tool_tip.repeat')"
@click.prevent="retweet()" @click.prevent="retweet()"
> >
<FAIcon <FALayers class="fa-old-padding-layer">
class="fa-scale-110 fa-old-padding" <FAIcon
icon="retweet" class="fa-scale-110"
:spin="animated" icon="retweet"
/> :spin="animated"
/>
<FAIcon
v-if="status.repeated"
class="active-marker"
transform="shrink-6 up-9 right-12"
icon="check"
/>
<FAIcon
v-if="!status.repeated"
class="focus-marker"
transform="shrink-6 up-9 right-12"
icon="plus"
/>
<FAIcon
v-else
class="focus-marker"
transform="shrink-6 up-9 right-12"
icon="minus"
/>
</FALayers>
</button> </button>
<span v-else-if="loggedIn"> <span v-else-if="loggedIn">
<FAIcon <FAIcon
@ -40,6 +60,7 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
@import '../../_mixins.scss';
.RetweetButton { .RetweetButton {
display: flex; display: flex;
@ -64,6 +85,26 @@
color: $fallback--cGreen; color: $fallback--cGreen;
color: var(--cGreen, $fallback--cGreen); color: var(--cGreen, $fallback--cGreen);
} }
@include unfocused-style {
.focus-marker {
visibility: hidden;
}
.active-marker {
visibility: visible;
}
}
@include focused-style {
.focus-marker {
visibility: visible;
}
.active-marker {
visibility: hidden;
}
}
} }
} }
</style> </style>

View File

@ -47,6 +47,8 @@
class="cancel-icon fa-scale-110 fa-old-padding" class="cancel-icon fa-scale-110 fa-old-padding"
/> />
</button> </button>
<span class="spacer" />
<span class="spacer" />
</template> </template>
</div> </div>
</template> </template>

View File

@ -42,6 +42,9 @@ export default {
methods: { methods: {
update (e) { update (e) {
set(this.$parent, this.path, e) set(this.$parent, this.path, e)
},
reset () {
set(this.$parent, this.path, this.defaultState)
} }
} }
} }

View File

@ -15,7 +15,12 @@
<slot /> <slot />
</span> </span>
{{ ' ' }} {{ ' ' }}
<ModifiedIndicator :changed="isChanged" /><ServerSideIndicator :server-side="isServerSide" /> </Checkbox> <ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<ServerSideIndicator :server-side="isServerSide" />
</Checkbox>
</label> </label>
</template> </template>

View File

@ -43,6 +43,9 @@ export default {
methods: { methods: {
update (e) { update (e) {
set(this.$parent, this.path, e) set(this.$parent, this.path, e)
},
reset () {
set(this.$parent, this.path, this.defaultState)
} }
} }
} }

View File

@ -19,7 +19,10 @@
{{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }} {{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }}
</option> </option>
</Select> </Select>
<ModifiedIndicator :changed="isChanged" /> <ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<ServerSideIndicator :server-side="isServerSide" /> <ServerSideIndicator :server-side="isServerSide" />
</label> </label>
</template> </template>

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