Merge remote-tracking branch 'upstream/develop' into birthdays

This commit is contained in:
tusooa 2023-01-22 09:34:01 -05:00
commit b1e75c25bd
No known key found for this signature in database
GPG Key ID: 7B467EDE43A08224
300 changed files with 13879 additions and 9055 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

@ -0,0 +1,25 @@
# Environment info
<!-- Everything is optional and where applicable but the more information the better. -->
* Browser, version, OS, platform:
* Instance URL:
* Frontend version (see settings -> about):
* Backend version (see settings -> about):
* Browser extensions (ublock, rikaichamp etc):
* Known instance/user customizations (i.e. pleromafe mods/forks, instance styles etc)
# Bug description & reproduction steps
<!-- Type out here how to reproduce the bug, what goes wrong and what should go right -->
<!-- Screenshots and videos help a lot ;) any observations might also help -->
<!-- Also mention if there any errors in browser's console if relevant -->
# Bug seriousness
<!-- Everything is optional and free-form -->
* How annoying it is:
* How often does it happen:
* How many people does it affect:
* Is there a workaround for it:
/label ~Bug

View File

@ -0,0 +1,11 @@
# Behavior suggestion/Feature request
<!--
Type out what you want to see changed or what feature you want to see added to
PleormaFE. Please also explain how it would benefit users (or admins/moderators)
and what intended usecase is. Any background information (i.e. porting behavior
from other frontends/services, specific situations, personal preferences etc.)
as well as examples would be greatly appreciated.
-->
/label ~suggestion

View File

@ -0,0 +1,7 @@
<!--
please use one of the templates if applicable, otherwise - type out here
in free-form
-->
/label ~needs-triage

View File

@ -0,0 +1,30 @@
<!--
Feel free to submit merge requests that are work-in-progress, but mark them as
Draft: or WIP:.
Merge requests that have Draft or WIP status will not be merged and have less chances
of being reviewed, but you can still ask people to take a look if you need advice.
-->
# Changes
*
*
*
<!-- List what your merge request changes and how -->
<!--
Try to not to break existing behavior, if your changes do break existing behavior
make it configurable to toggle between old behavior and new. Which one should be
default is up to discussion.
-->
<!-- If your merge request resolves some issue link it like so: "Closes #99999" -->
<!--
If merge request adds some new feature that depends on backend:
1. Make sure it gracefully degrades if backend hasn't been updated to support the feature,
we try to make PleromaFE compatible with older versions of BE so that people can still
update frontend safely without updating backend since it's costly and much riskier.
2. Link related BE merge request here
-->
<!-- Screenshots are welcome -->
/label ~needs-review

View File

@ -1 +1 @@
16.16.0 16.18.1

View File

@ -1,19 +1,41 @@
{ {
"extends": [ "extends": [
"stylelint-rscss/config", "stylelint-rscss/config",
"stylelint-config-recommended", "stylelint-config-standard",
"stylelint-config-standard" "stylelint-config-recommended-scss",
"stylelint-config-html",
"stylelint-config-recommended-vue/scss"
], ],
"rules": { "rules": {
"declaration-no-important": true, "declaration-no-important": true,
"rscss/no-descendant-combinator": false, "rscss/no-descendant-combinator": false,
"rscss/class-format": [ "rscss/class-format": [
true, false,
{ {
"component": "pascal-case", "component": "pascal-case",
"variant": "^-[a-z]\\w+", "variant": "^-[a-z]\\w+",
"element": "^[a-z]\\w+" "element": "^[a-z]\\w+"
} }
],
"selector-class-pattern": null,
"import-notation": null,
"custom-property-pattern": null,
"keyframes-name-pattern": null,
"scss/operator-no-newline-after": null,
"declaration-block-no-redundant-longhand-properties": [
true,
{
"ignoreShorthands": [
"grid-template",
"margin",
"padding",
"border",
"border-width",
"border-style",
"border-color",
"border-radius"
]
}
] ]
} }
} }

View File

@ -3,51 +3,76 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased ## 2.5.0 - 23.12.2022
### Fixed ### Fixed
- AdminFE button no longer scrolls page to top when clicked - UI no longer lags when switching between mobile and desktop mode
- Popovers no longer constrained by DOM hierarchy, shouldn't be cut off by anything
- Emoji autocomplete popover and picker popover stick to the text cursor.
- Attachments are ALWAYS in same order as user uploaded, no more "videos first"
- Pinned statuses no longer appear at bottom of user timeline (still appear as part of the timeline when fetched deep enough) - Pinned statuses no longer appear at bottom of user timeline (still appear as part of the timeline when fetched deep enough)
- Fixed many many bugs related to new mentions, including spacing and alignment issues - Fixed many many bugs related to new mentions, including spacing and alignment issues
- Links in profile bios now properly open in new tabs - Links in profile bios now properly open in new tabs
- "Always show mobile button" is working now
- Inline images now respect their intended width/height attributes - Inline images now respect their intended width/height attributes
- Links with `&` in them work properly now - Links with `&` in them work properly now
- Interaction list popovers now properly emojify names
- Completely hidden posts still had 1px border
- Attachments are ALWAYS in same order as user uploaded, no more "videos first"
- Attachment description is prefilled with backend-provided default when uploading - Attachment description is prefilled with backend-provided default when uploading
- Proper visual feedback that next image is loading when browsing - Proper visual feedback that next image is loading when browsing
- UI no longer lags when switching between mobile and desktop mode - Additional HTML sanitization on frontend side in case backend sanitization fails
- Popovers no longer constrained by DOM hierarchy, shouldn't be cut off by anything - Interaction list popovers now properly emojify names
- "Always show mobile button" is working now - AdminFE button no longer scrolls page to top when clicked
- User handles with non-ascii domains now have less intrusive indicator for the domain name
- Completely hidden posts still no longer have 1px border
- A lot of accessibility improvements
### Changed ### Changed
- Using Vue 3 now - Using Vue 3 now
- (You)s are optional (opt-in) now, bolding your nickname is also optional (opt-out) - A lot of internal dependencies updated
- "(You)s" are optional (opt-in) now, bolding your nickname is also optional (opt-out)
- User highlight background now also covers the `@` - User highlight background now also covers the `@`
- Reverted back to textual `@`, svg version is opt-in. - Reverted back to textual `@`, svg version is opt-in.
- Settings window has been thoroughly rearranged to make more sense and make navigation settings easier. - Settings window has been thoroughly rearranged to make more sense and make navigation settings easier.
- Uploaded attachments are uniform with displayed attachments - Uploaded attachments are uniform with displayed attachments
- Flash is watchable in media-modal (takes up nearly full screen though due to sizing issues) - Flash is watchable in media-modal (takes up nearly full screen though due to sizing issues)
- Notifications about likes/repeats/emoji reacts are now minimized so they always take up same amount of space irrelevant to size of post. - Notifications about likes/repeats/emoji reacts are now minimized so they always take up same amount of space irrelevant to size of post. (You can expand them to full if need be)
- Slight width/spacing adjustments - Slight width/spacing adjustments
- More sizing stuff is font-size dependent now - More sizing stuff is font-size dependent now
- Scrollbars are styled/colorized now - Scrollbars are styled/colorized now
- Scrollbars are toggleable (for stuff that didn't have visible scrollbars before) (opt-in) - Scrollbars are toggleable (for stuff that didn't have visible scrollbars before) (opt-in)
- Updated localization files
- Top bar is more useful in mobile mode now.
- "Show new" button is way more compact in mobile mode
- Slightly adjusted placement and spacing of the topbar buttons so it's less easy to accidentally log yourself out
### Added ### Added
- 3 column mode: only enables when there's space for it (opt-out, customizable) - 3 column mode: only enables when there's space for it (opt-out, customizable)
- Apologetic pleroma-tan
- New button on timeline header to change some of the new and often-used settings
- Support for lists
- Added ability to edit posts and view post edit history etc.
- Added ability to add personal note to users
- Added initial support for admin announcements
- Added ui for account migration
- Added ui for backups
- Added ability to force-unfollow a user from you
- Emoji are now grouped by pack
- Ability to pin navigation items and collapse the navigation menu
- Ability to rearrange order of attachments when uploading
- Ability to scroll column (or page) to top via panel header button
- Options to show domains in mentions - Options to show domains in mentions
- Option to show user avatars in mention links (opt-in) - Option to show user avatars in mention links (opt-in)
- Option to disable the tooltip for mentions - Option to disable the tooltip for mentions
- Option to completely hide muted threads - Option to completely hide muted threads
- Option to customize what clicking user avatar does in user popover
- Notifications for poll results
- "Favorites" link in navigation
- Very early and somewhat experimental system for automatic settings sync (used only for pinned navigation and apologetic pleroma-tan)
- Implemented remote interaction with statuses for anon visitors
- Ability to open videos in modal even if you disabled that feature, via an icon button - Ability to open videos in modal even if you disabled that feature, via an icon button
- New button on attachment that indicates that attachment has a description and shows a bar filled with description - New button on attachment that indicates that attachment has a description and shows a bar filled with description
- Attachments are truncated just like post contents - Attachments are truncated just like post contents
- Media modal now also displays description and counter position in gallery (i.e. 1/5) - Media modal now also displays description and counter position in gallery (i.e. 1/5)
- Ability to rearrange order of attachments when uploading
- Enabled users to zoom and pan images in media viewer with mouse and touch - Enabled users to zoom and pan images in media viewer with mouse and touch
- Timelines/panels and conversations have sticky headers now - Timelines/panels and conversations have sticky headers now (a bit glitchy on some browsers like safari) (opt-out)
- Added frontend ui for account migration
## [2.4.2] - 2022-01-09 ## [2.4.2] - 2022-01-09

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@kazv.moe): Code
- tusooa (tusooa@kazv.moe): Code

View File

@ -1,18 +1,19 @@
# Pleroma-FE # Pleroma-FE
> A single column frontend designed for Pleroma. > Highly-customizable frontend designed for Pleroma.
![screenshot](/uploads/796c5ecf985ed1e2b0943ee0df131ed0/DJVqSJ0.png) ![screenshot](./image-1.png)
# For Translators # For Translators
To translate Pleroma-FE, add your language to [src/i18n/messages.js](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/i18n/messages.js). Pleroma-FE will set your language by your browser locale, but you can temporarily force it in the code by changing the locale in main.js. To translate Pleroma-FE, use our weblate server: https://translate.pleroma.social/. If you need to add your language it should be added as a json file in [src/i18n/](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/i18n/) folder and added in a list within [src/i18n/languages.js](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/i18n/languages.js).
# FOR ADMINS Pleroma-FE will set your language by your browser locale, but you can change language in settings.
You don't need to build Pleroma-FE yourself. Those using the Pleroma backend will be able to use it out of the box. # For instance admins
You don't need to build Pleroma-FE yourself. Those using the Pleroma backend will be able to use it out of the box. Information of customizing PleromaFE settings/defaults is in our [guide](https://docs-develop.pleroma.social/frontend/CONFIGURATION/) and in case you want to build your own custom version there's [another](https://docs-develop.pleroma.social/frontend/HACKING/)
## Build Setup # Build Setup
``` bash ``` bash
# install dependencies # install dependencies
@ -20,13 +21,13 @@ npm install -g yarn
yarn yarn
# serve with hot reload at localhost:8080 # serve with hot reload at localhost:8080
npm run dev yarn dev
# build for production with minification # build for production with minification
npm run build yarn build
# run unit tests # run unit tests
npm run unit yarn unit
``` ```
# For Contributors: # For Contributors:
@ -40,10 +41,4 @@ FE Build process also leaves current commit hash in global variable `___pleromaf
# Configuration # Configuration
Edit config.json for configuration. Set configuration settings in AdminFE, additionally you can edit config.json. For more details see [documentation](https://docs-develop.pleroma.social/frontend/CONFIGURATION/).
## Options
### Login methods
```loginMethod``` can be set to either ```password``` (the default) or ```token```, which will use the full oauth redirection flow, which is useful for SSO situations.

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)
@ -33,4 +36,8 @@ webpack(webpackConfig, function (err, stats) {
chunks: false, chunks: false,
chunkModules: false chunkModules: false
}) + '\n') }) + '\n')
if (stats.hasErrors()) {
console.error('See above for errors.')
process.exit(1)
}
}) })

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,11 +2,11 @@ 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');
var StylelintPlugin = require('stylelint-webpack-plugin');
var env = process.env.NODE_ENV var env = process.env.NODE_ENV
// check env & config/index.js to decide weither to enable CSS Sourcemaps for the // check env & config/index.js to decide weither to enable CSS Sourcemaps for the
@ -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]')
}
} }
}, },
{ {
@ -112,14 +111,14 @@ module.exports = {
extensions: ['js', 'vue'], extensions: ['js', 'vue'],
formatter: require('eslint-formatter-friendly') formatter: require('eslint-formatter-friendly')
}), }),
new StylelintPlugin({}),
new VueLoaderPlugin(), new VueLoaderPlugin(),
// This copies Ruffle's WASM to a directory so that JS side can access it // This copies Ruffle's WASM to a directory so that JS side can access it
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

@ -25,7 +25,17 @@ This could be a bit trickier, you basically need steps 1-4 from *develop build*
### Replacing your instance's frontend with custom FE build ### Replacing your instance's frontend with custom FE build
This is the most easiest way to use and test FE build: you just need to copy or symlink contents of `dist` folder into backend's [static directory](../backend/configuration/static_dir.md), by default it is located in `instance/static`, or in `/var/lib/pleroma/static` for OTP release installations, create it if it doesn't exist already. Be aware that running `yarn build` wipes the contents of `dist` folder. #### New way (via AdminFE, a bit janky but works)
In backend's [static directory](../backend/configuration/static_dir.md) there should be a folder called `frontends` if you installed any frontends from AdminFE before, otherwise you can create it yourself (ensuring correct permissions). Backend will serve given frontend from path `frontends/{frontend}/{reference}`, where `{frontend}` is name of frontend (`pleroma-fe`) and `{reference}` is version. You could make a production build, move `dist` folder into `frontends/pleroma-fe` and rename it into something like `myCustomVersion`. To actually make backend serve this frontend by default, in AdminFE you'll need to set name/reference in Settings -> Frontend -> Frontends -> Primary.
You could also install from a zip file (i.e. CI build) but AdminFE UI is a bit buggy and lacking, so this approach is not recommended.
Take note that frontend management is in early development and currently there's no way for user to change frontend or version for themselves, primary frontend becomes default frontend for all users and visitors.
#### Old way (replaces everything, hard to maintain, not recommended)
Copy or symlink contents of `dist` folder into backend's [static directory](../backend/configuration/static_dir.md), by default it is located in `instance/static`, or in `/var/lib/pleroma/static` for OTP release installations, create it if it doesn't exist already. Be aware that running `yarn build` wipes the contents of `dist` folder, and this could remove emojis, other frontends etc. and therefore this approach is not recommended.
### Running production build locally or on a separate server ### Running production build locally or on a separate server

BIN
image-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

BIN
image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -10,5 +10,6 @@
<noscript>To use Pleroma, please enable JavaScript.</noscript> <noscript>To use Pleroma, please enable JavaScript.</noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->
<div id="popovers" />
</body> </body>
</html> </html>

View File

@ -1,9 +1,9 @@
{ {
"name": "pleroma_fe", "name": "pleroma_fe",
"version": "1.0.0", "version": "2.5.0",
"description": "A Qvitter-style frontend for certain GS servers.", "description": "Pleroma frontend, the default frontend of Pleroma social network server",
"author": "Roger Braun <roger@rogerbraun.net>", "author": "Pleroma contributors <https://git.pleroma.social/pleroma/pleroma-fe/-/blob/develop/CONTRIBUTORS.md>",
"private": true, "private": false,
"scripts": { "scripts": {
"dev": "node build/dev-server.js", "dev": "node build/dev-server.js",
"build": "node build/build.js", "build": "node build/build.js",
@ -11,120 +11,125 @@
"unit:watch": "karma start test/unit/karma.conf.js --single-run=false", "unit:watch": "karma start test/unit/karma.conf.js --single-run=false",
"e2e": "node test/e2e/runner.js", "e2e": "node test/e2e/runner.js",
"test": "npm run unit && npm run e2e", "test": "npm run unit && npm run e2e",
"stylelint": "npx stylelint src/components/status/status.scss", "stylelint": "npx stylelint '**/*.scss' '**/*.vue'",
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs", "lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs",
"lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs" "lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.18.9", "@babel/runtime": "7.20.7",
"@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.1",
"@fortawesome/free-regular-svg-icons": "6.1.2", "@fortawesome/free-regular-svg-icons": "6.2.1",
"@fortawesome/free-solid-svg-icons": "6.1.2", "@fortawesome/free-solid-svg-icons": "6.2.1",
"@fortawesome/vue-fontawesome": "3.0.1", "@fortawesome/vue-fontawesome": "3.0.2",
"@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/validators": "2.0.0-alpha.31", "@vuelidate/core": "2.0.0",
"@vuelidate/validators": "2.0.0",
"body-scroll-lock": "3.1.5", "body-scroll-lock": "3.1.5",
"chromatism": "3.0.0", "chromatism": "3.0.0",
"click-outside-vue3": "4.0.1", "click-outside-vue3": "4.0.1",
"cropperjs": "1.5.12", "cropperjs": "1.5.12",
"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", "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.45",
"vue-template-compiler": "2.7.8", "vue-i18n": "9.2.2",
"vuex": "4.0.2" "vue-router": "4.1.6",
"vue-template-compiler": "2.7.14",
"vue-virtual-scroller": "^2.0.0-beta.7",
"vuex": "4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.18.9", "@babel/core": "7.20.7",
"@babel/plugin-transform-runtime": "7.18.9", "@babel/eslint-parser": "7.19.1",
"@babel/preset-env": "7.18.9", "@babel/plugin-transform-runtime": "7.19.6",
"@babel/preset-env": "7.20.2",
"@babel/register": "7.18.9", "@babel/register": "7.18.9",
"@babel/eslint-parser": "7.18.9", "@intlify/vue-i18n-loader": "5.0.0",
"@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.45",
"@vue/test-utils": "2.0.2", "@vue/test-utils": "2.2.7",
"autoprefixer": "6.7.7", "autoprefixer": "10.4.13",
"babel-loader": "8.2.5", "babel-loader": "9.1.0",
"babel-plugin-lodash": "3.3.4", "babel-plugin-lodash": "3.3.4",
"chai": "3.5.0", "chai": "4.3.7",
"chalk": "1.1.3", "chalk": "1.1.3",
"chromedriver": "103.0.0", "chromedriver": "108.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.3",
"css-minimizer-webpack-plugin": "4.2.2",
"custom-event-polyfill": "1.0.7", "custom-event-polyfill": "1.0.7",
"eslint": "8.20.0", "eslint": "8.32.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.6.1",
"eslint-plugin-promise": "6.0.0", "eslint-plugin-promise": "6.1.1",
"eslint-plugin-vue": "9.3.0", "eslint-plugin-vue": "9.8.0",
"eslint-webpack-plugin": "3.2.0",
"eventsource-polyfill": "0.9.6", "eventsource-polyfill": "0.9.6",
"express": "4.18.1", "express": "4.18.2",
"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.1",
"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.36",
"karma-webpack": "4.0.2", "karma-webpack": "5.0.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"lolex": "1.6.0", "mini-css-extract-plugin": "2.7.2",
"mini-css-extract-plugin": "0.12.0", "mocha": "10.2.0",
"mocha": "3.5.3", "nightwatch": "2.6.10",
"nightwatch": "0.9.21", "opn": "5.5.0",
"opn": "4.0.2",
"ora": "0.4.1", "ora": "0.4.1",
"postcss-loader": "3.0.0", "postcss": "8.4.20",
"raw-loader": "0.5.1", "postcss-html": "^1.5.0",
"sass": "1.54.0", "postcss-loader": "7.0.2",
"sass-loader": "7.3.1", "postcss-scss": "^4.0.6",
"sass": "1.57.1",
"sass-loader": "13.2.0",
"selenium-server": "2.53.1", "selenium-server": "2.53.1",
"semver": "5.7.1", "semver": "7.3.8",
"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": "15.0.1",
"sinon-chai": "2.14.0", "sinon-chai": "3.7.0",
"stylelint": "13.13.1", "stylelint": "14.16.1",
"stylelint-config-standard": "20.0.0", "stylelint-config-html": "^1.1.0",
"stylelint-config-recommended-scss": "^8.0.0",
"stylelint-config-recommended-vue": "^1.4.0",
"stylelint-config-standard": "29.0.0",
"stylelint-rscss": "0.4.0", "stylelint-rscss": "0.4.0",
"url-loader": "1.1.2", "stylelint-webpack-plugin": "^3.3.0",
"vue-loader": "^16.0.0", "vue-loader": "17.0.1",
"vue-style-loader": "4.1.3", "vue-style-loader": "4.1.3",
"webpack": "4.46.0", "webpack": "5.75.0",
"webpack-dev-middleware": "3.7.3", "webpack-dev-middleware": "3.7.3",
"webpack-hot-middleware": "2.25.1", "webpack-hot-middleware": "2.25.3",
"webpack-merge": "0.20.0" "webpack-merge": "0.20.0"
}, },
"engines": { "engines": {
"node": ">= 4.0.0", "node": ">= 16.0.0",
"npm": ">= 3.0.0" "npm": ">= 3.0.0"
} }
} }

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

@ -1,16 +1,18 @@
// stylelint-disable rscss/class-format // stylelint-disable rscss/class-format
@import './_variables.scss'; /* stylelint-disable no-descending-specificity */
@import "./variables";
@import "./panel";
:root { :root {
--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,20 +119,35 @@ h4 {
margin: 0; margin: 0;
} }
i[class*=icon-], .iconLetter {
.svg-inline--fa { display: inline-block;
text-align: center;
font-weight: 1000;
}
i[class*="icon-"],
.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);
background-color: $fallback--fg; background-color: $fallback--fg;
background-color: var(--topBar, $fallback--fg); background-color: var(--topBar, $fallback--fg);
color: $fallback--faint; color: $fallback--faint;
color: var(--faint, $fallback--faint); color: var(--faint, $fallback--faint);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.6); box-shadow: 0 0 4px rgb(0 0 0 / 60%);
box-shadow: var(--topBarShadow); box-shadow: var(--topBarShadow);
box-sizing: border-box; box-sizing: border-box;
height: var(--navbar-height); height: var(--navbar-height);
@ -141,6 +158,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;
@ -170,25 +192,28 @@ nav {
} }
.underlay { .underlay {
grid-column-start: 1; grid-column: 1 / span 3;
grid-column-end: span 3; grid-row: 1 / 1;
grid-row-start: 1;
grid-row-end: 1;
pointer-events: none; pointer-events: none;
background-color: rgba(0, 0, 0, 0.15); background-color: rgb(0 0 0 / 15%);
background-color: var(--underlay, rgba(0, 0, 0, 0.15)); background-color: var(--underlay, rgb(0 0 0 / 15%));
z-index: -1000; z-index: -1000;
} }
.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;
@ -205,8 +230,7 @@ nav {
display: grid; display: grid;
grid-template-columns: 100%; grid-template-columns: 100%;
box-sizing: border-box; box-sizing: border-box;
grid-row-start: 1; grid-row: 1 / 1;
grid-row-end: 1;
margin: 0 calc(var(--___columnMargin) / 2); margin: 0 calc(var(--___columnMargin) / 2);
padding: calc(var(--___columnMargin)) 0; padding: calc(var(--___columnMargin)) 0;
row-gap: var(--___columnMargin); row-gap: var(--___columnMargin);
@ -281,16 +305,25 @@ nav {
align-content: start; align-content: start;
} }
&.-reverse:not(.-wide):not(.-mobile) { &.-reverse:not(.-wide, .-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";
} }
} }
@ -301,11 +334,8 @@ nav {
padding: 0; padding: 0;
.column { .column {
margin-left: 0;
margin-right: 0;
padding-top: 0; padding-top: 0;
margin-top: var(--navbar-height); margin: var(--navbar-height) 0 0 0;
margin-bottom: 0;
} }
.panel-heading, .panel-heading,
@ -354,7 +384,7 @@ nav {
background: transparent; background: transparent;
} }
i[class*=icon-], i[class*="icon-"],
.svg-inline--fa { .svg-inline--fa {
color: $fallback--text; color: $fallback--text;
color: var(--btnText, $fallback--text); color: var(--btnText, $fallback--text);
@ -365,12 +395,15 @@ nav {
} }
&:hover { &:hover {
box-shadow: 0 0 4px rgba(255, 255, 255, 0.3); box-shadow: 0 0 4px rgb(255 255 255 / 30%);
box-shadow: var(--buttonHoverShadow); box-shadow: var(--buttonHoverShadow);
} }
&:active { &:active {
box-shadow: 0 0 4px 0 rgba(255, 255, 255, 0.3), 0 1px 0 0 rgba(0, 0, 0, 0.2) inset, 0 -1px 0 0 rgba(255, 255, 255, 0.2) inset; box-shadow:
0 0 4px 0 rgb(255 255 255 / 30%),
0 1px 0 0 rgb(0 0 0 / 20%) inset,
0 -1px 0 0 rgb(255 255 255 / 20%) inset;
box-shadow: var(--buttonPressedShadow); box-shadow: var(--buttonPressedShadow);
color: $fallback--text; color: $fallback--text;
color: var(--btnPressedText, $fallback--text); color: var(--btnPressedText, $fallback--text);
@ -403,7 +436,10 @@ nav {
color: var(--btnToggledText, $fallback--text); color: var(--btnToggledText, $fallback--text);
background-color: $fallback--fg; background-color: $fallback--fg;
background-color: var(--btnToggled, $fallback--fg); background-color: var(--btnToggled, $fallback--fg);
box-shadow: 0 0 4px 0 rgba(255, 255, 255, 0.3), 0 1px 0 0 rgba(0, 0, 0, 0.2) inset, 0 -1px 0 0 rgba(255, 255, 255, 0.2) inset; box-shadow:
0 0 4px 0 rgb(255 255 255 / 30%),
0 1px 0 0 rgb(0 0 0 / 20%) inset,
0 -1px 0 0 rgb(255 255 255 / 20%) inset;
box-shadow: var(--buttonPressedShadow); box-shadow: var(--buttonPressedShadow);
svg, svg,
@ -468,7 +504,10 @@ textarea,
border: none; border: none;
border-radius: $fallback--inputRadius; border-radius: $fallback--inputRadius;
border-radius: var(--inputRadius, $fallback--inputRadius); border-radius: var(--inputRadius, $fallback--inputRadius);
box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.2) inset, 0 -1px 0 0 rgba(255, 255, 255, 0.2) inset, 0 0 2px 0 rgba(0, 0, 0, 1) inset; box-shadow:
0 1px 0 0 rgb(0 0 0 / 20%) inset,
0 -1px 0 0 rgb(255 255 255 / 20%) inset,
0 0 2px 0 rgb(0 0 0 / 100%) inset;
box-shadow: var(--inputShadow); box-shadow: var(--inputShadow);
background-color: $fallback--fg; background-color: $fallback--fg;
background-color: var(--input, $fallback--fg); background-color: var(--input, $fallback--fg);
@ -486,13 +525,13 @@ textarea,
padding: 0 var(--_padding); padding: 0 var(--_padding);
&:disabled, &:disabled,
&[disabled=disabled], &[disabled="disabled"],
&.disabled { &.disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
} }
&[type=range] { &[type="range"] {
background: none; background: none;
border: none; border: none;
margin: 0; margin: 0;
@ -500,7 +539,7 @@ textarea,
flex: 1; flex: 1;
} }
&[type=radio] { &[type="radio"] {
display: none; display: none;
&:checked + label::before { &:checked + label::before {
@ -520,7 +559,7 @@ textarea,
+ label::before { + label::before {
flex-shrink: 0; flex-shrink: 0;
display: inline-block; display: inline-block;
content: ''; content: "";
transition: box-shadow 200ms; transition: box-shadow 200ms;
width: 1.1em; width: 1.1em;
height: 1.1em; height: 1.1em;
@ -540,7 +579,7 @@ textarea,
} }
} }
&[type=checkbox] { &[type="checkbox"] {
display: none; display: none;
&:checked + label::before { &:checked + label::before {
@ -559,7 +598,7 @@ textarea,
+ label::before { + label::before {
flex-shrink: 0; flex-shrink: 0;
display: inline-block; display: inline-block;
content: ''; content: "";
transition: color 200ms; transition: color 200ms;
width: 1.1em; width: 1.1em;
height: 1.1em; height: 1.1em;
@ -599,10 +638,10 @@ option {
} }
.hide-number-spinner { .hide-number-spinner {
-moz-appearance: textfield; appearance: textfield;
&[type=number]::-webkit-inner-spin-button, &[type="number"]::-webkit-inner-spin-button,
&[type=number]::-webkit-outer-spin-button { &[type="number"]::-webkit-outer-spin-button {
opacity: 0; opacity: 0;
display: none; display: none;
} }
@ -634,8 +673,6 @@ option {
} }
} }
@import './panel.scss';
.fa { .fa {
color: grey; color: grey;
} }
@ -651,7 +688,7 @@ option {
max-width: 10em; max-width: 10em;
min-width: 1.7em; min-width: 1.7em;
height: 1.3em; height: 1.3em;
padding: 0.15em 0.15em; padding: 0.15em;
vertical-align: middle; vertical-align: middle;
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
@ -746,17 +783,24 @@ 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;
@ -842,3 +886,4 @@ option {
.fade-leave-active { .fade-leave-active {
opacity: 0; opacity: 0;
} }
/* stylelint-enable no-descending-specificity */

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"
@ -30,10 +33,10 @@
<div id="notifs-sidebar" /> <div id="notifs-sidebar" />
</template> </template>
</div> </div>
<div <main
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"
@ -47,7 +50,7 @@
</router-link> </router-link>
</div> </div>
<router-view /> <router-view />
</div> </main>
<div <div
id="notifs-column" id="notifs-column"
class="column -scrollable" class="column -scrollable"
@ -64,10 +67,12 @@
<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> </div>
</template> </template>

18
src/_mixins.scss Normal file
View File

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

View File

@ -4,20 +4,20 @@ $darkened-background: whitesmoke;
$fallback--bg: #121a24; $fallback--bg: #121a24;
$fallback--fg: #182230; $fallback--fg: #182230;
$fallback--faint: rgba(185, 185, 186, .5); $fallback--faint: rgb(185 185 186 / 50%);
$fallback--text: #b9b9ba; $fallback--text: #b9b9ba;
$fallback--link: #d8a070; $fallback--link: #d8a070;
$fallback--icon: #666; $fallback--icon: #666;
$fallback--lightBg: rgb(21, 30, 42); $fallback--lightBg: rgb(21 30 42);
$fallback--lightText: #b9b9ba; $fallback--lightText: #b9b9ba;
$fallback--border: #222; $fallback--border: #222;
$fallback--cRed: #ff0000; $fallback--cRed: #f00;
$fallback--cBlue: #0095ff; $fallback--cBlue: #0095ff;
$fallback--cGreen: #0fa00f; $fallback--cGreen: #0fa00f;
$fallback--cOrange: orange; $fallback--cOrange: orange;
$fallback--alertError: rgba(211,16,20,.5); $fallback--alertError: rgb(211 16 20 / 50%);
$fallback--alertWarning: rgba(111,111,20,.5); $fallback--alertWarning: rgb(111 111 20 / 50%);
$fallback--panelRadius: 10px; $fallback--panelRadius: 10px;
$fallback--checkboxRadius: 2px; $fallback--checkboxRadius: 2px;
@ -29,6 +29,8 @@ $fallback--avatarAltRadius: 10px;
$fallback--attachmentRadius: 10px; $fallback--attachmentRadius: 10px;
$fallback--chatMessageRadius: 10px; $fallback--chatMessageRadius: 10px;
$fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset; $fallback--buttonShadow: 0 0 2px 0 rgb(0 0 0 / 100%),
0 1px 0 0 rgb(255 255 255 / 20%) inset,
0 -1px 0 0 rgb(0 0 0 / 20%) inset;
$status-margin: 0.75em; $status-margin: 0.75em;

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

@ -1,6 +1,8 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import vClickOutside from 'click-outside-vue3' import vClickOutside from 'click-outside-vue3'
import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
@ -12,7 +14,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 +253,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 +363,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([
@ -371,6 +376,7 @@ const afterStoreSetup = async ({ store, i18n }) => {
// Start fetching things that don't need to block the UI // Start fetching things that don't need to block the UI
store.dispatch('fetchMutes') store.dispatch('fetchMutes')
store.dispatch('startFetchingAnnouncements')
getTOS({ store }) getTOS({ store })
getStickers({ store }) getStickers({ store })
@ -393,6 +399,7 @@ const afterStoreSetup = async ({ store, i18n }) => {
app.use(vClickOutside) app.use(vClickOutside)
app.use(VBodyScrollLock) app.use(VBodyScrollLock)
app.use(VueVirtualScroller)
app.component('FAIcon', FontAwesomeIcon) app.component('FAIcon', FontAwesomeIcon)
app.component('FALayers', FontAwesomeLayers) app.component('FALayers', FontAwesomeLayers)

View File

@ -20,6 +20,11 @@ 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'
import AnnouncementsPage from 'components/announcements_page/announcements_page.vue'
export default (store) => { export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => { const validateAuthenticatedRoute = (to, from, next) => {
@ -58,7 +63,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 +77,14 @@ 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: 'announcements', path: '/announcements', component: AnnouncementsPage },
{ 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

@ -9,6 +9,3 @@
</template> </template>
<script src="./about.js"></script> <script src="./about.js"></script>
<style lang="scss">
</style>

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"
@ -72,7 +80,8 @@
<script src="./account_actions.js"></script> <script src="./account_actions.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
.AccountActions { .AccountActions {
.ellipsis-button { .ellipsis-button {
width: 2.5em; width: 2.5em;

View File

@ -0,0 +1,108 @@
import { mapState } from 'vuex'
import AnnouncementEditor from '../announcement_editor/announcement_editor.vue'
import RichContent from '../rich_content/rich_content.jsx'
import localeService from '../../services/locale/locale.service.js'
const Announcement = {
components: {
AnnouncementEditor,
RichContent
},
data () {
return {
editing: false,
editedAnnouncement: {
content: '',
startsAt: undefined,
endsAt: undefined,
allDay: undefined
},
editError: ''
}
},
props: {
announcement: Object
},
computed: {
...mapState({
currentUser: state => state.users.currentUser
}),
canEditAnnouncement () {
return this.currentUser && this.currentUser.privileges.includes('announcements_manage_announcements')
},
content () {
return this.announcement.content
},
isRead () {
return this.announcement.read
},
publishedAt () {
const time = this.announcement.published_at
if (!time) {
return
}
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
},
startsAt () {
const time = this.announcement.starts_at
if (!time) {
return
}
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
},
endsAt () {
const time = this.announcement.ends_at
if (!time) {
return
}
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
},
inactive () {
return this.announcement.inactive
}
},
methods: {
markAsRead () {
if (!this.isRead) {
return this.$store.dispatch('markAnnouncementAsRead', this.announcement.id)
}
},
deleteAnnouncement () {
return this.$store.dispatch('deleteAnnouncement', this.announcement.id)
},
formatTimeOrDate (time, locale) {
const d = new Date(time)
return this.announcement.all_day ? d.toLocaleDateString(locale) : d.toLocaleString(locale)
},
enterEditMode () {
this.editedAnnouncement.content = this.announcement.pleroma.raw_content
this.editedAnnouncement.startsAt = this.announcement.starts_at
this.editedAnnouncement.endsAt = this.announcement.ends_at
this.editedAnnouncement.allDay = this.announcement.all_day
this.editing = true
},
submitEdit () {
this.$store.dispatch('editAnnouncement', {
id: this.announcement.id,
...this.editedAnnouncement
})
.then(() => {
this.editing = false
})
.catch(error => {
this.editError = error.error
})
},
cancelEdit () {
this.editing = false
},
clearError () {
this.editError = undefined
}
}
}
export default Announcement

View File

@ -0,0 +1,136 @@
<template>
<div class="announcement">
<div class="heading">
<h4>{{ $t('announcements.title') }}</h4>
</div>
<div class="body">
<rich-content
v-if="!editing"
:html="content"
:emoji="announcement.emojis"
:handle-links="true"
/>
<announcement-editor
v-else
:announcement="editedAnnouncement"
/>
</div>
<div class="footer">
<div
v-if="!editing"
class="times"
>
<span v-if="publishedAt">
{{ $t('announcements.published_time_display', { time: publishedAt }) }}
</span>
<span v-if="startsAt">
{{ $t('announcements.start_time_display', { time: startsAt }) }}
</span>
<span v-if="endsAt">
{{ $t('announcements.end_time_display', { time: endsAt }) }}
</span>
</div>
<div
v-if="!editing"
class="actions"
>
<button
v-if="currentUser"
class="btn button-default"
:class="{ toggled: isRead }"
:disabled="inactive"
:title="inactive ? $t('announcements.inactive_message') : ''"
@click="markAsRead"
>
{{ $t('announcements.mark_as_read_action') }}
</button>
<button
v-if="canEditAnnouncement"
class="btn button-default"
@click="enterEditMode"
>
{{ $t('announcements.edit_action') }}
</button>
<button
v-if="canEditAnnouncement"
class="btn button-default"
@click="deleteAnnouncement"
>
{{ $t('announcements.delete_action') }}
</button>
</div>
<div
v-else
class="actions"
>
<button
class="btn button-default"
@click="submitEdit"
>
{{ $t('announcements.submit_edit_action') }}
</button>
<button
class="btn button-default"
@click="cancelEdit"
>
{{ $t('announcements.cancel_edit_action') }}
</button>
<div
v-if="editing && editError"
class="alert error"
>
{{ $t('announcements.edit_error', { error }) }}
<button
class="button-unstyled"
@click="clearError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
:title="$t('announcements.close_error')"
/>
</button>
</div>
</div>
</div>
</div>
</template>
<script src="./announcement.js"></script>
<style lang="scss">
@import "../../variables";
.announcement {
border-bottom: 1px solid var(--border, $fallback--border);
border-radius: 0;
padding: var(--status-margin, $status-margin);
.heading,
.body {
margin-bottom: var(--status-margin, $status-margin);
}
.footer {
display: flex;
flex-direction: column;
.times {
display: flex;
flex-direction: column;
}
}
.footer .actions {
display: flex;
flex-direction: row;
justify-content: space-evenly;
.btn {
flex: 1;
margin: 1em;
max-width: 10em;
}
}
}
</style>

View File

@ -0,0 +1,13 @@
import Checkbox from '../checkbox/checkbox.vue'
const AnnouncementEditor = {
components: {
Checkbox
},
props: {
announcement: Object,
disabled: Boolean
}
}
export default AnnouncementEditor

View File

@ -0,0 +1,60 @@
<template>
<div class="announcement-editor">
<textarea
ref="textarea"
v-model="announcement.content"
class="post-textarea"
rows="1"
cols="1"
:placeholder="$t('announcements.post_placeholder')"
:disabled="disabled"
/>
<span class="announcement-metadata">
<label for="announcement-start-time">{{ $t('announcements.start_time_prompt') }}</label>
<input
id="announcement-start-time"
v-model="announcement.startsAt"
:type="announcement.allDay ? 'date' : 'datetime-local'"
:disabled="disabled"
>
</span>
<span class="announcement-metadata">
<label for="announcement-end-time">{{ $t('announcements.end_time_prompt') }}</label>
<input
id="announcement-end-time"
v-model="announcement.endsAt"
:type="announcement.allDay ? 'date' : 'datetime-local'"
:disabled="disabled"
>
</span>
<span class="announcement-metadata">
<Checkbox
id="announcement-all-day"
v-model="announcement.allDay"
:disabled="disabled"
/>
<label for="announcement-all-day">{{ $t('announcements.all_day_prompt') }}</label>
</span>
</div>
</template>
<script src="./announcement_editor.js"></script>
<style lang="scss">
.announcement-editor {
display: flex;
align-items: stretch;
flex-direction: column;
.announcement-metadata {
margin-top: 0.5em;
}
.post-textarea {
resize: vertical;
height: 10em;
overflow: none;
box-sizing: content-box;
}
}
</style>

View File

@ -0,0 +1,58 @@
import { mapState } from 'vuex'
import Announcement from '../announcement/announcement.vue'
import AnnouncementEditor from '../announcement_editor/announcement_editor.vue'
const AnnouncementsPage = {
components: {
Announcement,
AnnouncementEditor
},
data () {
return {
newAnnouncement: {
content: '',
startsAt: undefined,
endsAt: undefined,
allDay: false
},
posting: false,
error: undefined
}
},
mounted () {
this.$store.dispatch('fetchAnnouncements')
},
computed: {
...mapState({
currentUser: state => state.users.currentUser
}),
announcements () {
return this.$store.state.announcements.announcements
},
canPostAnnouncement () {
return this.currentUser && this.currentUser.privileges.includes('announcements_manage_announcements')
}
},
methods: {
postAnnouncement () {
this.posting = true
this.$store.dispatch('postAnnouncement', this.newAnnouncement)
.then(() => {
this.newAnnouncement.content = ''
this.startsAt = undefined
this.endsAt = undefined
})
.catch(error => {
this.error = error.error
})
.finally(() => {
this.posting = false
})
},
clearError () {
this.error = undefined
}
}
}
export default AnnouncementsPage

View File

@ -0,0 +1,80 @@
<template>
<div class="panel panel-default announcements-page">
<div class="panel-heading">
<span>
{{ $t('announcements.page_header') }}
</span>
</div>
<div class="panel-body">
<section
v-if="canPostAnnouncement"
>
<div class="post-form">
<div class="heading">
<h4>{{ $t('announcements.post_form_header') }}</h4>
</div>
<div class="body">
<announcement-editor
:announcement="newAnnouncement"
:disabled="posting"
/>
</div>
<div class="footer">
<button
class="btn button-default post-button"
:disabled="posting"
@click.prevent="postAnnouncement"
>
{{ $t('announcements.post_action') }}
</button>
<div
v-if="error"
class="alert error"
>
{{ $t('announcements.post_error', { error }) }}
<button
class="button-unstyled"
@click="clearError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
:title="$t('announcements.close_error')"
/>
</button>
</div>
</div>
</div>
</section>
<section
v-for="announcement in announcements"
:key="announcement.id"
>
<announcement
:announcement="announcement"
/>
</section>
</div>
</div>
</template>
<script src="./announcements_page.js"></script>
<style lang="scss">
@import "../../variables";
.announcements-page {
.post-form {
padding: var(--status-margin, $status-margin);
.heading,
.body {
margin-bottom: var(--status-margin, $status-margin);
}
.post-button {
min-width: 10em;
}
}
}
</style>

View File

@ -34,9 +34,10 @@ export default {
height: 100%; height: 100%;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
.btn { .btn {
margin: .5em; margin: 0.5em;
padding: .5em 2em; padding: 0.5em 2em;
} }
} }
</style> </style>

View File

@ -36,6 +36,7 @@ library.add(
const Attachment = { const Attachment = {
props: [ props: [
'attachment', 'attachment',
'compact',
'description', 'description',
'hideDescription', 'hideDescription',
'nsfw', 'nsfw',
@ -71,7 +72,8 @@ const Attachment = {
{ {
'-loading': this.loading, '-loading': this.loading,
'-nsfw-placeholder': this.hidden, '-nsfw-placeholder': this.hidden,
'-editable': this.edit !== undefined '-editable': this.edit !== undefined,
'-compact': this.compact
}, },
'-type-' + this.type, '-type-' + this.type,
this.size && '-size-' + this.size, this.size && '-size-' + this.size,
@ -129,6 +131,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,4 +1,4 @@
@import '../../_variables.scss'; @import "../../variables";
.Attachment { .Attachment {
display: inline-flex; display: inline-flex;
@ -102,14 +102,13 @@
padding-top: 0.5em; padding-top: 0.5em;
} }
.play-icon { .play-icon {
position: absolute; position: absolute;
font-size: 64px; font-size: 64px;
top: calc(50% - 32px); top: calc(50% - 32px);
left: calc(50% - 32px); left: calc(50% - 32px);
color: rgba(255, 255, 255, 0.75); color: rgb(255 255 255 / 75%);
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4); text-shadow: 0 0 2px rgb(0 0 0 / 40%);
&::before { &::before {
margin: 0; margin: 0;
@ -135,18 +134,32 @@
margin-left: 0.5em; margin-left: 0.5em;
font-size: 1.25em; font-size: 1.25em;
// TODO: theming? hard to theme with unknown background image color // TODO: theming? hard to theme with unknown background image color
background: rgba(230, 230, 230, 0.7); background: rgb(230 230 230 / 70%);
.svg-inline--fa { .svg-inline--fa {
color: rgba(0, 0, 0, 0.6); color: rgb(0 0 0 / 60%);
} }
&:hover .svg-inline--fa { &:hover .svg-inline--fa {
color: rgba(0, 0, 0, 0.9); color: rgb(0 0 0 / 90%);
} }
} }
} }
&.-contain-fit {
img,
canvas {
object-fit: contain;
}
}
&.-cover-fit {
img,
canvas {
object-fit: cover;
}
}
.oembed-container { .oembed-container {
line-height: 1.2em; line-height: 1.2em;
flex: 1 0 100%; flex: 1 0 100%;
@ -160,8 +173,9 @@
.image { .image {
flex: 1; flex: 1;
img { img {
border: 0px; border: 0;
border-radius: 5px; border-radius: 5px;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
@ -172,9 +186,10 @@
flex: 2; flex: 2;
margin: 8px; margin: 8px;
word-break: break-all; word-break: break-all;
h1 { h1 {
font-size: 1rem; font-size: 1rem;
margin: 0px; margin: 0;
} }
} }
} }
@ -252,17 +267,9 @@
cursor: progress; cursor: progress;
} }
&.-contain-fit { &.-compact {
img, .placeholder-container {
canvas { padding-bottom: 0.5em;
object-fit: contain;
}
}
&.-cover-fit {
img,
canvas {
object-fit: cover;
} }
} }
} }

View File

@ -162,10 +162,11 @@
target="_blank" target="_blank"
> >
<FAIcon <FAIcon
size="5x" :size="compact ? '2x' : '5x'"
:icon="placeholderIconClass" :icon="placeholderIconClass"
:title="localDescription"
/> />
<p> <p v-if="!compact">
{{ localDescription }} {{ localDescription }}
</p> </p>
</a> </a>

View File

@ -24,7 +24,7 @@
<script src="./autosuggest.js"></script> <script src="./autosuggest.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
.autosuggest { .autosuggest {
position: relative; position: relative;
@ -50,7 +50,7 @@
border-radius: var(--inputRadius, $fallback--inputRadius); border-radius: var(--inputRadius, $fallback--inputRadius);
border-top-left-radius: 0; border-top-left-radius: 0;
border-top-right-radius: 0; border-top-right-radius: 0;
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6); box-shadow: 1px 1px 4px rgb(0 0 0 / 60%);
box-shadow: var(--panelShadow); box-shadow: var(--panelShadow);
overflow-y: auto; overflow-y: auto;
z-index: 1; z-index: 1;

View File

@ -17,7 +17,7 @@
<script src="./avatar_list.js"></script> <script src="./avatar_list.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
.avatars { .avatars {
display: flex; display: flex;

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>
@ -51,7 +49,7 @@
margin: 0; margin: 0;
padding: 0.6em 1em; padding: 0.6em 1em;
--emoji-size: 14px; --emoji-size: 14px;
&-collapsed-content { &-collapsed-content {
margin-left: 0.7em; margin-left: 0.7em;

View File

@ -37,6 +37,7 @@
.block-card-content-container { .block-card-content-container {
margin-top: 0.5em; margin-top: 0.5em;
text-align: right; text-align: right;
button { button {
width: 10em; width: 10em;
} }

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

@ -17,7 +17,7 @@
width: 100%; width: 100%;
overflow: visible; overflow: visible;
min-height: calc(100vh - var(--navbar-height)); min-height: calc(100vh - var(--navbar-height));
margin: 0 0 0 0; margin: 0;
border-radius: 10px 10px 0 0; border-radius: 10px 10px 0 0;
border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0; border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0;
@ -66,7 +66,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.3); box-shadow: 0 1px 1px rgb(0 0 0 / 30%), 0 2px 4px rgb(0 0 0 / 30%);
z-index: 10; z-index: 10;
transition: 0.35s all; transition: 0.35s all;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1); transition-timing-function: cubic-bezier(0, 1, 0.5, 1);

View File

@ -95,6 +95,6 @@
<script src="./chat.js"></script> <script src="./chat.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
@import './chat.scss'; @import "./chat";
</style> </style>

View File

@ -45,7 +45,7 @@
<script src="./chat_list.js"></script> <script src="./chat_list.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
.chat-list { .chat-list {
min-height: 25em; min-height: 25em;

View File

@ -13,7 +13,7 @@
&:hover { &:hover {
background-color: var(--selectedPost, $fallback--lightBg); background-color: var(--selectedPost, $fallback--lightBg);
box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1); box-shadow: 0 0 3px 1px rgb(0 0 0 / 10%);
} }
.chat-list-item-left { .chat-list-item-left {
@ -67,6 +67,7 @@
canvas { canvas {
display: none; display: none;
} }
img { img {
visibility: visible; visibility: visible;
} }
@ -79,13 +80,11 @@
.chat-preview-body { .chat-preview-body {
--emoji-size: 1.4em; --emoji-size: 1.4em;
padding-right: 1em;
} }
.time-wrapper { .time-wrapper {
line-height: var(--post-line-height); line-height: var(--post-line-height);
} }
.chat-preview-body {
padding-right: 1em;
}
} }

View File

@ -48,6 +48,6 @@
<script src="./chat_list_item.js"></script> <script src="./chat_list_item.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
@import './chat_list_item.scss'; @import "./chat_list_item";
</style> </style>

View File

@ -1,12 +1,12 @@
@import '../../_variables.scss'; @import "../../variables";
.chat-message-wrapper { .chat-message-wrapper {
&.hovered-message-chain { &.hovered-message-chain {
.animated.Avatar { .animated.Avatar {
canvas { canvas {
display: none; display: none;
} }
img { img {
visibility: visible; visibility: visible;
} }
@ -28,7 +28,8 @@
.menu-icon { .menu-icon {
cursor: pointer; cursor: pointer;
&:hover, .extra-button-popover.open & { &:hover,
.extra-button-popover.open & {
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
@ -54,27 +55,11 @@
width: 32px; width: 32px;
} }
.link-preview, .attachments { .link-preview,
.attachments {
margin-bottom: 1em; margin-bottom: 1em;
} }
.chat-message-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 80%;
min-width: 10em;
width: 100%;
&.with-media {
width: 100%;
.status {
width: 100%;
}
}
}
.status { .status {
border-radius: $fallback--chatMessageRadius; border-radius: $fallback--chatMessageRadius;
border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius); border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
@ -86,7 +71,7 @@
position: relative; position: relative;
float: right; float: right;
font-size: 0.8em; font-size: 0.8em;
margin: -1em 0 -0.5em 0; margin: -1em 0 -0.5em;
font-style: italic; font-style: italic;
opacity: 0.8; opacity: 0.8;
} }
@ -103,18 +88,54 @@
} }
.pending { .pending {
.status-content.media-body, .created-at { .status-content.media-body,
.created-at {
color: var(--faint); color: var(--faint);
} }
} }
.error { .error {
.status-content.media-body, .created-at { .status-content.media-body,
.created-at {
color: $fallback--cRed; color: $fallback--cRed;
color: var(--badgeNotification, $fallback--cRed); color: var(--badgeNotification, $fallback--cRed);
} }
} }
.chat-message-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 80%;
min-width: 10em;
width: 100%;
}
.outgoing {
display: flex;
flex-flow: row wrap;
align-content: end;
justify-content: flex-end;
a {
color: var(--chatMessageOutgoingLink, $fallback--link);
}
.status {
color: var(--chatMessageOutgoingText, $fallback--text);
background-color: var(--chatMessageOutgoingBg, $fallback--lightBg);
border: 1px solid var(--chatMessageOutgoingBorder, --lightBg);
}
.chat-message-inner {
align-items: flex-end;
}
.chat-message-menu {
right: 0.4rem;
}
}
.incoming { .incoming {
a { a {
color: var(--chatMessageIncomingLink, $fallback--link); color: var(--chatMessageIncomingLink, $fallback--link);
@ -137,36 +158,17 @@
} }
} }
.outgoing { .chat-message-inner.with-media {
display: flex; width: 100%;
flex-direction: row;
flex-wrap: wrap;
align-content: end;
justify-content: flex-end;
a {
color: var(--chatMessageOutgoingLink, $fallback--link);
}
.status { .status {
color: var(--chatMessageOutgoingText, $fallback--text); width: 100%;
background-color: var(--chatMessageOutgoingBg, $fallback--lightBg);
border: 1px solid var(--chatMessageOutgoingBorder, --lightBg);
}
.chat-message-inner {
align-items: flex-end;
}
.chat-message-menu {
right: 0.4rem;
} }
} }
.visible { .visible {
opacity: 1; opacity: 1;
} }
} }
.chat-message-date-separator { .chat-message-date-separator {

View File

@ -33,7 +33,7 @@
<div <div
class="media status" class="media status"
:class="{ 'without-attachment': !hasAttachment, 'pending': chatViewItem.data.pending, 'error': chatViewItem.data.error }" :class="{ 'without-attachment': !hasAttachment, 'pending': chatViewItem.data.pending, 'error': chatViewItem.data.error }"
style="position: relative" style="position: relative;"
@mouseenter="hovered = true" @mouseenter="hovered = true"
@mouseleave="hovered = false" @mouseleave="hovered = false"
> >
@ -98,6 +98,6 @@
<script src="./chat_message.js"></script> <script src="./chat_message.js"></script>
<style lang="scss"> <style lang="scss">
@import './chat_message.scss'; @import "./chat_message";
</style> </style>

View File

@ -1,7 +1,7 @@
.chat-new { .chat-new {
.input-wrap { .input-wrap {
display: flex; display: flex;
margin: 0.7em 0.5em 0.7em 0.5em; margin: 0.7em 0.5em;
input { input {
width: 100%; width: 100%;

View File

@ -46,6 +46,6 @@
<script src="./chat_new.js"></script> <script src="./chat_new.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
@import './chat_new.scss'; @import "./chat_new";
</style> </style>

View File

@ -26,7 +26,7 @@
<script src="./chat_title.js"></script> <script src="./chat_title.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
.chat-title { .chat-title {
display: flex; display: flex;

View File

@ -32,7 +32,7 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
.checkbox { .checkbox {
position: relative; position: relative;
@ -49,13 +49,13 @@ export default {
right: 0; right: 0;
top: 0; top: 0;
display: block; display: block;
content: '✓'; content: "✓";
transition: color 200ms; transition: color 200ms;
width: 1.1em; width: 1.1em;
height: 1.1em; height: 1.1em;
border-radius: $fallback--checkboxRadius; border-radius: $fallback--checkboxRadius;
border-radius: var(--checkboxRadius, $fallback--checkboxRadius); border-radius: var(--checkboxRadius, $fallback--checkboxRadius);
box-shadow: 0px 0px 2px black inset; box-shadow: 0 0 2px black inset;
box-shadow: var(--inputShadow); box-shadow: var(--inputShadow);
background-color: $fallback--fg; background-color: $fallback--fg;
background-color: var(--input, $fallback--fg); background-color: var(--input, $fallback--fg);
@ -71,15 +71,16 @@ export default {
&.disabled { &.disabled {
.checkbox-indicator::before, .checkbox-indicator::before,
.label { .label {
opacity: .5; opacity: 0.5;
} }
.label { .label {
color: $fallback--faint; color: $fallback--faint;
color: var(--faint, $fallback--faint); color: var(--faint, $fallback--faint);
} }
} }
input[type=checkbox] { input[type="checkbox"] {
display: none; display: none;
&:checked + .checkbox-indicator::before { &:checked + .checkbox-indicator::before {
@ -88,15 +89,14 @@ export default {
} }
&:indeterminate + .checkbox-indicator::before { &:indeterminate + .checkbox-indicator::before {
content: ''; content: "";
color: $fallback--text; color: $fallback--text;
color: var(--inputText, $fallback--text); color: var(--inputText, $fallback--text);
} }
} }
& > span { & > span {
margin-left: .5em; margin-left: 0.5em;
} }
} }
</style> </style>

View File

@ -1,4 +1,4 @@
@import '../../_variables.scss'; @import "../../variables";
.color-input { .color-input {
display: inline-flex; display: inline-flex;
@ -8,7 +8,7 @@
flex: 0 0 0; flex: 0 0 0;
max-width: 9em; max-width: 9em;
align-items: stretch; align-items: stretch;
padding: .2em 8px; padding: 0.2em 8px;
input { input {
background: none; background: none;
@ -27,33 +27,39 @@
&.nativeColor { &.nativeColor {
flex: 0 0 2em; flex: 0 0 2em;
min-width: 2em; min-width: 2em;
align-self: center; align-self: stretch;
height: 100%; min-height: 100%;
} }
} }
.computedIndicator, .computedIndicator,
.transparentIndicator { .transparentIndicator {
flex: 0 0 2em; flex: 0 0 2em;
min-width: 2em; min-width: 2em;
align-self: center; align-self: stretch;
height: 100%; min-height: 100%;
} }
.transparentIndicator { .transparentIndicator {
// forgot to install counter-strike source, ooops // forgot to install counter-strike source, ooops
background-color: #FF00FF; background-color: #f0f;
position: relative; position: relative;
&::before, &::after {
&::before,
&::after {
display: block; display: block;
content: ''; content: "";
background-color: #000000; background-color: #000;
position: absolute; position: absolute;
height: 50%; height: 50%;
width: 50%; width: 50%;
} }
&::after { &::after {
top: 0; top: 0;
left: 0; left: 0;
} }
&::before { &::before {
bottom: 0; bottom: 0;
right: 0; right: 0;
@ -64,5 +70,4 @@
.label { .label {
flex: 1 1 auto; flex: 1 1 auto;
} }
} }

View File

@ -87,7 +87,6 @@ export default {
.contrast-ratio { .contrast-ratio {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: -4px; margin-top: -4px;
margin-bottom: 5px; margin-bottom: 5px;

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,16 @@
> >
{{ $t('timeline.collapse') }} {{ $t('timeline.collapse') }}
</button> </button>
<QuickFilterSettings
v-if="!collapsable"
:conversation="true"
class="rightside-button"
/>
<QuickViewSettings
v-if="!collapsable"
:conversation="true"
class="rightside-button"
/>
</div> </div>
<div class="conversation-body panel-body"> <div class="conversation-body panel-body">
<div <div
@ -50,7 +60,7 @@
v-if="shouldShowAncestors" v-if="shouldShowAncestors"
class="thread-ancestors" class="thread-ancestors"
> >
<div <article
v-for="status in ancestorsOf(diveRoot)" v-for="status in ancestorsOf(diveRoot)"
:key="status.id" :key="status.id"
class="thread-ancestor" class="thread-ancestor"
@ -120,7 +130,7 @@
</i18n-t> </i18n-t>
</div> </div>
</div> </div>
</div> </article>
</div> </div>
<thread-tree <thread-tree
v-for="status in showingTopLevel" v-for="status in showingTopLevel"
@ -158,34 +168,36 @@
v-if="isLinearView" v-if="isLinearView"
class="thread-body" class="thread-body"
> >
<status <article>
v-for="status in conversation" <status
:key="status.id" v-for="status in conversation"
ref="statusComponent" :key="status.id"
:inline-expanded="collapsable && isExpanded" ref="statusComponent"
:statusoid="status" :inline-expanded="collapsable && isExpanded"
:expandable="!isExpanded" :statusoid="status"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]" :expandable="!isExpanded"
:focused="focused(status.id)" :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:in-conversation="isExpanded" :focused="focused(status.id)"
:highlight="getHighlight()" :in-conversation="isExpanded"
:replies="getReplies(status.id)" :highlight="getHighlight()"
:in-profile="inProfile" :replies="getReplies(status.id)"
:profile-user-id="profileUserId" :in-profile="inProfile"
class="conversation-status status-fadein panel-body" :profile-user-id="profileUserId"
class="conversation-status status-fadein panel-body"
:toggle-thread-display="toggleThreadDisplay" :toggle-thread-display="toggleThreadDisplay"
:thread-display-status="threadDisplayStatus" :thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively" :show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount" :total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth" :total-reply-depth="totalReplyDepth"
:status-content-properties="statusContentProperties" :status-content-properties="statusContentProperties"
:set-status-content-property="setStatusContentProperty" :set-status-content-property="setStatusContentProperty"
:toggle-status-content-property="toggleStatusContentProperty" :toggle-status-content-property="toggleStatusContentProperty"
@goto="setHighlight" @goto="setHighlight"
@toggleExpanded="toggleExpanded" @toggleExpanded="toggleExpanded"
/> />
</article>
</div> </div>
</div> </div>
</div> </div>
@ -198,17 +210,16 @@
<script src="./conversation.js"></script> <script src="./conversation.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
.Conversation { .Conversation {
z-index: 1; z-index: 1;
.conversation-dive-to-top-level-box { .conversation-dive-to-top-level-box {
padding: var(--status-margin, $status-margin); padding: var(--status-margin, $status-margin);
border-bottom-width: 1px; border-bottom: 1px solid var(--border, $fallback--border);
border-bottom-style: solid;
border-bottom-color: var(--border, $fallback--border);
border-radius: 0; border-radius: 0;
/* Make the button stretch along the whole row */ /* Make the button stretch along the whole row */
display: flex; display: flex;
align-items: stretch; align-items: stretch;
@ -223,52 +234,48 @@
.thread-ancestor.-faded .StatusContent { .thread-ancestor.-faded .StatusContent {
--link: var(--faintLink); --link: var(--faintLink);
--text: var(--faint); --text: var(--faint);
color: var(--text); color: var(--text);
} }
.thread-ancestor-dive-box { .thread-ancestor-dive-box {
padding-left: var(--status-margin, $status-margin); padding-left: var(--status-margin, $status-margin);
border-bottom-width: 1px; border-bottom: 1px solid var(--border, $fallback--border);
border-bottom-style: solid;
border-bottom-color: var(--border, $fallback--border);
border-radius: 0; border-radius: 0;
/* Make the button stretch along the whole row */ /* Make the button stretch along the whole row */
&, &-inner { &,
&-inner {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
} }
} }
.thread-ancestor-dive-box-inner { .thread-ancestor-dive-box-inner {
padding: var(--status-margin, $status-margin); padding: var(--status-margin, $status-margin);
} }
.conversation-status { .conversation-status {
border-bottom-width: 1px; border-bottom: 1px solid var(--border, $fallback--border);
border-bottom-style: solid;
border-bottom-color: var(--border, $fallback--border);
border-radius: 0; border-radius: 0;
} }
.thread-ancestor-has-other-replies .conversation-status, .thread-ancestor-has-other-replies .conversation-status,
&:last-child .conversation-status,
.thread-ancestor:last-child .conversation-status, .thread-ancestor:last-child .conversation-status,
.thread-ancestor:last-child .thread-ancestor-dive-box, .thread-ancestor:last-child .thread-ancestor-dive-box,
&:last-child .conversation-status,
&.-expanded .thread-tree .conversation-status { &.-expanded .thread-tree .conversation-status {
border-bottom: none; border-bottom: none;
} }
.thread-ancestors + .thread-tree > .conversation-status { .thread-ancestors + .thread-tree > .conversation-status {
border-top-width: 1px; border-top: 1px solid var(--border, $fallback--border);
border-top-style: solid;
border-top-color: var(--border, $fallback--border);
} }
/* expanded conversation in timeline */ /* expanded conversation in timeline */
&.status-fadein.-expanded .thread-body { &.status-fadein.-expanded .thread-body {
border-left-width: 4px; border-left: 4px solid $fallback--cRed;
border-left-style: solid;
border-left-color: $fallback--cRed;
border-left-color: var(--cRed, $fallback--cRed); border-left-color: var(--cRed, $fallback--cRed);
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);

View File

@ -1,4 +1,4 @@
@import '../../_variables.scss'; @import "../../variables";
.DesktopNav { .DesktopNav {
width: 100%; width: 100%;
@ -23,13 +23,37 @@
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)
);
}
&.-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";
} }
&.-column-stretch.-wide .inner-nav {
max-width:
calc(
var(--sidebarColumnWidth, var(--miniColumn)) +
var(--contentColumnWidth, var(--maxiColumn)) +
var(--notifsColumnWidth, var(--miniColumn)) +
var(--columnGap)
);
}
.button-default { .button-default {
&, svg { &,
svg {
color: $fallback--text; color: $fallback--text;
color: var(--btnTopBarText, $fallback--text); color: var(--btnTopBarText, $fallback--text);
} }
@ -50,7 +74,7 @@
color: $fallback--text; color: $fallback--text;
color: var(--btnToggledTopBarText, $fallback--text); color: var(--btnToggledTopBarText, $fallback--text);
background-color: $fallback--fg; background-color: $fallback--fg;
background-color: var(--btnToggledTopBar, $fallback--fg) background-color: var(--btnToggledTopBar, $fallback--fg);
} }
} }
@ -62,6 +86,7 @@
transition-duration: 100ms; transition-duration: 100ms;
@media all and (min-width: 800px) { @media all and (min-width: 800px) {
/* stylelint-disable-next-line declaration-no-important */
opacity: 1 !important; opacity: 1 !important;
} }
@ -117,4 +142,8 @@
text-align: right; text-align: right;
} }
} }
.spacer {
width: 1em;
}
} }

View File

@ -38,7 +38,7 @@
/> />
<button <button
class="button-unstyled nav-icon" class="button-unstyled nav-icon"
@click="openSettingsModal" @click.stop="openSettingsModal"
> >
<FAIcon <FAIcon
fixed-width fixed-width
@ -61,10 +61,11 @@
: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"
@click.prevent="logout" @click.stop.prevent="logout"
> >
<FAIcon <FAIcon
fixed-width fixed-width

View File

@ -25,7 +25,7 @@
<script src="./dialog_modal.js"></script> <script src="./dialog_modal.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
// TODO: unify with other modals. // TODO: unify with other modals.
.dark-overlay { .dark-overlay {
@ -38,7 +38,7 @@
position: fixed; position: fixed;
right: 0; right: 0;
top: 0; top: 0;
background: rgba(27,31,35,.5); background: rgb(27 31 35 / 50%);
z-index: 99; z-index: 99;
} }
} }
@ -65,7 +65,7 @@
.dialog-modal-content { .dialog-modal-content {
margin: 0; margin: 0;
padding: 1rem 1rem; padding: 1rem;
background-color: $fallback--bg; background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg); background-color: var(--bg, $fallback--bg);
white-space: normal; white-space: normal;
@ -73,7 +73,7 @@
.dialog-modal-footer { .dialog-modal-footer {
margin: 0; margin: 0;
padding: .5em .5em; padding: 0.5em;
background-color: $fallback--bg; background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg); background-color: var(--bg, $fallback--bg);
border-top: 1px solid $fallback--border; border-top: 1px solid $fallback--border;
@ -83,7 +83,7 @@
button { button {
width: auto; width: auto;
margin-left: .5rem; margin-left: 0.5rem;
} }
} }
} }

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,49 @@
<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,10 @@
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 Popover from 'src/components/popover/popover.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
@ -108,46 +110,122 @@ const EmojiInput = {
data () { data () {
return { return {
input: undefined, input: undefined,
caretEl: undefined,
highlighted: 0, highlighted: 0,
caret: 0, caret: 0,
focused: false, focused: false,
blurTimeout: null, blurTimeout: null,
showPicker: false,
temporarilyHideSuggestions: false, temporarilyHideSuggestions: false,
keepOpen: false,
disableClickOutside: false, disableClickOutside: false,
suggestions: [] suggestions: [],
overlayStyle: {},
pickerShown: false
} }
}, },
components: { components: {
EmojiPicker Popover,
EmojiPicker,
UnicodeDomainIndicator
}, },
computed: { computed: {
padEmoji () { padEmoji () {
return this.$store.getters.mergedConfig.padEmoji return this.$store.getters.mergedConfig.padEmoji
}, },
preText () {
return this.modelValue.slice(0, this.caret)
},
postText () {
return this.modelValue.slice(this.caret)
},
showSuggestions () { showSuggestions () {
return this.focused && return this.focused &&
this.suggestions && this.suggestions &&
this.suggestions.length > 0 && this.suggestions.length > 0 &&
!this.showPicker && !this.pickerShown &&
!this.temporarilyHideSuggestions !this.temporarilyHideSuggestions
}, },
textAtCaret () { textAtCaret () {
return (this.wordAtCaret || {}).word || '' return this.wordAtCaret?.word
}, },
wordAtCaret () { wordAtCaret () {
if (this.modelValue && this.caret) { if (this.modelValue && this.caret) {
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
}
},
onInputScroll () {
this.$refs.hiddenOverlay.scrollTo({
top: this.input.scrollTop,
left: this.input.scrollLeft
})
} }
}, },
mounted () { mounted () {
const { root } = this.$refs const { root, hiddenOverlayCaret, suggestorPopover } = this.$refs
const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea') const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea')
if (!input) return if (!input) return
this.input = input this.input = input
this.caretEl = hiddenOverlayCaret
if (suggestorPopover.setAnchorEl) {
suggestorPopover.setAnchorEl(this.caretEl) // unit test compat
this.$refs.picker.setAnchorEl(this.caretEl)
} else {
console.warn('setAnchorEl not found, are we in a unit test?')
}
const style = getComputedStyle(this.input)
this.overlayStyle.padding = style.padding
this.overlayStyle.border = style.border
this.overlayStyle.margin = style.margin
this.overlayStyle.lineHeight = style.lineHeight
this.overlayStyle.fontFamily = style.fontFamily
this.overlayStyle.fontSize = style.fontSize
this.overlayStyle.wordWrap = style.wordWrap
this.overlayStyle.whiteSpace = style.whiteSpace
this.resize() this.resize()
input.addEventListener('blur', this.onBlur) input.addEventListener('blur', this.onBlur)
input.addEventListener('focus', this.onFocus) input.addEventListener('focus', this.onFocus)
@ -157,6 +235,7 @@ const EmojiInput = {
input.addEventListener('click', this.onClickInput) input.addEventListener('click', this.onClickInput)
input.addEventListener('transitionend', this.onTransition) input.addEventListener('transitionend', this.onTransition)
input.addEventListener('input', this.onInput) input.addEventListener('input', this.onInput)
input.addEventListener('scroll', this.onInputScroll)
}, },
unmounted () { unmounted () {
const { input } = this const { input } = this
@ -169,46 +248,43 @@ const EmojiInput = {
input.removeEventListener('click', this.onClickInput) input.removeEventListener('click', this.onClickInput)
input.removeEventListener('transitionend', this.onTransition) input.removeEventListener('transitionend', this.onTransition)
input.removeEventListener('input', this.onInput) input.removeEventListener('input', this.onInput)
input.removeEventListener('scroll', this.onInputScroll)
} }
}, },
watch: { watch: {
showSuggestions: function (newValue) { showSuggestions: function (newValue, oldValue) {
this.$emit('shown', newValue) this.$emit('shown', newValue)
if (newValue) {
this.$refs.suggestorPopover.showPopover()
} else {
this.$refs.suggestorPopover.hidePopover()
}
}, },
textAtCaret: async function (newWord) { textAtCaret: async function (newWord) {
if (newWord === undefined) return
const firstchar = newWord.charAt(0) const firstchar = newWord.charAt(0)
this.suggestions = [] if (newWord === firstchar) {
if (newWord === firstchar) return this.suggestions = []
const matchedSuggestions = await this.suggest(newWord) return
}
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 || matchedSuggestions.length <= 0) {
if (matchedSuggestions.length <= 0) return this.suggestions = []
return
}
this.suggestions = take(matchedSuggestions, 5) this.suggestions = take(matchedSuggestions, 5)
.map(({ imageUrl, ...rest }) => ({ .map(({ imageUrl, ...rest }) => ({
...rest, ...rest,
img: imageUrl || '' img: imageUrl || ''
})) }))
},
suggestions: {
handler (newValue) {
this.$nextTick(this.resize)
},
deep: true
} }
}, },
methods: { methods: {
focusPickerInput () {
const pickerEl = this.$refs.picker.$el
if (!pickerEl) return
const pickerInput = pickerEl.querySelector('input')
if (pickerInput) pickerInput.focus()
},
triggerShowPicker () { triggerShowPicker () {
this.showPicker = true
this.$refs.picker.startEmojiLoad()
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.picker.showPicker()
this.scrollIntoView() this.scrollIntoView()
this.focusPickerInput()
}) })
// This temporarily disables "click outside" handler // This temporarily disables "click outside" handler
// since external trigger also means click originates // since external trigger also means click originates
@ -220,11 +296,12 @@ const EmojiInput = {
}, },
togglePicker () { togglePicker () {
this.input.focus() this.input.focus()
this.showPicker = !this.showPicker if (!this.pickerShown) {
if (this.showPicker) {
this.scrollIntoView() this.scrollIntoView()
this.$refs.picker.showPicker()
this.$refs.picker.startEmojiLoad() this.$refs.picker.startEmojiLoad()
this.$nextTick(this.focusPickerInput) } else {
this.$refs.picker.hidePicker()
} }
}, },
replace (replacement) { replace (replacement) {
@ -261,7 +338,6 @@ const EmojiInput = {
spaceAfter, spaceAfter,
after after
].join('') ].join('')
this.keepOpen = keepOpen
this.$emit('update:modelValue', newValue) this.$emit('update:modelValue', newValue)
const position = this.caret + (insertion + spaceAfter + spaceBefore).length const position = this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) { if (!keepOpen) {
@ -361,8 +437,11 @@ const EmojiInput = {
} }
}) })
}, },
onTransition (e) { onPickerShown () {
this.resize() this.pickerShown = true
},
onPickerClosed () {
this.pickerShown = false
}, },
onBlur (e) { onBlur (e) {
// Clicking on any suggestion removes focus from autocomplete, // Clicking on any suggestion removes focus from autocomplete,
@ -370,7 +449,6 @@ const EmojiInput = {
this.blurTimeout = setTimeout(() => { this.blurTimeout = setTimeout(() => {
this.focused = false this.focused = false
this.setCaret(e) this.setCaret(e)
this.resize()
}, 200) }, 200)
}, },
onClick (e, suggestion) { onClick (e, suggestion) {
@ -382,18 +460,13 @@ const EmojiInput = {
this.blurTimeout = null this.blurTimeout = null
} }
if (!this.keepOpen) {
this.showPicker = false
}
this.focused = true this.focused = true
this.setCaret(e) this.setCaret(e)
this.resize()
this.temporarilyHideSuggestions = false this.temporarilyHideSuggestions = false
}, },
onKeyUp (e) { onKeyUp (e) {
const { key } = e const { key } = e
this.setCaret(e) this.setCaret(e)
this.resize()
// Setting hider in keyUp to prevent suggestions from blinking // Setting hider in keyUp to prevent suggestions from blinking
// when moving away from suggested spot // when moving away from suggested spot
@ -405,7 +478,6 @@ const EmojiInput = {
}, },
onPaste (e) { onPaste (e) {
this.setCaret(e) this.setCaret(e)
this.resize()
}, },
onKeyDown (e) { onKeyDown (e) {
const { ctrlKey, shiftKey, key } = e const { ctrlKey, shiftKey, key } = e
@ -450,58 +522,24 @@ const EmojiInput = {
this.input.focus() this.input.focus()
} }
} }
this.showPicker = false
this.resize()
}, },
onInput (e) { onInput (e) {
this.showPicker = false
this.setCaret(e) this.setCaret(e)
this.resize()
this.$emit('update:modelValue', e.target.value) this.$emit('update:modelValue', e.target.value)
}, },
onClickInput (e) {
this.showPicker = false
},
onClickOutside (e) {
if (this.disableClickOutside) return
this.showPicker = false
},
onStickerUploaded (e) { onStickerUploaded (e) {
this.showPicker = false
this.$emit('sticker-uploaded', e) this.$emit('sticker-uploaded', e)
}, },
onStickerUploadFailed (e) { onStickerUploadFailed (e) {
this.showPicker = false
this.$emit('sticker-upload-Failed', e) this.$emit('sticker-upload-Failed', e)
}, },
setCaret ({ target: { selectionStart } }) { setCaret ({ target: { selectionStart } }) {
this.caret = selectionStart this.caret = selectionStart
this.$nextTick(() => {
this.$refs.suggestorPopover.updateStyles()
})
}, },
resize () { resize () {
const panel = this.$refs.panel
if (!panel) return
const picker = this.$refs.picker.$el
const panelBody = this.$refs['panel-body']
const { offsetHeight, offsetTop } = this.input
const offsetBottom = offsetTop + offsetHeight
this.setPlacement(panelBody, panel, offsetBottom)
this.setPlacement(picker, picker, offsetBottom)
},
setPlacement (container, target, offsetBottom) {
if (!container || !target) return
target.style.top = offsetBottom + 'px'
target.style.bottom = 'auto'
if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) {
target.style.top = 'auto'
target.style.bottom = this.input.offsetHeight + 'px'
}
},
overflowsBottom (el) {
return el.getBoundingClientRect().bottom > window.innerHeight
} }
} }
} }

View File

@ -1,11 +1,23 @@
<template> <template>
<div <div
ref="root" ref="root"
v-click-outside="onClickOutside"
class="emoji-input" class="emoji-input"
:class="{ 'with-picker': !hideEmojiButton }" :class="{ 'with-picker': !hideEmojiButton }"
> >
<slot /> <slot />
<!-- TODO: make the 'x' disappear if at the end maybe? -->
<div
ref="hiddenOverlay"
class="hidden-overlay"
:style="overlayStyle"
>
<span>{{ preText }}</span>
<span
ref="hiddenOverlayCaret"
class="caret"
>x</span>
<span>{{ postText }}</span>
</div>
<template v-if="enableEmojiPicker"> <template v-if="enableEmojiPicker">
<button <button
v-if="!hideEmojiButton" v-if="!hideEmojiButton"
@ -18,66 +30,79 @@
<EmojiPicker <EmojiPicker
v-if="enableEmojiPicker" v-if="enableEmojiPicker"
ref="picker" ref="picker"
:class="{ hide: !showPicker }"
:enable-sticker-picker="enableStickerPicker" :enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel" class="emoji-picker-panel"
@emoji="insert" @emoji="insert"
@sticker-uploaded="onStickerUploaded" @sticker-uploaded="onStickerUploaded"
@sticker-upload-failed="onStickerUploadFailed" @sticker-upload-failed="onStickerUploadFailed"
@show="onPickerShown"
@close="onPickerClosed"
/> />
</template> </template>
<div <Popover
ref="panel" ref="suggestorPopover"
class="autocomplete-panel" class="autocomplete-panel"
:class="{ hide: !showSuggestions }" placement="bottom"
> >
<div <template #content>
ref="panel-body"
class="autocomplete-panel-body"
>
<div <div
v-for="(suggestion, index) in suggestions" ref="panel-body"
:key="index" class="autocomplete-panel-body"
class="autocomplete-item"
:class="{ highlighted: index === highlighted }"
@click.stop.prevent="onClick($event, suggestion)"
> >
<span class="image"> <div
<img v-for="(suggestion, index) in suggestions"
v-if="suggestion.img" :key="index"
:src="suggestion.img" class="autocomplete-item"
> :class="{ highlighted: index === highlighted }"
<span v-else>{{ suggestion.replacement }}</span> @click.stop.prevent="onClick($event, suggestion)"
</span> >
<div class="label"> <span class="image">
<span class="displayText">{{ suggestion.displayText }}</span> <img
<span class="detailText">{{ suggestion.detailText }}</span> v-if="suggestion.img"
:src="suggestion.img"
>
<span v-else>{{ suggestion.replacement }}</span>
</span>
<div class="label">
<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>
</div>
</div> </div>
</div> </div>
</div> </template>
</div> </Popover>
</div> </div>
</template> </template>
<script src="./emoji_input.js"></script> <script src="./emoji_input.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
.emoji-input { .emoji-input {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
&.with-picker input {
padding-right: 30px;
}
.emoji-picker-icon { .emoji-picker-icon {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
margin: .2em .25em; margin: 0.2em 0.25em;
font-size: 1.3em; font-size: 1.3em;
cursor: pointer; cursor: pointer;
line-height: 24px; line-height: 24px;
@ -87,99 +112,102 @@
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
} }
.emoji-picker-panel { .emoji-picker-panel {
position: absolute; position: absolute;
z-index: 20; z-index: 20;
margin-top: 2px; margin-top: 2px;
&.hide { &.hide {
display: none display: none;
} }
} }
.autocomplete { input,
&-panel { textarea {
position: absolute; flex: 1 0 auto;
z-index: 20; }
margin-top: 2px;
&.hide { &.with-picker input {
display: none padding-right: 30px;
} }
&-body { .hidden-overlay {
margin: 0 0.5em 0 0.5em; opacity: 0;
border-radius: $fallback--tooltipRadius; pointer-events: none;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius); position: absolute;
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); top: 0;
box-shadow: var(--popupShadow); bottom: 0;
min-width: 75%; right: 0;
background-color: $fallback--bg; left: 0;
background-color: var(--popover, $fallback--bg); overflow: hidden;
color: $fallback--link;
color: var(--popoverText, $fallback--link); /* DEBUG STUFF */
--faint: var(--popoverFaintText, $fallback--faint); color: red;
--faintLink: var(--popoverFaintLink, $fallback--faint);
--lightText: var(--popoverLightText, $fallback--lightText); /* set opacity to non-zero to see the overlay */
--postLink: var(--popoverPostLink, $fallback--link);
--postFaintLink: var(--popoverPostFaintLink, $fallback--link); .caret {
--icon: var(--popoverIcon, $fallback--icon); width: 0;
} margin-right: calc(-1ch - 1px);
border: 1px solid red;
} }
}
}
&-item { .autocomplete {
display: flex; &-panel {
cursor: pointer; position: absolute;
padding: 0.2em 0.4em; }
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
&-item {
display: flex;
cursor: pointer;
padding: 0.2em 0.4em;
border-bottom: 1px solid rgb(0 0 0 / 40%);
height: 32px;
.image {
width: 32px;
height: 32px; height: 32px;
line-height: 32px;
text-align: center;
font-size: 32px;
margin-right: 4px;
.image { img {
width: 32px; width: 32px;
height: 32px; height: 32px;
line-height: 32px; object-fit: contain;
text-align: center;
font-size: 32px;
margin-right: 4px;
img {
width: 32px;
height: 32px;
object-fit: contain;
}
}
.label {
display: flex;
flex-direction: column;
justify-content: center;
margin: 0 0.1em 0 0.2em;
.displayText {
line-height: 1.5;
}
.detailText {
font-size: 9px;
line-height: 9px;
}
}
&.highlighted {
background-color: $fallback--fg;
background-color: var(--selectedMenuPopover, $fallback--fg);
color: var(--selectedMenuPopoverText, $fallback--text);
--faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
--faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
--lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
--icon: var(--selectedMenuPopoverIcon, $fallback--icon);
} }
} }
}
input, textarea { .label {
flex: 1 0 auto; display: flex;
flex-direction: column;
justify-content: center;
margin: 0 0.1em 0 0.2em;
.displayText {
line-height: 1.5;
}
.detailText {
font-size: 9px;
line-height: 9px;
}
}
&.highlighted {
background-color: $fallback--fg;
background-color: var(--selectedMenuPopover, $fallback--fg);
color: var(--selectedMenuPopoverText, $fallback--text);
--faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
--faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
--lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
--icon: var(--selectedMenuPopoverIcon, $fallback--icon);
}
} }
} }
</style> </style>

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 Popover from 'src/components/popover/popover.vue'
import StillImage from '../still-image/still-image.vue'
import { ensureFinalFallback } from '../../i18n/languages.js'
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, chunk } 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] = []
@ -38,6 +81,17 @@ const filterByKeyword = (list, keyword = '') => {
return orderedEmojiList.flat() return orderedEmojiList.flat()
} }
const getOffset = (elem) => {
const style = elem.style.transform
const res = /translateY\((\d+)px\)/.exec(style)
if (!res) { return 0 }
return res[1]
}
const toHeaderId = id => {
return id.replace(/^row-\d+-/, '')
}
const EmojiPicker = { const EmojiPicker = {
props: { props: {
enableStickerPicker: { enableStickerPicker: {
@ -53,16 +107,41 @@ 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: [],
width: 0
} }
}, },
components: { components: {
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')), StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
Checkbox Checkbox,
StillImage,
Popover
}, },
methods: { methods: {
showPicker () {
this.$refs.popover.showPopover()
this.onShowing()
},
hidePicker () {
this.$refs.popover.hidePopover()
},
setAnchorEl (el) {
this.$refs.popover.setAnchorEl(el)
},
setGroupRef (name) {
return el => { this.groupRefs[name] = el }
},
onPopoverShown () {
this.$emit('show')
},
onPopoverClosed () {
this.$emit('close')
},
onStickerUploaded (e) { onStickerUploaded (e) {
this.$emit('sticker-uploaded', e) this.$emit('sticker-uploaded', e)
}, },
@ -71,23 +150,53 @@ const EmojiPicker = {
}, },
onEmoji (emoji) { onEmoji (emoji) {
const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
if (!this.keepOpen) {
this.$refs.popover.hidePopover()
}
this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen }) this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
}, },
onScroll (e) { onScroll (startIndex, endIndex, visibleStartIndex, visibleEndIndex) {
const target = (e && e.target) || this.$refs['emoji-groups'] const target = this.$refs['emoji-groups'].$el
this.updateScrolledClass(target) this.scrolledGroup(target, visibleStartIndex, visibleEndIndex)
this.scrolledGroup(target)
this.triggerLoadMore(target)
}, },
highlight (key) { scrolledGroup (target, start, end) {
const ref = this.$refs['group-' + key] const top = target.scrollTop + 5
const top = ref.offsetTop
this.setShowStickers(false)
this.activeGroup = key
this.$nextTick(() => { this.$nextTick(() => {
this.$refs['emoji-groups'].scrollTop = top + 1 this.emojiItems.slice(start, end + 1).forEach(group => {
const headerId = toHeaderId(group.id)
const ref = this.groupRefs['group-' + group.id]
if (!ref) { return }
const elem = ref.$el.parentElement
if (!elem) { return }
if (elem && getOffset(elem) <= top) {
this.activeGroup = headerId
}
})
this.scrollHeader()
}) })
}, },
scrollHeader () {
// Scroll the active tab's header into view
const headerRef = this.groupRefs['group-header-' + this.activeGroup]
const left = headerRef.offsetLeft
const right = left + headerRef.offsetWidth
const headerCont = this.$refs.header
const currentScroll = headerCont.scrollLeft
const currentScrollRight = currentScroll + headerCont.clientWidth
const setScroll = s => { headerCont.scrollLeft = s }
const margin = 7 // .emoji-tabs-item: padding
if (left - margin < currentScroll) {
setScroll(left - margin)
} else if (right + margin > currentScrollRight) {
setScroll(right + margin - headerCont.clientWidth)
}
},
highlight (groupId) {
this.setShowStickers(false)
const indexInList = this.emojiItems.findIndex(k => k.id === groupId)
this.$refs['emoji-groups'].scrollToItem(indexInList)
},
updateScrolledClass (target) { updateScrolledClass (target) {
if (target.scrollTop <= 5) { if (target.scrollTop <= 5) {
this.groupsScrolledClass = 'scrolled-top' this.groupsScrolledClass = 'scrolled-top'
@ -97,74 +206,70 @@ 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)
},
onShowing () {
const oldContentLoaded = this.contentLoaded
this.recalculateItemPerRow()
this.$nextTick(() => {
this.$refs.search.focus()
})
this.contentLoaded = true
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
if (!oldContentLoaded) {
this.$nextTick(() => {
if (this.defaultGroup) {
this.highlight(this.defaultGroup)
}
})
}
},
getFilteredEmojiGroups () {
return this.allEmojiGroups
.map(group => ({
...group,
emojis: this.filterByKeyword(group.emojis, trim(this.keyword))
}))
.filter(group => group.emojis.length > 0)
},
recalculateItemPerRow () {
this.$nextTick(() => {
if (!this.$refs['emoji-groups']) {
return
}
this.width = this.$refs['emoji-groups'].$el.clientWidth
})
} }
}, },
watch: { watch: {
keyword () { keyword () {
this.customEmojiLoadAllConfirmed = false
this.onScroll() this.onScroll()
this.startEmojiLoad(true) this.debouncedHandleKeywordChange()
},
allCustomGroups () {
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
} }
}, },
computed: { computed: {
minItemSize () {
return this.emojiHeight
},
emojiHeight () {
return 32 + 4
},
emojiWidth () {
return 32 + 4
},
itemPerRow () {
return this.width ? Math.floor(this.width / this.emojiWidth - 1) : 6
},
activeGroupView () { activeGroupView () {
return this.showingStickers ? '' : this.activeGroup return this.showingStickers ? '' : this.activeGroup
}, },
@ -174,39 +279,69 @@ const EmojiPicker = {
} }
return 0 return 0
}, },
filteredEmoji () { allCustomGroups () {
return filterByKeyword( const emojis = this.$store.getters.groupedCustomEmojis
this.$store.state.instance.customEmoji || [], if (emojis.unpacked) {
trim(this.keyword) emojis.unpacked.text = this.$t('emoji.unpacked')
) }
return emojis
}, },
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.filteredEmojiGroups = this.getFilteredEmojiGroups()
}, 500)
},
emojiItems () {
return this.filteredEmojiGroups.map(group =>
chunk(group.emojis, this.itemPerRow)
.map((items, index) => ({
...group,
id: index === 0 ? group.id : `row-${index}-${group.id}`,
emojis: items,
isFirstRow: index === 0
})))
.reduce((a, c) => a.concat(c), [])
},
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,24 +1,43 @@
@import '../../_variables.scss'; @import "../../variables";
$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 {
width: 25em;
max-width: calc(100vw - 20px); // popover gives 10px margin from window edge
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: absolute;
right: 0;
left: 0;
margin: 0 !important;
// TODO: actually use popover in emoji picker
z-index: var(--ZI_popovers);
background-color: $fallback--bg; background-color: $fallback--bg;
background-color: var(--popover, $fallback--bg); background-color: var(--popover, $fallback--bg);
color: $fallback--link; color: $fallback--link;
color: var(--popoverText, $fallback--link); color: var(--popoverText, $fallback--link);
--lightText: var(--popoverLightText, $fallback--faint);
--faint: var(--popoverFaintText, $fallback--faint); --faint: var(--popoverFaintText, $fallback--faint);
--faintLink: var(--popoverFaintLink, $fallback--faint); --faintLink: var(--popoverFaintLink, $fallback--faint);
--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 +56,6 @@
.heading { .heading {
display: flex; display: flex;
height: 32px;
padding: 10px 7px 5px; padding: 10px 7px 5px;
} }
@ -45,18 +63,18 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0px; min-height: 0;
} }
.emoji-tabs { .emoji-tabs {
flex-grow: 1; flex-grow: 1;
} display: flex;
flex-flow: row nowrap;
.emoji-groups { overflow-x: auto;
min-height: 200px;
} }
.additional-tabs { .additional-tabs {
display: flex;
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 +84,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;
@ -93,7 +116,7 @@
} }
.sticker-picker { .sticker-picker {
flex: 1 1 auto flex: 1 1 auto;
} }
.stickers, .stickers,
@ -123,22 +146,27 @@
} }
&-groups { &-groups {
height: 100%;
min-height: 200px;
flex: 1 1 1px; flex: 1 1 1px;
position: relative; position: relative;
overflow: auto; overflow: auto;
user-select: none; user-select: none;
mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat, mask:
linear-gradient(to bottom, white 0, transparent 100%) top no-repeat, linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
linear-gradient(to top, white, white); linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
linear-gradient(to top, white, white);
transition: mask-size 150ms; transition: mask-size 150ms;
mask-size: 100% 20px, 100% 20px, auto; mask-size: 100% 20px, 100% 20px, auto;
// Autoprefixed seem to ignore this one, and also syntax is different // Autoprefixed seem to ignore this one, and also syntax is different
-webkit-mask-composite: xor; mask-composite: xor;
mask-composite: exclude; mask-composite: exclude;
&.scrolled { &.scrolled {
&-top { &-top {
mask-size: 100% 20px, 100% 0, auto; mask-size: 100% 20px, 100% 0, auto;
} }
&-bottom { &-bottom {
mask-size: 100% 0, 100% 20px, auto; mask-size: 100% 0, 100% 20px, auto;
} }
@ -164,24 +192,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,105 +1,148 @@
<template> <template>
<div class="emoji-picker panel panel-default panel-body"> <Popover
<div class="heading"> ref="popover"
<span class="emoji-tabs"> trigger="click"
popover-class="emoji-picker popover-default"
@show="onPopoverShown"
@close="onPopoverClosed"
>
<template #content>
<div class="heading">
<span <span
v-for="group in emojis" ref="header"
:key="group.id" class="emoji-tabs"
class="emoji-tabs-item"
:class="{
active: activeGroupView === group.id,
disabled: group.emojis.length === 0
}"
:title="group.text"
@click.prevent="highlight(group.id)"
> >
<FAIcon <span
:icon="group.icon" v-for="group in filteredEmojiGroups"
fixed-width :ref="setGroupRef('group-header-' + group.id)"
/>
</span>
</span>
<span
v-if="stickerPickerEnabled"
class="additional-tabs"
>
<span
class="stickers-tab-icon additional-tabs-item"
:class="{active: showingStickers}"
:title="$t('emoji.stickers')"
@click.prevent="toggleStickers"
>
<FAIcon
icon="sticky-note"
fixed-width
/>
</span>
</span>
</div>
<div class="content">
<div
class="emoji-content"
:class="{hidden: showingStickers}"
>
<div class="emoji-search">
<input
v-model="keyword"
type="text"
class="form-control"
:placeholder="$t('emoji.search_emoji')"
@input="$event.target.composing = false"
>
</div>
<div
ref="emoji-groups"
class="emoji-groups"
:class="groupsScrolledClass"
@scroll="onScroll"
>
<div
v-for="group in emojisView"
:key="group.id" :key="group.id"
class="emoji-group" class="emoji-tabs-item"
:class="{
active: activeGroupView === group.id
}"
:title="group.text"
@click.prevent="highlight(group.id)"
> >
<h6
:ref="'group-' + group.id"
class="emoji-group-title"
>
{{ group.text }}
</h6>
<span <span
v-for="emoji in group.emojis" v-if="group.image"
:key="group.id + emoji.displayText" class="emoji-picker-header-image"
:title="emoji.displayText"
class="emoji-item"
@click.stop.prevent="onEmoji(emoji)"
> >
<span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span> <still-image
<img :alt="group.text"
v-else :src="group.image"
:src="emoji.imageUrl" />
>
</span> </span>
<span :ref="'group-end-' + group.id" /> <FAIcon
v-else
:icon="group.icon"
fixed-width
/>
</span>
</span>
<span
v-if="stickerPickerEnabled"
class="additional-tabs"
>
<span
class="stickers-tab-icon additional-tabs-item"
:class="{active: showingStickers}"
:title="$t('emoji.stickers')"
@click.prevent="toggleStickers"
>
<FAIcon
icon="sticky-note"
fixed-width
/>
</span>
</span>
</div>
<div
v-if="contentLoaded"
class="content"
>
<div
class="emoji-content"
:class="{hidden: showingStickers}"
>
<div class="emoji-search">
<input
ref="search"
v-model="keyword"
type="text"
class="form-control"
:placeholder="$t('emoji.search_emoji')"
@input="$event.target.composing = false"
>
</div>
<DynamicScroller
ref="emoji-groups"
class="emoji-groups"
:class="groupsScrolledClass"
:min-item-size="minItemSize"
:items="emojiItems"
:emit-update="true"
@update="onScroll"
@visible="recalculateItemPerRow"
@resize="recalculateItemPerRow"
>
<template #default="{ item: group, index, active }">
<DynamicScrollerItem
:ref="setGroupRef('group-' + group.id)"
:item="group"
:active="active"
:data-index="index"
:size-dependencies="[group.emojis.length]"
>
<div
class="emoji-group"
>
<h6
v-if="group.isFirstRow"
class="emoji-group-title"
>
{{ group.text }}
</h6>
<span
v-for="emoji in group.emojis"
:key="group.id + emoji.displayText"
:title="maybeLocalizedEmojiName(emoji)"
class="emoji-item"
@click.stop.prevent="onEmoji(emoji)"
>
<span
v-if="!emoji.imageUrl"
class="emoji-picker-emoji -unicode"
>{{ emoji.replacement }}</span>
<still-image
v-else
class="emoji-picker-emoji -custom"
loading="lazy"
:src="emoji.imageUrl"
:data-emoji-name="group.id + emoji.displayText"
/>
</span>
</div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
<div class="keep-open">
<Checkbox v-model="keepOpen">
{{ $t('emoji.keep_open') }}
</Checkbox>
</div> </div>
</div> </div>
<div class="keep-open"> <div
<Checkbox v-model="keepOpen"> v-if="showingStickers"
{{ $t('emoji.keep_open') }} class="stickers-content"
</Checkbox> >
<sticker-picker
@uploaded="onStickerUploaded"
@upload-failed="onStickerUploadFailed"
/>
</div> </div>
</div> </div>
<div </template>
v-if="showingStickers" </Popover>
class="stickers-content"
>
<sticker-picker
@uploaded="onStickerUploaded"
@upload-failed="onStickerUploadFailed"
/>
</div>
</div>
</div>
</template> </template>
<script src="./emoji_picker.js"></script> <script src="./emoji_picker.js"></script>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="emoji-reactions"> <div class="EmojiReactions">
<UserListPopover <UserListPopover
v-for="(reaction) in emojiReactions" v-for="(reaction) in emojiReactions"
:key="reaction.name" :key="reaction.name"
@ -7,7 +7,7 @@
> >
<button <button
class="emoji-reaction btn button-default" class="emoji-reaction btn button-default"
:class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }" :class="{ '-picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
@click="emojiOnClick(reaction.name, $event)" @click="emojiOnClick(reaction.name, $event)"
@mouseenter="fetchEmojiReactionsByIfMissing()" @mouseenter="fetchEmojiReactionsByIfMissing()"
> >
@ -28,55 +28,58 @@
<script src="./emoji_reactions.js"></script> <script src="./emoji_reactions.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
.emoji-reactions { .EmojiReactions {
display: flex; display: flex;
margin-top: 0.25em; margin-top: 0.25em;
flex-wrap: wrap; flex-wrap: wrap;
}
.emoji-reaction { .emoji-reaction {
padding: 0 0.5em; padding: 0 0.5em;
margin-right: 0.5em; margin-right: 0.5em;
margin-top: 0.5em; margin-top: 0.5em;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-sizing: border-box; box-sizing: border-box;
.reaction-emoji {
width: 1.25em; .reaction-emoji {
margin-right: 0.25em; width: 1.25em;
} margin-right: 0.25em;
&:focus { }
outline: none;
&:focus {
outline: none;
}
&.not-clickable {
cursor: default;
&:hover {
box-shadow: $fallback--buttonShadow;
box-shadow: var(--buttonShadow);
}
}
&.-picked-reaction {
border: 1px solid var(--accent, $fallback--link);
margin-left: -1px; // offset the border, can't use inset shadows either
margin-right: calc(0.5em - 1px);
}
} }
&.not-clickable { .emoji-reaction-expand {
cursor: default; padding: 0 0.5em;
margin-right: 0.5em;
margin-top: 0.5em;
display: flex;
align-items: center;
justify-content: center;
&:hover { &:hover {
box-shadow: $fallback--buttonShadow; text-decoration: underline;
box-shadow: var(--buttonShadow);
} }
} }
} }
.emoji-reaction-expand {
padding: 0 0.5em;
margin-right: 0.5em;
margin-top: 0.5em;
display: flex;
align-items: center;
justify-content: center;
&:hover {
text-decoration: underline;
}
}
.picked-reaction {
border: 1px solid var(--accent, $fallback--link);
margin-left: -1px; // offset the border, can't use inset shadows either
margin-right: calc(0.5em - 1px);
}
</style> </style>

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,14 +88,32 @@ 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: {
currentUser () { return this.$store.state.users.currentUser }, currentUser () { return this.$store.state.users.currentUser },
canDelete () { canDelete () {
if (!this.currentUser) { return } if (!this.currentUser) { return }
const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin return this.currentUser.privileges.includes('messages_delete') || this.status.user.id === this.currentUser.id
return superuser || this.status.user.id === this.currentUser.id
}, },
ownStatus () { ownStatus () {
return this.status.user.id === this.currentUser.id return this.status.user.id === this.currentUser.id
@ -94,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>
@ -134,14 +172,10 @@
<script src="./extra_buttons.js"></script> <script src="./extra_buttons.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
@import "../../mixins";
.ExtraButtons { .ExtraButtons {
/* override of popover internal stuff */
.popover-trigger-button {
width: auto;
}
.popover-trigger { .popover-trigger {
position: static; position: static;
padding: 10px; padding: 10px;
@ -152,5 +186,22 @@
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
} }
.popover-trigger-button {
/* override of popover internal stuff */
width: auto;
@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 = {
@ -31,7 +39,10 @@ const FavoriteButton = {
} }
}, },
computed: { computed: {
...mapGetters(['mergedConfig']) ...mapGetters(['mergedConfig']),
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
} }
} }

View File

@ -7,19 +7,45 @@
: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> <a
v-else
class="button-unstyled interactive"
target="_blank"
role="button"
:href="remoteInteractionLink"
>
<FAIcon <FAIcon
class="fa-scale-110 fa-old-padding" class="fa-scale-110 fa-old-padding"
:title="$t('tool_tip.favorite')" :title="$t('tool_tip.favorite')"
:icon="['far', 'star']" :icon="['far', 'star']"
/> />
</span> </a>
<span <span
v-if="!mergedConfig.hidePostStats && status.fave_num > 0" v-if="!mergedConfig.hidePostStats && status.fave_num > 0"
class="action-counter" class="action-counter"
@ -32,7 +58,8 @@
<script src="./favorite_button.js"></script> <script src="./favorite_button.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
@import "../../mixins";
.FavoriteButton { .FavoriteButton {
display: flex; display: flex;
@ -57,6 +84,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

@ -42,7 +42,8 @@
<script src="./flash.js"></script> <script src="./flash.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
.Flash { .Flash {
display: inline-block; display: inline-block;
width: 100%; width: 100%;
@ -78,7 +79,7 @@
.hidden { .hidden {
display: none; display: none;
visibility: 'hidden'; 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>
@ -34,12 +39,17 @@
&-content-container { &-content-container {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
flex-direction: row; flex-flow: row wrap;
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap;
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

@ -22,8 +22,8 @@
<style lang="scss"> <style lang="scss">
.follow-request-card-content-container { .follow-request-card-content-container {
display: flex; display: flex;
flex-direction: row; flex-flow: row wrap;
flex-wrap: wrap;
button { button {
margin-top: 0.5em; margin-top: 0.5em;
margin-right: 0.5em; margin-right: 0.5em;

View File

@ -50,17 +50,20 @@
<script src="./font_control.js"></script> <script src="./font_control.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
.font-control { .font-control {
input.custom-font { input.custom-font {
min-width: 10em; min-width: 10em;
} }
&.custom { &.custom {
/* TODO Should make proper joiners... */ /* TODO Should make proper joiners... */
.font-switcher { .font-switcher {
border-top-right-radius: 0; border-top-right-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
} }
.custom-font { .custom-font {
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;

View File

@ -4,6 +4,7 @@ import { sumBy, set } from 'lodash'
const Gallery = { const Gallery = {
props: [ props: [
'attachments', 'attachments',
'compact',
'limitRows', 'limitRows',
'descriptions', 'descriptions',
'limit', 'limit',

View File

@ -20,6 +20,7 @@
v-for="(attachment, attachmentIndex) in row.items" v-for="(attachment, attachmentIndex) in row.items"
:key="attachment.id" :key="attachment.id"
class="gallery-item" class="gallery-item"
:compact="compact"
:nsfw="nsfw" :nsfw="nsfw"
:attachment="attachment" :attachment="attachment"
:size="size" :size="size"
@ -86,7 +87,7 @@
<script src='./gallery.js'></script> <script src='./gallery.js'></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
.Gallery { .Gallery {
.gallery-rows { .gallery-rows {
@ -100,6 +101,53 @@
width: 100%; width: 100%;
flex-grow: 1; flex-grow: 1;
.gallery-row-inner {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-flow: row wrap;
align-content: stretch;
.gallery-item {
margin: 0 0.5em 0 0;
flex-grow: 1;
height: 100%;
box-sizing: border-box;
// to make failed images a bit more noticeable on chromium
min-width: 2em;
&:last-child {
margin: 0;
}
}
&.-grid {
width: 100%;
height: auto;
position: relative;
display: grid;
grid-gap: 0.5em;
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
.gallery-item {
margin: 0;
height: 200px;
}
}
}
&.-grid,
&.-minimal {
height: auto;
.gallery-row-inner {
position: relative;
}
}
&:not(:first-child) { &:not(:first-child) {
margin-top: 0.5em; margin-top: 0.5em;
} }
@ -114,7 +162,7 @@
linear-gradient(to top, white, white); linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */ /* Autoprefixed seem to ignore this one, and also syntax is different */
-webkit-mask-composite: xor; mask-composite: xor;
mask-composite: exclude; mask-composite: exclude;
} }
} }
@ -138,54 +186,5 @@
padding: 0 2em; padding: 0 2em;
} }
} }
.gallery-row {
&.-grid,
&.-minimal {
height: auto;
.gallery-row-inner {
position: relative;
}
}
}
.gallery-row-inner {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-content: stretch;
&.-grid {
width: 100%;
height: auto;
position: relative;
display: grid;
grid-column-gap: 0.5em;
grid-row-gap: 0.5em;
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
.gallery-item {
margin: 0;
height: 200px;
}
}
}
.gallery-item {
margin: 0 0.5em 0 0;
flex-grow: 1;
height: 100%;
box-sizing: border-box;
// to make failed images a bit more noticeable on chromium
min-width: 2em;
&:last-child {
margin: 0;
}
}
} }
</style> </style>

View File

@ -25,14 +25,14 @@
<script src="./global_notice_list.js"></script> <script src="./global_notice_list.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
.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;
@ -73,6 +73,7 @@
.global-success { .global-success {
background-color: var(--alertPopupSuccess, $fallback--cGreen); background-color: var(--alertPopupSuccess, $fallback--cGreen);
color: var(--alertPopupSuccessText, $fallback--text); color: var(--alertPopupSuccessText, $fallback--text);
.svg-inline--fa { .svg-inline--fa {
color: var(--alertPopupSuccessText, $fallback--text); color: var(--alertPopupSuccessText, $fallback--text);
} }
@ -81,6 +82,7 @@
.global-info { .global-info {
background-color: var(--alertPopupNeutral, $fallback--fg); background-color: var(--alertPopupNeutral, $fallback--fg);
color: var(--alertPopupNeutralText, $fallback--text); color: var(--alertPopupNeutralText, $fallback--text);
.svg-inline--fa { .svg-inline--fa {
color: var(--alertPopupNeutralText, $fallback--text); color: var(--alertPopupNeutralText, $fallback--text);
} }
@ -88,6 +90,7 @@
.close-notice { .close-notice {
padding-right: 0.2em; padding-right: 0.2em;
.svg-inline--fa:hover { .svg-inline--fa:hover {
opacity: 0.6; opacity: 0.6;
} }

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: this.$store.state.users.currentUser.privileges.includes('reports_manage_reports')
} }
}, },
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

@ -33,7 +33,7 @@
<script src="./link-preview.js"></script> <script src="./link-preview.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
.link-preview-card { .link-preview-card {
display: flex; display: flex;
@ -46,6 +46,7 @@
flex-shrink: 0; flex-shrink: 0;
width: 120px; width: 120px;
max-width: 25%; max-width: 25%;
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -67,7 +68,7 @@
} }
.card-description { .card-description {
margin: 0.5em 0 0 0; margin: 0.5em 0 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
word-break: break-word; word-break: break-word;

View File

@ -35,7 +35,7 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import "../../variables";
.list { .list {
&-item:not(:last-child) { &-item:not(:last-child) {

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,52 @@
<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";
.list-card {
display: flex;
}
.list-name {
flex-grow: 1;
}
.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);
}
}
</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: user.id, listId: this.id })
},
removeUser (userId) {
this.$store.dispatch('removeListAccount', { accountId: userId, 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

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