Merge branch 'develop' into 'allow-opening-profile-in-user-popover'

# Conflicts:
#   src/components/settings_modal/tabs/general_tab.vue
This commit is contained in:
HJ 2022-08-22 22:29:13 +00:00
commit 272b748f26
99 changed files with 4868 additions and 3873 deletions

View File

@ -29,18 +29,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 +50,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

View File

@ -2,7 +2,7 @@ var path = require('path')
var config = require('../config') var config = require('../config')
var utils = require('./utils') var utils = require('./utils')
var projectRoot = path.resolve(__dirname, '../') var projectRoot = path.resolve(__dirname, '../')
var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin') var ServiceWorkerWebpackPlugin = require('serviceworker-webpack5-plugin')
var CopyPlugin = require('copy-webpack-plugin'); var CopyPlugin = require('copy-webpack-plugin');
var { VueLoaderPlugin } = require('vue-loader') var { VueLoaderPlugin } = require('vue-loader')
var ESLintPlugin = require('eslint-webpack-plugin'); var ESLintPlugin = require('eslint-webpack-plugin');
@ -42,6 +42,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 +82,16 @@ module.exports = {
}, },
{ {
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
use: { type: 'asset',
loader: 'url-loader', generator: {
options: { filename: utils.assetsPath('img/[name].[hash:7][ext]')
limit: 10000,
name: utils.assetsPath('img/[name].[hash:7].[ext]')
}
} }
}, },
{ {
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
use: { type: 'asset',
loader: 'url-loader', generator: {
options: { filename: utils.assetsPath('fonts/[name].[hash:7][ext]')
limit: 10000,
name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
}
} }
}, },
{ {
@ -117,9 +115,8 @@ module.exports = {
new CopyPlugin({ new CopyPlugin({
patterns: [ patterns: [
{ {
from: "node_modules/@ruffle-rs/ruffle/*", from: "node_modules/@ruffle-rs/ruffle/**/*",
to: "static/ruffle", to: "static/ruffle/[name][ext]"
flatten: true
}, },
], ],
options: { options: {

View File

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

View File

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

View File

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

View File

@ -32,6 +32,7 @@ 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,
GlobalNoticeList GlobalNoticeList
@ -59,6 +60,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 () {

View File

@ -182,13 +182,18 @@ nav {
.app-layout { .app-layout {
--miniColumn: 25rem; --miniColumn: 25rem;
--maxiColumn: minmax(var(--miniColumn), 45rem); --maxiColumn: 45rem;
--columnGap: 1em; --columnGap: 1em;
--status-margin: 0.75em; --status-margin: 0.75em;
--effectiveSidebarColumnWidth: minmax(var(--miniColumn), var(--sidebarColumnWidth, var(--miniColumn)));
--effectiveNotifsColumnWidth: minmax(var(--miniColumn), var(--notifsColumnWidth, var(--miniColumn)));
--effectiveContentColumnWidth: minmax(var(--miniColumn), var(--contentColumnWidth, var(--maxiColumn)));
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: var(--miniColumn) var(--maxiColumn); grid-template-columns:
var(--effectiveSidebarColumnWidth)
var(--effectiveContentColumnWidth);
grid-template-areas: "sidebar content"; grid-template-areas: "sidebar content";
grid-template-rows: 1fr; grid-template-rows: 1fr;
box-sizing: border-box; box-sizing: border-box;
@ -282,15 +287,24 @@ nav {
} }
&.-reverse:not(.-wide):not(.-mobile) { &.-reverse:not(.-wide):not(.-mobile) {
grid-template-columns: var(--maxiColumn) var(--miniColumn); grid-template-columns:
var(--effectiveContentColumnWidth)
var(--effectiveSidebarColumnWidth);
grid-template-areas: "content sidebar"; grid-template-areas: "content sidebar";
} }
&.-wide { &.-wide {
grid-template-columns: var(--miniColumn) var(--maxiColumn) var(--miniColumn); grid-template-columns:
var(--effectiveSidebarColumnWidth)
var(--effectiveContentColumnWidth)
var(--effectiveNotifsColumnWidth);
grid-template-areas: "sidebar content notifs"; grid-template-areas: "sidebar content notifs";
&.-reverse { &.-reverse {
grid-template-columns:
var(--effectiveNotifsColumnWidth)
var(--effectiveContentColumnWidth)
var(--effectiveSidebarColumnWidth);
grid-template-areas: "notifs content sidebar"; grid-template-areas: "notifs content sidebar";
} }
} }
@ -752,7 +766,7 @@ option {
} }
.fa-old-padding { .fa-old-padding {
&.svg-inline--fa { &.svg-inline--fa, &-layer {
padding: 0 0.3em; padding: 0 0.3em;
} }
} }

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"
@ -65,6 +68,7 @@
<UserReportingModal /> <UserReportingModal />
<PostStatusModal /> <PostStatusModal />
<SettingsModal /> <SettingsModal />
<UpdateNotification />
<div id="modal" /> <div id="modal" />
<GlobalNoticeList /> <GlobalNoticeList />
<div id="popovers" /> <div id="popovers" />

17
src/_mixins.scss Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

View File

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

View File

@ -20,6 +20,9 @@ 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'
export default (store) => { export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => { const validateAuthenticatedRoute = (to, from, next) => {
@ -58,7 +61,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 +75,11 @@ export default (store) => {
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) }, { name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute }, { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About }, { name: 'about', path: '/about', component: About },
{ name: 'user-profile', path: '/:_(users)?/:name', component: UserProfile } { name: 'user-profile', path: '/users/:name', component: UserProfile },
{ name: 'legacy-user-profile', path: '/:name', component: UserProfile },
{ name: 'lists', path: '/lists', component: Lists },
{ name: 'lists-timeline', path: '/lists/:id', component: ListsTimeline },
{ name: 'lists-edit', path: '/lists/:id/edit', component: ListsEdit }
] ]
if (store.state.instance.pleromaChatMessagesAvailable) { if (store.state.instance.pleromaChatMessagesAvailable) {

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

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

View File

@ -6,7 +6,9 @@ import {
faEyeSlash, faEyeSlash,
faThumbtack, faThumbtack,
faShareAlt, faShareAlt,
faExternalLinkAlt faExternalLinkAlt,
faPlus,
faTimes
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { import {
faBookmark as faBookmarkReg, faBookmark as faBookmarkReg,
@ -21,13 +23,26 @@ library.add(
faThumbtack, faThumbtack,
faShareAlt, faShareAlt,
faExternalLinkAlt, faExternalLinkAlt,
faFlag faFlag,
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) {

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">
@ -122,10 +124,24 @@
</template> </template>
<template #trigger> <template #trigger>
<span class="button-unstyled popover-trigger"> <span class="button-unstyled popover-trigger">
<FALayers class="fa-old-padding-layer">
<FAIcon <FAIcon
class="fa-scale-110 fa-old-padding" class="fa-scale-110 "
icon="ellipsis-h" icon="ellipsis-h"
/> />
<FAIcon
v-show="!expanded"
class="focus-marker"
transform="shrink-6 up-8 right-16"
icon="plus"
/>
<FAIcon
v-show="expanded"
class="focus-marker"
transform="shrink-6 up-8 right-16"
icon="times"
/>
</FALayers>
</span> </span>
</template> </template>
</Popover> </Popover>
@ -135,6 +151,7 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
@import '../../_mixins.scss';
.ExtraButtons { .ExtraButtons {
/* override of popover internal stuff */ /* override of popover internal stuff */
@ -151,6 +168,21 @@
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
}
.popover-trigger-button {
@include unfocused-style {
.focus-marker {
visibility: hidden;
}
}
@include focused-style {
.focus-marker {
visibility: visible;
}
}
} }
} }
</style> </style>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,32 @@
import ListsCard from '../lists_card/lists_card.vue'
import ListsNew from '../lists_new/lists_new.vue'
const Lists = {
data () {
return {
isNew: false
}
},
components: {
ListsCard,
ListsNew
},
created () {
this.$store.dispatch('startFetchingLists')
},
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,31 @@
<template>
<div v-if="isNew">
<ListsNew @cancel="cancelNewList" />
</div>
<div
v-else
class="settings panel panel-default"
>
<div class="panel-heading">
<div class="title">
{{ $t('lists.lists') }}
</div>
<button
class="button-default"
@click="newList"
>
{{ $t("lists.new") }}
</button>
</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>

View File

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

View File

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

View File

@ -0,0 +1,91 @@
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 UserAvatar from '../user_avatar/user_avatar.vue'
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
},
data () {
return {
title: '',
userIds: [],
selectedUserIds: []
}
},
created () {
this.$store.dispatch('fetchList', { id: this.id })
.then(() => { this.title = this.findListTitle(this.id) })
this.$store.dispatch('fetchListAccounts', { id: this.id })
.then(() => {
this.selectedUserIds = this.findListAccounts(this.id)
this.selectedUserIds.forEach(userId => {
this.$store.dispatch('fetchUserIfMissing', userId)
})
})
},
computed: {
id () {
return this.$route.params.id
},
users () {
return this.userIds.map(userId => this.findUser(userId))
},
selectedUsers () {
return this.selectedUserIds.map(userId => this.findUser(userId)).filter(user => user)
},
...mapState({
currentUser: state => state.users.currentUser
}),
...mapGetters(['findUser', 'findListTitle', 'findListAccounts'])
},
methods: {
onInput () {
this.search(this.query)
},
selectUser (user) {
if (this.selectedUserIds.includes(user.id)) {
this.removeUser(user.id)
} else {
this.addUser(user)
}
},
isSelected (user) {
return this.selectedUserIds.includes(user.id)
},
addUser (user) {
this.selectedUserIds.push(user.id)
},
removeUser (userId) {
this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId)
},
onResults (results) {
this.userIds = results
},
updateList () {
this.$store.dispatch('setList', { id: this.id, title: this.title })
this.$store.dispatch('setListAccounts', { id: this.id, accountIds: this.selectedUserIds })
this.$router.push({ name: 'lists-timeline', params: { id: this.id } })
},
deleteList () {
this.$store.dispatch('deleteList', { id: this.id })
this.$router.push({ name: 'lists' })
}
}
}
export default ListsNew

View File

@ -0,0 +1,108 @@
<template>
<div class="panel-default panel list-edit">
<div
ref="header"
class="panel-heading"
>
<button
class="button-unstyled go-back-button"
@click="$router.back"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
</button>
</div>
<div class="input-wrap">
<input
ref="title"
v-model="title"
:placeholder="$t('lists.title')"
>
</div>
<div class="member-list">
<div
v-for="user in selectedUsers"
:key="user.id"
class="member"
>
<BasicUserCard
:user="user"
:class="isSelected(user) ? 'selected' : ''"
@click.capture.prevent="selectUser(user)"
/>
</div>
</div>
<ListsUserSearch @results="onResults" />
<div class="member-list">
<div
v-for="user in users"
:key="user.id"
class="member"
>
<BasicUserCard
:user="user"
:class="isSelected(user) ? 'selected' : ''"
@click.capture.prevent="selectUser(user)"
/>
</div>
</div>
<button
:disabled="title && title.length === 0"
class="btn button-default"
@click="updateList"
>
{{ $t('lists.save') }}
</button>
<button
class="btn button-default"
@click="deleteList"
>
{{ $t('lists.delete') }}
</button>
</div>
</template>
<script src="./lists_edit.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.list-edit {
.input-wrap {
display: flex;
margin: 0.7em 0.5em 0.7em 0.5em;
input {
width: 100%;
}
}
.search-icon {
margin-right: 0.3em;
}
.member-list {
padding-bottom: 0.7rem;
}
.basic-user-card:hover,
.basic-user-card.selected {
cursor: pointer;
background-color: var(--selectedPost, $fallback--lightBg);
}
.go-back-button {
text-align: center;
line-height: 1;
height: 100%;
align-self: start;
width: var(--__panel-heading-height-inner);
}
.btn {
margin: 0.5em;
}
}
</style>

View File

@ -0,0 +1,33 @@
import { mapState } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faUsers,
faGlobe,
faBookmark,
faEnvelope,
faHome
} from '@fortawesome/free-solid-svg-icons'
library.add(
faUsers,
faGlobe,
faBookmark,
faEnvelope,
faHome
)
const ListsMenuContent = {
created () {
this.$store.dispatch('startFetchingLists')
},
computed: {
...mapState({
lists: state => state.lists.allLists,
currentUser: state => state.users.currentUser,
privateMode: state => state.instance.private,
federating: state => state.instance.federating
})
}
}
export default ListsMenuContent

View File

@ -0,0 +1,17 @@
<template>
<ul>
<li
v-for="list in lists.slice().reverse()"
:key="list.id"
>
<router-link
class="menu-item"
:to="{ name: 'lists-timeline', params: { id: list.id } }"
>
{{ list.title }}
</router-link>
</li>
</ul>
</template>
<script src="./lists_menu_content.js"></script>

View File

@ -0,0 +1,79 @@
import { mapState, mapGetters } from 'vuex'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import ListsUserSearch from '../lists_user_search/lists_user_search.vue'
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
},
data () {
return {
title: '',
userIds: [],
selectedUserIds: []
}
},
computed: {
users () {
return this.userIds.map(userId => this.findUser(userId))
},
selectedUsers () {
return this.selectedUserIds.map(userId => this.findUser(userId))
},
...mapState({
currentUser: state => state.users.currentUser
}),
...mapGetters(['findUser'])
},
methods: {
goBack () {
this.$emit('cancel')
},
onInput () {
this.search(this.query)
},
selectUser (user) {
if (this.selectedUserIds.includes(user.id)) {
this.removeUser(user.id)
} else {
this.addUser(user)
}
},
isSelected (user) {
return this.selectedUserIds.includes(user.id)
},
addUser (user) {
this.selectedUserIds.push(user.id)
},
removeUser (userId) {
this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId)
},
onResults (results) {
this.userIds = results
},
createList () {
// the API has two different endpoints for "creating a list with a name"
// and "updating the accounts on the list".
this.$store.dispatch('createList', { title: this.title })
.then((list) => {
this.$store.dispatch('setListAccounts', { id: list.id, accountIds: this.selectedUserIds })
this.$router.push({ name: 'lists-timeline', params: { id: list.id } })
})
}
}
}
export default ListsNew

View File

@ -0,0 +1,95 @@
<template>
<div class="panel-default panel list-new">
<div
ref="header"
class="panel-heading"
>
<button
class="button-unstyled go-back-button"
@click="goBack"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
</button>
</div>
<div class="input-wrap">
<input
ref="title"
v-model="title"
:placeholder="$t('lists.title')"
>
</div>
<div class="member-list">
<div
v-for="user in selectedUsers"
:key="user.id"
class="member"
>
<BasicUserCard
:user="user"
:class="isSelected(user) ? 'selected' : ''"
@click.capture.prevent="selectUser(user)"
/>
</div>
</div>
<ListsUserSearch
@results="onResults"
/>
<div
v-for="user in users"
:key="user.id"
class="member"
>
<BasicUserCard
:user="user"
:class="isSelected(user) ? 'selected' : ''"
@click.capture.prevent="selectUser(user)"
/>
</div>
<button
:disabled="title && title.length === 0"
class="btn button-default"
@click="createList"
>
{{ $t('lists.create') }}
</button>
</div>
</template>
<script src="./lists_new.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.list-new {
.search-icon {
margin-right: 0.3em;
}
.member-list {
padding-bottom: 0.7rem;
}
.basic-user-card:hover,
.basic-user-card.selected {
cursor: pointer;
background-color: var(--selectedPost, $fallback--lightBg);
}
.go-back-button {
text-align: center;
line-height: 1;
height: 100%;
align-self: start;
width: var(--__panel-heading-height-inner);
}
.btn {
margin: 0.5em;
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import TimelineMenuContent from '../timeline_menu/timeline_menu_content.vue' import TimelineMenuContent from '../timeline_menu/timeline_menu_content.vue'
import ListsMenuContent from '../lists_menu/lists_menu_content.vue'
import { mapState, mapGetters } from 'vuex' import { mapState, mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
@ -12,7 +13,8 @@ import {
faComments, faComments,
faBell, faBell,
faInfoCircle, faInfoCircle,
faStream faStream,
faList
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
@ -25,7 +27,8 @@ library.add(
faComments, faComments,
faBell, faBell,
faInfoCircle, faInfoCircle,
faStream faStream,
faList
) )
const NavPanel = { const NavPanel = {
@ -35,19 +38,27 @@ const NavPanel = {
} }
}, },
components: { components: {
TimelineMenuContent TimelineMenuContent,
ListsMenuContent
}, },
data () { data () {
return { return {
showTimelines: false showTimelines: false,
showLists: false
} }
}, },
methods: { methods: {
toggleTimelines () { toggleTimelines () {
this.showTimelines = !this.showTimelines this.showTimelines = !this.showTimelines
},
toggleLists () {
this.showLists = !this.showLists
} }
}, },
computed: { computed: {
listsNavigation () {
return this.$store.getters.mergedConfig.listsNavigation
},
...mapState({ ...mapState({
currentUser: state => state.users.currentUser, currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length, followRequestCount: state => state.api.followRequests.length,

View File

@ -25,6 +25,51 @@
<TimelineMenuContent class="timelines" /> <TimelineMenuContent class="timelines" />
</div> </div>
</li> </li>
<li v-if="currentUser && listsNavigation">
<button
class="button-unstyled menu-item"
@click="toggleLists"
>
<router-link
:to="{ name: 'lists' }"
@click.stop
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="list"
/>{{ $t("nav.lists") }}
</router-link>
<FAIcon
class="timelines-chevron"
fixed-width
:icon="showLists ? 'chevron-up' : 'chevron-down'"
/>
</button>
<div
v-show="showLists"
class="timelines-background"
>
<ListsMenuContent class="timelines" />
</div>
</li>
<li v-if="currentUser && !listsNavigation">
<router-link
:to="{ name: 'lists' }"
@click.stop
>
<button
class="button-unstyled menu-item"
@click="toggleLists"
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="list"
/>{{ $t("nav.lists") }}
</button>
</router-link>
</li>
<li v-if="currentUser"> <li v-if="currentUser">
<router-link <router-link
class="menu-item" class="menu-item"

View File

@ -4,6 +4,7 @@ import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue' import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue' import Timeago from '../timeago/timeago.vue'
import Report from '../report/report.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx' import RichContent from 'src/components/rich_content/rich_content.jsx'
import UserPopover from '../user_popover/user_popover.vue' import UserPopover from '../user_popover/user_popover.vue'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js' import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
@ -47,6 +48,7 @@ const Notification = {
UserCard, UserCard,
Timeago, Timeago,
Status, Status,
Report,
RichContent, RichContent,
UserPopover UserPopover
}, },

View File

@ -121,6 +121,9 @@
</i18n-t> </i18n-t>
</small> </small>
</span> </span>
<span v-if="notification.type === 'pleroma:report'">
<small>{{ $t('notifications.submitted_report') }}</small>
</span>
<span v-if="notification.type === 'poll'"> <span v-if="notification.type === 'poll'">
<FAIcon <FAIcon
class="type-icon" class="type-icon"
@ -211,6 +214,10 @@
@{{ notification.target.screen_name_ui }} @{{ notification.target.screen_name_ui }}
</router-link> </router-link>
</div> </div>
<Report
v-else-if="notification.type === 'pleroma:report'"
:report-id="notification.report.id"
/>
<template v-else> <template v-else>
<StatusContent <StatusContent
class="faint" class="faint"

View File

@ -59,9 +59,11 @@
height: 32px; height: 32px;
} }
.faint {
--link: var(--faintLink); --link: var(--faintLink);
--text: var(--faint); --text: var(--faint);
} }
}
.follow-request-accept { .follow-request-accept {
&:hover { &:hover {

View File

@ -1,15 +1,21 @@
import Popover from '../popover/popover.vue' import Popover from '../popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons'
import { faSmileBeam } from '@fortawesome/free-regular-svg-icons' import { faSmileBeam } from '@fortawesome/free-regular-svg-icons'
import { trim } from 'lodash' import { trim } from 'lodash'
library.add(faSmileBeam) library.add(
faPlus,
faTimes,
faSmileBeam
)
const ReactButton = { const ReactButton = {
props: ['status'], props: ['status'],
data () { data () {
return { return {
filterWord: '' filterWord: '',
expanded: false
} }
}, },
components: { components: {
@ -25,6 +31,13 @@ const ReactButton = {
} }
close() close()
}, },
onShow () {
this.expanded = true
this.focusInput()
},
onClose () {
this.expanded = false
},
focusInput () { focusInput () {
this.$nextTick(() => { this.$nextTick(() => {
const input = this.$el.querySelector('input') const input = this.$el.querySelector('input')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,10 @@
@change="update" @change="update"
> >
{{ ' ' }} {{ ' ' }}
<ModifiedIndicator :changed="isChanged" /> <ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
</span> </span>
</template> </template>

View File

@ -0,0 +1,67 @@
import { get, set } from 'lodash'
import ModifiedIndicator from './modified_indicator.vue'
import Select from 'src/components/select/select.vue'
export const allCssUnits = ['cm', 'mm', 'in', 'px', 'pt', 'pc', 'em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', '%']
export const defaultHorizontalUnits = ['px', 'rem', 'vw']
export const defaultVerticalUnits = ['px', 'rem', 'vh']
export default {
components: {
ModifiedIndicator,
Select
},
props: {
path: String,
disabled: Boolean,
min: Number,
units: {
type: [String],
default: () => allCssUnits
},
expert: [Number, String]
},
computed: {
pathDefault () {
const [firstSegment, ...rest] = this.path.split('.')
return [firstSegment + 'DefaultValue', ...rest].join('.')
},
stateUnit () {
return (this.state || '').replace(/\d+/, '')
},
stateValue () {
return (this.state || '').replace(/\D+/, '')
},
state () {
const value = get(this.$parent, this.path)
if (value === undefined) {
return this.defaultState
} else {
return value
}
},
defaultState () {
return get(this.$parent, this.pathDefault)
},
isChanged () {
return this.state !== this.defaultState
},
matchesExpertLevel () {
return (this.expert || 0) <= this.$parent.expertLevel
}
},
methods: {
update (e) {
set(this.$parent, this.path, e)
},
reset () {
set(this.$parent, this.path, this.defaultState)
},
updateValue (e) {
set(this.$parent, this.path, parseInt(e.target.value) + this.stateUnit)
},
updateUnit (e) {
set(this.$parent, this.path, this.stateValue + e.target.value)
}
}
}

View File

@ -0,0 +1,54 @@
<template>
<span
v-if="matchesExpertLevel"
class="SizeSetting"
>
<label
:for="path"
class="size-label"
>
<slot />
</label>
<input
:id="path"
class="number-input"
type="number"
step="1"
:disabled="disabled"
:min="min || 0"
:value="stateValue"
@change="updateValue"
>
<Select
:id="path"
:model-value="stateUnit"
:disabled="disabled"
class="css-unit-input"
@change="updateUnit"
>
<option
v-for="option in units"
:key="option"
:value="option"
>
{{ option }}
</option>
</Select>
{{ ' ' }}
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
</span>
</template>
<script src="./size_setting.js"></script>
<style lang="scss">
.css-unit-input, .css-unit-input select {
margin-left: 0.5em;
width: 4em !important;
max-width: 4em !important;
min-width: 4em !important;
}
</style>

View File

@ -2,6 +2,7 @@ import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue' import ChoiceSetting from '../helpers/choice_setting.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue' import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import IntegerSetting from '../helpers/integer_setting.vue' import IntegerSetting from '../helpers/integer_setting.vue'
import SizeSetting, { defaultHorizontalUnits } from '../helpers/size_setting.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue' import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js' import SharedComputedObject from '../helpers/shared_computed_object.js'
@ -61,11 +62,15 @@ const GeneralTab = {
BooleanSetting, BooleanSetting,
ChoiceSetting, ChoiceSetting,
IntegerSetting, IntegerSetting,
SizeSetting,
InterfaceLanguageSwitcher, InterfaceLanguageSwitcher,
ScopeSelector, ScopeSelector,
ServerSideIndicator ServerSideIndicator
}, },
computed: { computed: {
horizontalUnits () {
return defaultHorizontalUnits
},
postFormats () { postFormats () {
return this.$store.state.instance.postFormats || [] return this.$store.state.instance.postFormats || []
}, },
@ -76,6 +81,17 @@ const GeneralTab = {
label: this.$t(`post_status.content_type["${format}"]`) label: this.$t(`post_status.content_type["${format}"]`)
})) }))
}, },
columns () {
const mode = this.$store.getters.mergedConfig.thirdColumnMode
const notif = mode === 'none' ? [] : ['notifs']
if (this.$store.getters.mergedConfig.sidebarRight || mode === 'postform') {
return [...notif, 'content', 'sidebar']
} else {
return ['sidebar', 'content', ...notif]
}
},
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel }, instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
instanceWallpaperUsed () { instanceWallpaperUsed () {
return this.$store.state.instance.background && return this.$store.state.instance.background &&

View File

@ -15,11 +15,6 @@
{{ $t('settings.hide_isp') }} {{ $t('settings.hide_isp') }}
</BooleanSetting> </BooleanSetting>
</li> </li>
<li>
<BooleanSetting path="sidebarRight">
{{ $t('settings.right_sidebar') }}
</BooleanSetting>
</li>
<li v-if="instanceWallpaperUsed"> <li v-if="instanceWallpaperUsed">
<BooleanSetting path="hideInstanceWallpaper"> <BooleanSetting path="hideInstanceWallpaper">
{{ $t('settings.hide_wallpaper') }} {{ $t('settings.hide_wallpaper') }}
@ -64,16 +59,6 @@
{{ $t('settings.virtual_scrolling') }} {{ $t('settings.virtual_scrolling') }}
</BooleanSetting> </BooleanSetting>
</li> </li>
<li>
<BooleanSetting path="disableStickyHeaders">
{{ $t('settings.disable_sticky_headers') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="showScrollbars">
{{ $t('settings.show_scrollbars') }}
</BooleanSetting>
</li>
<li> <li>
<ChoiceSetting <ChoiceSetting
id="userPopoverAvatarAction" id="userPopoverAvatarAction"
@ -92,16 +77,6 @@
{{ $t('settings.user_popover_avatar_overlay') }} {{ $t('settings.user_popover_avatar_overlay') }}
</BooleanSetting> </BooleanSetting>
</li> </li>
<li>
<ChoiceSetting
v-if="user"
id="thirdColumnMode"
path="thirdColumnMode"
:options="thirdColumnModeOptions"
>
{{ $t('settings.third_column_mode') }}
</ChoiceSetting>
</li>
<li> <li>
<BooleanSetting <BooleanSetting
path="alwaysShowNewPostButton" path="alwaysShowNewPostButton"
@ -126,6 +101,58 @@
{{ $t('settings.hide_shoutbox') }} {{ $t('settings.hide_shoutbox') }}
</BooleanSetting> </BooleanSetting>
</li> </li>
<li>
<BooleanSetting path="listsNavigation">
{{ $t('settings.lists_navigation') }}
</BooleanSetting>
</li>
<li>
<h3>{{ $t('settings.columns') }}</h3>
</li>
<li>
<BooleanSetting path="disableStickyHeaders">
{{ $t('settings.disable_sticky_headers') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="showScrollbars">
{{ $t('settings.show_scrollbars') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="sidebarRight">
{{ $t('settings.right_sidebar') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="navbarColumnStretch">
{{ $t('settings.navbar_column_stretch') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
v-if="user"
id="thirdColumnMode"
path="thirdColumnMode"
:options="thirdColumnModeOptions"
>
{{ $t('settings.third_column_mode') }}
</ChoiceSetting>
</li>
<li v-if="expertLevel > 0">
{{ $t('settings.column_sizes') }}
<div class="column-settings">
<SizeSetting
v-for="column in columns"
:key="column"
:path="column + 'ColumnWidth'"
:units="horizontalUnits"
expert="1"
>
{{ $t('settings.column_sizes_' + column) }}
</SizeSetting>
</div>
</li>
</ul> </ul>
</div> </div>
<div class="setting-item"> <div class="setting-item">
@ -435,3 +462,16 @@
</template> </template>
<script src="./general_tab.js"></script> <script src="./general_tab.js"></script>
<style lang="scss">
.column-settings {
display: flex;
justify-content: space-evenly;
flex-wrap: wrap;
}
.column-settings .size-label {
display: block;
margin-bottom: 0.5em;
margin-top: 0.5em;
}
</style>

View File

@ -14,7 +14,8 @@ import {
faSearch, faSearch,
faTachometerAlt, faTachometerAlt,
faCog, faCog,
faInfoCircle faInfoCircle,
faList
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
@ -28,7 +29,8 @@ library.add(
faSearch, faSearch,
faTachometerAlt, faTachometerAlt,
faCog, faCog,
faInfoCircle faInfoCircle,
faList
) )
const SideDrawer = { const SideDrawer = {

View File

@ -55,6 +55,18 @@
/> {{ $t("nav.timelines") }} /> {{ $t("nav.timelines") }}
</router-link> </router-link>
</li> </li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link :to="{ name: 'lists' }">
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="list"
/> {{ $t("nav.lists") }}
</router-link>
</li>
<li <li
v-if="currentUser && pleromaChatMessagesAvailable" v-if="currentUser && pleromaChatMessagesAvailable"
@click="toggleDrawer" @click="toggleDrawer"

View File

@ -19,6 +19,7 @@ const Timeline = {
'timelineName', 'timelineName',
'title', 'title',
'userId', 'userId',
'listId',
'tag', 'tag',
'embedded', 'embedded',
'count', 'count',
@ -103,6 +104,7 @@ const Timeline = {
timeline: this.timelineName, timeline: this.timelineName,
showImmediately, showImmediately,
userId: this.userId, userId: this.userId,
listId: this.listId,
tag: this.tag tag: this.tag
}) })
}, },
@ -158,6 +160,7 @@ const Timeline = {
older: true, older: true,
showImmediately: true, showImmediately: true,
userId: this.userId, userId: this.userId,
listId: this.listId,
tag: this.tag tag: this.tag
}).then(({ statuses }) => { }).then(({ statuses }) => {
if (statuses && statuses.length === 0) { if (statuses && statuses.length === 0) {

View File

@ -58,6 +58,9 @@ const TimelineMenu = {
if (route === 'tag-timeline') { if (route === 'tag-timeline') {
return '#' + this.$route.params.tag return '#' + this.$route.params.tag
} }
if (route === 'lists-timeline') {
return this.$store.getters.findListTitle(this.$route.params.id)
}
const i18nkey = timelineNames()[this.$route.name] const i18nkey = timelineNames()[this.$route.name]
return i18nkey ? this.$t(i18nkey) : route return i18nkey ? this.$t(i18nkey) : route
} }

View File

@ -0,0 +1,66 @@
import Modal from 'src/components/modal/modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import pleromaTan from 'src/assets/pleromatan_apology.png'
import pleromaTanFox from 'src/assets/pleromatan_apology_fox.png'
import {
faTimes
} from '@fortawesome/free-solid-svg-icons'
library.add(
faTimes
)
export const CURRENT_UPDATE_COUNTER = 1
const UpdateNotification = {
data () {
return {
pleromaTanVariant: Math.random() > 0.5 ? pleromaTan : pleromaTanFox,
showingMore: false,
contentHeight: 0
}
},
components: {
Modal
},
computed: {
pleromaTanStyles () {
return {
'shape-outside': 'url(' + this.pleromaTanVariant + ')'
}
},
dynamicStyles () {
return {
'--____extraInfoGroupHeight': this.contentHeight + 'px'
}
},
shouldShow () {
return !this.$store.state.instance.disableUpdateNotification &&
this.$store.state.users.currentUser &&
this.$store.state.serverSideStorage.flagStorage.updateCounter < CURRENT_UPDATE_COUNTER &&
!this.$store.state.serverSideStorage.flagStorage.dontShowUpdateNotifs
}
},
methods: {
toggleShow () {
this.showingMore = !this.showingMore
},
neverShowAgain () {
this.toggleShow()
this.$store.commit('setFlag', { flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER })
this.$store.commit('setFlag', { flag: 'dontShowUpdateNotifs', value: 1 })
this.$store.dispatch('pushServerSideStorage')
},
dismiss () {
this.$store.commit('setFlag', { flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER })
this.$store.dispatch('pushServerSideStorage')
}
},
mounted () {
setTimeout(() => {
this.contentHeight = this.$refs.animatedText.scrollHeight
}, 1000)
}
}
export default UpdateNotification

View File

@ -0,0 +1,107 @@
@import 'src/_variables.scss';
.UpdateNotification {
overflow: hidden;
}
.UpdateNotificationModal {
--__top-fringe: 15em; // how much pleroma-tan should stick her head above
--__bottom-fringe: 80em; // just reserving as much as we can, number is mostly irrelevant
--__right-fringe: 8em;
font-size: 15px;
position: relative;
transition: transform;
transition-timing-function: ease-in-out;
transition-duration: 500ms;
.text {
max-width: 40em;
padding-left: 1em;
}
@media all and (max-width: 800px) {
/* For mobile, the modal takes 100% of the available screen.
This ensures the minimized modal is always 50px above the browser bottom bar regardless of whether or not it is visible.
*/
width: 100vw;
}
@media all and (max-height: 600px) {
display: none;
}
.content {
overflow: hidden;
margin-top: calc(-1 * var(--__top-fringe));
margin-bottom: calc(-1 * var(--__bottom-fringe));
margin-right: calc(-1 * var(--__right-fringe));
}
.panel-body {
border-width: 0 0 1px 0;
border-style: solid;
border-color: var(--border, $fallback--border);
}
.panel-footer {
z-index: 22;
position: relative;
border-width: 0;
grid-template-columns: auto;
}
.pleroma-tan {
object-fit: cover;
object-position: top;
transition: position, left, right, top, bottom, max-width, max-height;
transition-timing-function: ease-in-out;
transition-duration: 500ms;
width: 25em;
float: right;
z-index: 20;
position: relative;
shape-margin: 0.5em;
filter: drop-shadow(5px 5px 10px rgba(0,0,0,0.5));
pointer-events: none;
}
.spacer-top {
min-height: var(--__top-fringe);
}
.spacer-bottom {
min-height: var(--__bottom-fringe);
}
.extra-info-group {
transition: max-height, padding, height;
transition-timing-function: ease-in-out;
transition-duration: 500ms;
max-height: calc(var(--____extraInfoGroupHeight) + 1em); // include bottom padding
mask:
linear-gradient(to top, white, transparent) bottom/100% 2px no-repeat,
linear-gradient(to top, white, white);
}
.art-credit {
text-align: right;
}
&.-peek {
/* Explanation:
* 100vh - 100% = Distance between modal's top+bottom boundaries and screen
* (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen
*/
transform: translateY(calc(((100vh - 100%) / 2)));
.pleroma-tan {
float: right;
z-index: 10;
shape-image-threshold: 0.7;
}
.extra-info-group {
max-height: 0;
}
}
}

View File

@ -0,0 +1,100 @@
<template>
<Modal
:is-open="!!shouldShow"
class="UpdateNotification"
:no-background="true"
>
<div
class="UpdateNotificationModal panel"
:class="{ '-peek': !showingMore }"
:style="dynamicStyles"
>
<div class="panel-heading">
<span class="title">
{{ $t('update.big_update_title') }}
</span>
</div>
<div class="panel-body">
<div class="content">
<img
class="pleroma-tan"
:src="pleromaTanVariant"
:style="pleromaTanStyles"
>
<div class="spacer-top" />
<div class="text">
<p>
{{ $t('update.big_update_content') }}
</p>
<div
ref="animatedText"
class="extra-info-group"
>
<i18n-t
keypath="update.update_bugs"
tag="p"
>
<template #pleromaGitlab>
<a
target="_blank"
href="https://git.pleroma.social/"
>{{ $t('update.update_bugs_gitlab') }}</a>
</template>
</i18n-t>
<i18n-t
keypath="update.update_changelog"
tag="p"
>
<template #theFullChangelog>
<a
target="_blank"
href="https://pleroma.social/announcements/"
>{{ $t('update.update_changelog_here') }}</a>
</template>
</i18n-t>
<p class="art-credit">
<i18n-t
keypath="update.art_by"
tag="small"
>
<template #linkToArtist>
<a
target="_blank"
href="https://post.ebin.club/pipivovott"
>pipivovott</a>
</template>
</i18n-t>
</p>
</div>
</div>
<div class="spacer-bottom" />
</div>
</div>
<div class="panel-footer">
<button
class="button-default"
@click.prevent="neverShowAgain"
>
{{ $t("general.never_show_again") }}
</button>
<button
v-if="!showingMore"
class="button-default"
@click.prevent="toggleShow"
>
{{ $t("general.show_more") }}
</button>
<button
class="button-default"
@click.prevent="dismiss"
>
{{ $t("general.dismiss") }}
</button>
</div>
</div>
</Modal>
</template>
<script src="./update_notification.js"></script>
<style src="./update_notification.scss" lang="scss"></style>

View File

@ -45,7 +45,7 @@ const UserProfile = {
}, },
created () { created () {
const routeParams = this.$route.params const routeParams = this.$route.params
this.load(routeParams.name || routeParams.id) this.load({ name: routeParams.name, id: routeParams.id })
this.tab = get(this.$route, 'query.tab', defaultTabKey) this.tab = get(this.$route, 'query.tab', defaultTabKey)
}, },
unmounted () { unmounted () {
@ -106,12 +106,17 @@ const UserProfile = {
this.userId = null this.userId = null
this.error = false this.error = false
const maybeId = userNameOrId.id
const maybeName = userNameOrId.name
// Check if user data is already loaded in store // Check if user data is already loaded in store
const user = this.$store.getters.findUser(userNameOrId) const user = maybeId ? this.$store.getters.findUser(maybeId) : this.$store.getters.findUserByName(maybeName)
if (user) { if (user) {
loadById(user.id) loadById(user.id)
} else { } else {
this.$store.dispatch('fetchUser', userNameOrId) (maybeId
? this.$store.dispatch('fetchUser', maybeId)
: this.$store.dispatch('fetchUserByName', maybeName))
.then(({ id }) => loadById(id)) .then(({ id }) => loadById(id))
.catch((reason) => { .catch((reason) => {
const errorMessage = get(reason, 'error.error') const errorMessage = get(reason, 'error.error')
@ -150,12 +155,12 @@ const UserProfile = {
watch: { watch: {
'$route.params.id': function (newVal) { '$route.params.id': function (newVal) {
if (newVal) { if (newVal) {
this.switchUser(newVal) this.switchUser({ id: newVal })
} }
}, },
'$route.params.name': function (newVal) { '$route.params.name': function (newVal) {
if (newVal) { if (newVal) {
this.switchUser(newVal) this.switchUser({ name: newVal })
} }
}, },
'$route.query': function (newVal) { '$route.query': function (newVal) {

View File

@ -1,4 +1,3 @@
import Status from '../status/status.vue' import Status from '../status/status.vue'
import List from '../list/list.vue' import List from '../list/list.vue'
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from '../checkbox/checkbox.vue'
@ -21,14 +20,17 @@ const UserReportingModal = {
} }
}, },
computed: { computed: {
reportModal () {
return this.$store.state.reports.reportModal
},
isLoggedIn () { isLoggedIn () {
return !!this.$store.state.users.currentUser return !!this.$store.state.users.currentUser
}, },
isOpen () { isOpen () {
return this.isLoggedIn && this.$store.state.reports.modalActivated return this.isLoggedIn && this.reportModal.activated
}, },
userId () { userId () {
return this.$store.state.reports.userId return this.reportModal.userId
}, },
user () { user () {
return this.$store.getters.findUser(this.userId) return this.$store.getters.findUser(this.userId)
@ -37,10 +39,10 @@ const UserReportingModal = {
return !this.user.is_local && this.user.screen_name.substr(this.user.screen_name.indexOf('@') + 1) return !this.user.is_local && this.user.screen_name.substr(this.user.screen_name.indexOf('@') + 1)
}, },
statuses () { statuses () {
return this.$store.state.reports.statuses return this.reportModal.statuses
}, },
preTickedIds () { preTickedIds () {
return this.$store.state.reports.preTickedIds return this.reportModal.preTickedIds
} }
}, },
watch: { watch: {

View File

@ -66,11 +66,13 @@
"more": "More", "more": "More",
"loading": "Loading…", "loading": "Loading…",
"generic_error": "An error occured", "generic_error": "An error occured",
"generic_error_message": "An error occured: {0}",
"error_retry": "Please try again", "error_retry": "Please try again",
"retry": "Try again", "retry": "Try again",
"optional": "optional", "optional": "optional",
"show_more": "Show more", "show_more": "Show more",
"show_less": "Show less", "show_less": "Show less",
"never_show_again": "Never show again",
"dismiss": "Dismiss", "dismiss": "Dismiss",
"cancel": "Cancel", "cancel": "Cancel",
"disable": "Disable", "disable": "Disable",
@ -146,7 +148,8 @@
"who_to_follow": "Who to follow", "who_to_follow": "Who to follow",
"preferences": "Preferences", "preferences": "Preferences",
"timelines": "Timelines", "timelines": "Timelines",
"chats": "Chats" "chats": "Chats",
"lists": "Lists"
}, },
"notifications": { "notifications": {
"broken_favorite": "Unknown status, searching for it…", "broken_favorite": "Unknown status, searching for it…",
@ -161,6 +164,7 @@
"no_more_notifications": "No more notifications", "no_more_notifications": "No more notifications",
"migrated_to": "migrated to", "migrated_to": "migrated to",
"reacted_with": "reacted with {0}", "reacted_with": "reacted with {0}",
"submitted_report": "submitted a report",
"poll_ended": "poll has ended" "poll_ended": "poll has ended"
}, },
"polls": { "polls": {
@ -196,6 +200,8 @@
"interactions": { "interactions": {
"favs_repeats": "Repeats and favorites", "favs_repeats": "Repeats and favorites",
"follows": "New follows", "follows": "New follows",
"emoji_reactions": "Emoji Reactions",
"reports": "Reports",
"moves": "User migrates", "moves": "User migrates",
"load_older": "Load older interactions" "load_older": "Load older interactions"
}, },
@ -264,6 +270,16 @@
"searching_for": "Searching for", "searching_for": "Searching for",
"error": "Not found." "error": "Not found."
}, },
"report": {
"reporter": "Reporter:",
"reported_user": "Reported user:",
"reported_statuses": "Reported statuses:",
"notes": "Notes:",
"state": "State:",
"state_open": "Open",
"state_closed": "Closed",
"state_resolved": "Resolved"
},
"selectable_list": { "selectable_list": {
"select_all": "Select all" "select_all": "Select all"
}, },
@ -298,6 +314,7 @@
"desc": "To enable two-factor authentication, enter the code from your two-factor app:" "desc": "To enable two-factor authentication, enter the code from your two-factor app:"
} }
}, },
"lists_navigation": "Show lists in navigation",
"allow_following_move": "Allow auto-follow when following account moves", "allow_following_move": "Allow auto-follow when following account moves",
"attachmentRadius": "Attachments", "attachmentRadius": "Attachments",
"attachments": "Attachments", "attachments": "Attachments",
@ -395,6 +412,7 @@
"hide_isp": "Hide instance-specific panel", "hide_isp": "Hide instance-specific panel",
"hide_shoutbox": "Hide instance shoutbox", "hide_shoutbox": "Hide instance shoutbox",
"right_sidebar": "Reverse order of columns", "right_sidebar": "Reverse order of columns",
"navbar_column_stretch": "Stretch navbar to columns width",
"always_show_post_button": "Always show floating New Post button", "always_show_post_button": "Always show floating New Post button",
"hide_wallpaper": "Hide instance wallpaper", "hide_wallpaper": "Hide instance wallpaper",
"preload_images": "Preload images", "preload_images": "Preload images",
@ -516,6 +534,11 @@
"third_column_mode_none": "Don't show third column at all", "third_column_mode_none": "Don't show third column at all",
"third_column_mode_notifications": "Notifications column", "third_column_mode_notifications": "Notifications column",
"third_column_mode_postform": "Main post form and navigation", "third_column_mode_postform": "Main post form and navigation",
"columns": "Columns",
"column_sizes": "Column sizes",
"column_sizes_sidebar": "Sidebar",
"column_sizes_content": "Content",
"column_sizes_notifs": "Notifications",
"tree_advanced": "Allow more flexible navigation in tree view", "tree_advanced": "Allow more flexible navigation in tree view",
"tree_fade_ancestors": "Display ancestors of the current status in faint text", "tree_fade_ancestors": "Display ancestors of the current status in faint text",
"conversation_display_linear": "Linear-style", "conversation_display_linear": "Linear-style",
@ -956,6 +979,16 @@
"error_sending_message": "Something went wrong when sending the message.", "error_sending_message": "Something went wrong when sending the message.",
"empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!" "empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!"
}, },
"lists": {
"lists": "Lists",
"new": "New List",
"title": "List title",
"search": "Search users",
"create": "Create",
"save": "Save changes",
"delete": "Delete list",
"following_only": "Limit to Following"
},
"file_type": { "file_type": {
"audio": "Audio", "audio": "Audio",
"video": "Video", "video": "Video",
@ -964,5 +997,14 @@
}, },
"display_date": { "display_date": {
"today": "Today" "today": "Today"
},
"update": {
"big_update_title": "Please bear with us",
"big_update_content": "We haven't had a release in a while, so things might look and feel different than what you're used to.",
"update_bugs": "Please report any issues and bugs on {pleromaGitlab}, as we have changed a lot, and although we test thoroughly and use development versions ourselves, we may have missed some things. We welcome your feedback and suggestions on issues you might encounter, or how to improve Pleroma and Pleroma-FE.",
"update_bugs_gitlab": "Pleroma GitLab",
"update_changelog": "For more details on what's changed, see {theFullChangelog}.",
"update_changelog_here": "the full changelog",
"art_by": "Art by {linkToArtist}"
} }
} }

View File

@ -744,6 +744,8 @@
"favs_repeats": "Herhalingen en favorieten", "favs_repeats": "Herhalingen en favorieten",
"follows": "Nieuwe gevolgden", "follows": "Nieuwe gevolgden",
"moves": "Gebruikermigraties", "moves": "Gebruikermigraties",
"emoji_reactions": "Emoji Reacties",
"reports": "Rapportages",
"load_older": "Oudere interacties laden" "load_older": "Oudere interacties laden"
}, },
"remote_user_resolver": { "remote_user_resolver": {
@ -751,6 +753,17 @@
"error": "Niet gevonden.", "error": "Niet gevonden.",
"remote_user_resolver": "Externe gebruikers-zoeker" "remote_user_resolver": "Externe gebruikers-zoeker"
}, },
"report": {
"reporter": "Reporteerder:",
"reported_user": "Gerapporteerde gebruiker:",
"reported_statuses": "Gerapporteerde statussen:",
"notes": "Notas:",
"state": "Status:",
"state_open": "Open",
"state_closed": "Gesloten",
"state_resolved": "Opgelost"
},
"selectable_list": { "selectable_list": {
"select_all": "Alles selecteren" "select_all": "Alles selecteren"
}, },

View File

@ -456,6 +456,15 @@
"subject_line_mastodon": "Как в Mastodon: скопировать как есть", "subject_line_mastodon": "Как в Mastodon: скопировать как есть",
"subject_line_email": "Как в электронной почте: \"re: тема\"", "subject_line_email": "Как в электронной почте: \"re: тема\"",
"subject_line_behavior": "Копировать тему в ответах", "subject_line_behavior": "Копировать тему в ответах",
"third_column_mode": "Когда недостаточно места, показывать третью колонку содержащую",
"third_column_mode_none": "Не показывать третью колонку совсем",
"third_column_mode_notifications": "Колонку уведомлений",
"third_column_mode_postform": "Форму отправки сообщения и навигацию",
"columns": "Колонки",
"column_sizes": "Размеры колонок",
"column_sizes_sidebar": "Боковой",
"column_sizes_content": "Содержимого",
"column_sizes_notifs": "Уведомлений",
"no_mutes": "Нет игнорируемых", "no_mutes": "Нет игнорируемых",
"no_blocks": "Нет блокировок", "no_blocks": "Нет блокировок",
"notification_visibility_emoji_reactions": "Реакции", "notification_visibility_emoji_reactions": "Реакции",

View File

@ -17,6 +17,7 @@ const saveImmedeatelyActions = [
'markNotificationsAsSeen', 'markNotificationsAsSeen',
'clearCurrentUser', 'clearCurrentUser',
'setCurrentUser', 'setCurrentUser',
'setServerSideStorage',
'setHighlight', 'setHighlight',
'setOption', 'setOption',
'setClientData', 'setClientData',

View File

@ -6,10 +6,12 @@ import './lib/event_target_polyfill.js'
import interfaceModule from './modules/interface.js' import interfaceModule from './modules/interface.js'
import instanceModule from './modules/instance.js' import instanceModule from './modules/instance.js'
import statusesModule from './modules/statuses.js' import statusesModule from './modules/statuses.js'
import listsModule from './modules/lists.js'
import usersModule from './modules/users.js' import usersModule from './modules/users.js'
import apiModule from './modules/api.js' import apiModule from './modules/api.js'
import configModule from './modules/config.js' import configModule from './modules/config.js'
import serverSideConfigModule from './modules/serverSideConfig.js' import serverSideConfigModule from './modules/serverSideConfig.js'
import serverSideStorageModule from './modules/serverSideStorage.js'
import shoutModule from './modules/shout.js' import shoutModule from './modules/shout.js'
import oauthModule from './modules/oauth.js' import oauthModule from './modules/oauth.js'
import authFlowModule from './modules/auth_flow.js' import authFlowModule from './modules/auth_flow.js'
@ -42,6 +44,7 @@ messages.setLanguage(i18n, currentLocale)
const persistedStateOptions = { const persistedStateOptions = {
paths: [ paths: [
'serverSideStorage.cache',
'config', 'config',
'users.lastLoginName', 'users.lastLoginName',
'oauth' 'oauth'
@ -70,9 +73,11 @@ const persistedStateOptions = {
// TODO refactor users/statuses modules, they depend on each other // TODO refactor users/statuses modules, they depend on each other
users: usersModule, users: usersModule,
statuses: statusesModule, statuses: statusesModule,
lists: listsModule,
api: apiModule, api: apiModule,
config: configModule, config: configModule,
serverSideConfig: serverSideConfigModule, serverSideConfig: serverSideConfigModule,
serverSideStorage: serverSideStorageModule,
shout: shoutModule, shout: shoutModule,
oauth: oauthModule, oauth: oauthModule,
authFlow: authFlowModule, authFlow: authFlowModule,

View File

@ -191,12 +191,13 @@ const api = {
startFetchingTimeline (store, { startFetchingTimeline (store, {
timeline = 'friends', timeline = 'friends',
tag = false, tag = false,
userId = false userId = false,
listId = false
}) { }) {
if (store.state.fetchers[timeline]) return if (store.state.fetchers[timeline]) return
const fetcher = store.state.backendInteractor.startFetchingTimeline({ const fetcher = store.state.backendInteractor.startFetchingTimeline({
timeline, store, userId, tag timeline, store, userId, listId, tag
}) })
store.commit('addFetcher', { fetcherName: timeline, fetcher }) store.commit('addFetcher', { fetcherName: timeline, fetcher })
}, },
@ -248,6 +249,18 @@ const api = {
store.commit('setFollowRequests', requests) store.commit('setFollowRequests', requests)
}, },
// Lists
startFetchingLists (store) {
if (store.state.fetchers.lists) return
const fetcher = store.state.backendInteractor.startFetchingLists({ store })
store.commit('addFetcher', { fetcherName: 'lists', fetcher })
},
stopFetchingLists (store) {
const fetcher = store.state.fetchers.lists
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'lists', fetcher })
},
// Pleroma websocket // Pleroma websocket
setWsToken (store, token) { setWsToken (store, token) {
store.commit('setWsToken', token) store.commit('setWsToken', token)

View File

@ -1,5 +1,5 @@
import Cookies from 'js-cookie' import Cookies from 'js-cookie'
import { setPreset, applyTheme } from '../services/style_setter/style_setter.js' import { setPreset, applyTheme, applyConfig } from '../services/style_setter/style_setter.js'
import messages from '../i18n/messages' import messages from '../i18n/messages'
import localeService from '../services/locale/locale.service.js' import localeService from '../services/locale/locale.service.js'
@ -60,6 +60,7 @@ export const defaultState = {
moves: true, moves: true,
emojiReactions: true, emojiReactions: true,
followRequest: true, followRequest: true,
reports: true,
chatMention: true, chatMention: true,
polls: true polls: true
}, },
@ -84,6 +85,11 @@ export const defaultState = {
showScrollbars: false, showScrollbars: false,
userPopoverAvatarAction: 'close', userPopoverAvatarAction: 'close',
userPopoverOverlay: true, userPopoverOverlay: true,
sidebarColumnWidth: '25rem',
contentColumnWidth: '45rem',
notifsColumnWidth: '25rem',
navbarColumnStretch: false,
listsNavigation: false,
greentext: undefined, // instance default greentext: undefined, // instance default
useAtIcon: undefined, // instance default useAtIcon: undefined, // instance default
mentionLinkDisplay: undefined, // instance default mentionLinkDisplay: undefined, // instance default
@ -161,12 +167,17 @@ const config = {
setHighlight ({ commit, dispatch }, { user, color, type }) { setHighlight ({ commit, dispatch }, { user, color, type }) {
commit('setHighlight', { user, color, type }) commit('setHighlight', { user, color, type })
}, },
setOption ({ commit, dispatch }, { name, value }) { setOption ({ commit, dispatch, state }, { name, value }) {
commit('setOption', { name, value }) commit('setOption', { name, value })
switch (name) { switch (name) {
case 'theme': case 'theme':
setPreset(value) setPreset(value)
break break
case 'sidebarColumnWidth':
case 'contentColumnWidth':
case 'notifsColumnWidth':
applyConfig(state)
break
case 'customTheme': case 'customTheme':
case 'customThemeSource': case 'customThemeSource':
applyTheme(value) applyTheme(value)

View File

@ -41,6 +41,7 @@ const defaultState = {
logoMargin: '.2em', logoMargin: '.2em',
logoMask: true, logoMask: true,
logoLeft: false, logoLeft: false,
disableUpdateNotification: false,
minimalScopesMode: false, minimalScopesMode: false,
nsfwCensorImage: undefined, nsfwCensorImage: undefined,
postContentType: 'text/plain', postContentType: 'text/plain',

94
src/modules/lists.js Normal file
View File

@ -0,0 +1,94 @@
import { remove, find } from 'lodash'
export const defaultState = {
allLists: [],
allListsObject: {}
}
export const mutations = {
setLists (state, value) {
state.allLists = value
},
setList (state, { id, title }) {
if (!state.allListsObject[id]) {
state.allListsObject[id] = {}
}
state.allListsObject[id].title = title
if (!find(state.allLists, { id })) {
state.allLists.push({ id, title })
} else {
find(state.allLists, { id }).title = title
}
},
setListAccounts (state, { id, accountIds }) {
if (!state.allListsObject[id]) {
state.allListsObject[id] = {}
}
state.allListsObject[id].accountIds = accountIds
},
deleteList (state, { id }) {
delete state.allListsObject[id]
remove(state.allLists, list => list.id === id)
}
}
const actions = {
setLists ({ commit }, value) {
commit('setLists', value)
},
createList ({ rootState, commit }, { title }) {
return rootState.api.backendInteractor.createList({ title })
.then((list) => {
commit('setList', { id: list.id, title })
return list
})
},
fetchList ({ rootState, commit }, { id }) {
return rootState.api.backendInteractor.getList({ id })
.then((list) => commit('setList', { id: list.id, title: list.title }))
},
fetchListAccounts ({ rootState, commit }, { id }) {
return rootState.api.backendInteractor.getListAccounts({ id })
.then((accountIds) => commit('setListAccounts', { id, accountIds }))
},
setList ({ rootState, commit }, { id, title }) {
rootState.api.backendInteractor.updateList({ id, title })
commit('setList', { id, title })
},
setListAccounts ({ rootState, commit }, { id, accountIds }) {
const saved = rootState.lists.allListsObject[id].accountIds || []
const added = accountIds.filter(id => !saved.includes(id))
const removed = saved.filter(id => !accountIds.includes(id))
commit('setListAccounts', { id, accountIds })
if (added.length > 0) {
rootState.api.backendInteractor.addAccountsToList({ id, accountIds: added })
}
if (removed.length > 0) {
rootState.api.backendInteractor.removeAccountsFromList({ id, accountIds: removed })
}
},
deleteList ({ rootState, commit }, { id }) {
rootState.api.backendInteractor.deleteList({ id })
commit('deleteList', { id })
}
}
export const getters = {
findListTitle: state => id => {
if (!state.allListsObject[id]) return
return state.allListsObject[id].title
},
findListAccounts: state => id => {
return [...state.allListsObject[id].accountIds]
}
}
const lists = {
state: defaultState,
mutations,
actions,
getters
}
export default lists

View File

@ -2,20 +2,29 @@ import filter from 'lodash/filter'
const reports = { const reports = {
state: { state: {
reportModal: {
userId: null, userId: null,
statuses: [], statuses: [],
preTickedIds: [], preTickedIds: [],
modalActivated: false activated: false
},
reports: {}
}, },
mutations: { mutations: {
openUserReportingModal (state, { userId, statuses, preTickedIds }) { openUserReportingModal (state, { userId, statuses, preTickedIds }) {
state.userId = userId state.reportModal.userId = userId
state.statuses = statuses state.reportModal.statuses = statuses
state.preTickedIds = preTickedIds state.reportModal.preTickedIds = preTickedIds
state.modalActivated = true state.reportModal.activated = true
}, },
closeUserReportingModal (state) { closeUserReportingModal (state) {
state.modalActivated = false state.reportModal.activated = false
},
setReportState (reportsState, { id, state }) {
reportsState.reports[id].state = state
},
addReport (state, report) {
state.reports[report.id] = report
} }
}, },
actions: { actions: {
@ -31,6 +40,23 @@ const reports = {
}, },
closeUserReportingModal ({ commit }) { closeUserReportingModal ({ commit }) {
commit('closeUserReportingModal') commit('closeUserReportingModal')
},
setReportState ({ commit, dispatch, rootState }, { id, state }) {
const oldState = rootState.reports.reports[id].state
commit('setReportState', { id, state })
rootState.api.backendInteractor.setReportState({ id, state }).catch(e => {
console.error('Failed to set report state', e)
dispatch('pushGlobalNotice', {
level: 'error',
messageKey: 'general.generic_error_message',
messageArgs: [e.message],
timeout: 5000
})
commit('setReportState', { id, state: oldState })
})
},
addReport ({ commit }, report) {
commit('addReport', report)
} }
} }
} }

View File

@ -0,0 +1,230 @@
import { toRaw } from 'vue'
import { isEqual, cloneDeep } from 'lodash'
import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js'
export const VERSION = 1
export const NEW_USER_DATE = new Date('2022-08-04') // date of writing this, basically
export const COMMAND_TRIM_FLAGS = 1000
export const COMMAND_TRIM_FLAGS_AND_RESET = 1001
export const defaultState = {
// do we need to update data on server?
dirty: false,
// storage of flags - stuff that can only be set and incremented
flagStorage: {
updateCounter: 0, // Counter for most recent update notification seen
// TODO move to prefsStorage when that becomes a thing since only way
// this can be reset is by complete reset of all flags
dontShowUpdateNotifs: 0, // if user chose to not show update notifications ever again
reset: 0 // special flag that can be used to force-reset all flags, debug purposes only
// special reset codes:
// 1000: trim keys to those known by currently running FE
// 1001: same as above + reset everything to 0
},
// raw data
raw: null,
// local cache
cache: null
}
export const newUserFlags = {
...defaultState.flagStorage,
updateCounter: CURRENT_UPDATE_COUNTER // new users don't need to see update notification
}
const _wrapData = (data) => ({
...data,
_timestamp: Date.now(),
_version: VERSION
})
const _checkValidity = (data) => data._timestamp > 0 && data._version > 0
export const _getRecentData = (cache, live) => {
const result = { recent: null, stale: null, needUpload: false }
const cacheValid = _checkValidity(cache || {})
const liveValid = _checkValidity(live || {})
if (!liveValid && cacheValid) {
result.needUpload = true
console.debug('Nothing valid stored on server, assuming cache to be source of truth')
result.recent = cache
result.stale = live
} else if (!cacheValid && liveValid) {
console.debug('Valid storage on server found, no local cache found, using live as source of truth')
result.recent = live
result.stale = cache
} else if (cacheValid && liveValid) {
console.debug('Both sources have valid data, figuring things out...')
if (live._timestamp === cache._timestamp && live._version === cache._version) {
console.debug('Same version/timestamp on both source, source of truth irrelevant')
result.recent = cache
result.stale = live
} else {
console.debug('Different timestamp, figuring out which one is more recent')
if (live._timestamp < cache._timestamp) {
result.recent = cache
result.stale = live
} else {
result.recent = live
result.stale = cache
}
}
} else {
console.debug('Both sources are invalid, start from scratch')
result.needUpload = true
}
return result
}
export const _getAllFlags = (recent, stale) => {
return Array.from(new Set([
...Object.keys(toRaw((recent || {}).flagStorage || {})),
...Object.keys(toRaw((stale || {}).flagStorage || {}))
]))
}
export const _mergeFlags = (recent, stale, allFlagKeys) => {
return Object.fromEntries(allFlagKeys.map(flag => {
const recentFlag = recent.flagStorage[flag]
const staleFlag = stale.flagStorage[flag]
// use flag that is of higher value
return [flag, Number((recentFlag > staleFlag ? recentFlag : staleFlag) || 0)]
}))
}
export const _resetFlags = (totalFlags, knownKeys = defaultState.flagStorage) => {
let result = { ...totalFlags }
const allFlagKeys = Object.keys(totalFlags)
// flag reset functionality
if (totalFlags.reset >= COMMAND_TRIM_FLAGS && totalFlags.reset <= COMMAND_TRIM_FLAGS_AND_RESET) {
console.debug('Received command to trim the flags')
const knownKeysSet = new Set(Object.keys(knownKeys))
// Trim
result = {}
allFlagKeys.forEach(flag => {
if (knownKeysSet.has(flag)) {
result[flag] = totalFlags[flag]
}
})
// Reset
if (totalFlags.reset === COMMAND_TRIM_FLAGS_AND_RESET) {
// 1001 - and reset everything to 0
console.debug('Received command to reset the flags')
Object.keys(knownKeys).forEach(flag => { result[flag] = 0 })
}
} else if (totalFlags.reset > 0 && totalFlags.reset < 9000) {
console.debug('Received command to reset the flags')
allFlagKeys.forEach(flag => { result[flag] = 0 })
}
result.reset = 0
return result
}
export const _doMigrations = (cache) => {
if (!cache) return cache
if (cache._version < VERSION) {
console.debug('Local cached data has older version, seeing if there any migrations that can be applied')
// no migrations right now since we only have one version
console.debug('No migrations found')
}
if (cache._version > VERSION) {
console.debug('Local cached data has newer version, seeing if there any reverse migrations that can be applied')
// no reverse migrations right now but we leave a possibility of loading a hotpatch if need be
if (window._PLEROMA_HOTPATCH) {
if (window._PLEROMA_HOTPATCH.reverseMigrations) {
console.debug('Found hotpatch migration, applying')
return window._PLEROMA_HOTPATCH.reverseMigrations.call({}, 'serverSideStorage', { from: cache._version, to: VERSION }, cache)
}
}
}
return cache
}
export const mutations = {
setServerSideStorage (state, userData) {
const live = userData.storage
state.raw = live
let cache = state.cache
cache = _doMigrations(cache)
let { recent, stale, needsUpload } = _getRecentData(cache, live)
const userNew = userData.created_at > NEW_USER_DATE
const flagsTemplate = userNew ? newUserFlags : defaultState.flagStorage
let dirty = false
if (recent === null) {
console.debug(`Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`)
recent = _wrapData({
flagStorage: { ...flagsTemplate }
})
}
if (!needsUpload && recent && stale) {
console.debug('Checking if data needs merging...')
// discarding timestamps and versions
const { _timestamp: _0, _version: _1, ...recentData } = recent
const { _timestamp: _2, _version: _3, ...staleData } = stale
dirty = !isEqual(recentData, staleData)
console.debug(`Data ${dirty ? 'needs' : 'doesn\'t need'} merging`)
}
const allFlagKeys = _getAllFlags(recent, stale)
let totalFlags
if (dirty) {
// Merge the flags
console.debug('Merging the flags...')
totalFlags = _mergeFlags(recent, stale, allFlagKeys)
} else {
totalFlags = recent.flagStorage
}
totalFlags = _resetFlags(totalFlags)
recent.flagStorage = totalFlags
state.dirty = dirty || needsUpload
state.cache = recent
// set local timestamp to smaller one if we don't have any changes
if (stale && recent && !state.dirty) {
state.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
}
state.flagStorage = state.cache.flagStorage
},
setFlag (state, { flag, value }) {
state.flagStorage[flag] = value
state.dirty = true
}
}
const serverSideStorage = {
state: {
...cloneDeep(defaultState)
},
mutations,
actions: {
pushServerSideStorage ({ state, rootState, commit }, { force = false } = {}) {
const needPush = state.dirty || force
if (!needPush) return
state.cache = _wrapData({
flagStorage: toRaw(state.flagStorage)
})
const params = { pleroma_settings_store: { 'pleroma-fe': state.cache } }
rootState.api.backendInteractor
.updateProfile({ params })
.then((user) => commit('setServerSideStorage', user))
state.dirty = false
}
}
}
export default serverSideStorage

View File

@ -62,7 +62,8 @@ export const defaultState = () => ({
friends: emptyTl(), friends: emptyTl(),
tag: emptyTl(), tag: emptyTl(),
dms: emptyTl(), dms: emptyTl(),
bookmarks: emptyTl() bookmarks: emptyTl(),
list: emptyTl()
} }
}) })
@ -336,6 +337,10 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
notification.status = notification.status && addStatusToGlobalStorage(state, notification.status).item notification.status = notification.status && addStatusToGlobalStorage(state, notification.status).item
} }
if (notification.type === 'pleroma:report') {
dispatch('addReport', notification.report)
}
if (notification.type === 'pleroma:emoji_reaction') { if (notification.type === 'pleroma:emoji_reaction') {
dispatch('fetchEmojiReactionsBy', notification.status.id) dispatch('fetchEmojiReactionsBy', notification.status.id)
} }

View File

@ -16,9 +16,6 @@ export const mergeOrAdd = (arr, obj, item) => {
// This is a new item, prepare it // This is a new item, prepare it
arr.push(item) arr.push(item)
obj[item.id] = item obj[item.id] = item
if (item.screen_name && !item.screen_name.includes('@')) {
obj[item.screen_name.toLowerCase()] = item
}
return { item, new: true } return { item, new: true }
} }
} }
@ -162,7 +159,11 @@ export const mutations = {
if (user.relationship) { if (user.relationship) {
state.relationships[user.relationship.id] = user.relationship state.relationships[user.relationship.id] = user.relationship
} }
mergeOrAdd(state.users, state.usersObject, user) const res = mergeOrAdd(state.users, state.usersObject, user)
const item = res.item
if (res.new && item.screen_name && !item.screen_name.includes('@')) {
state.usersByNameObject[item.screen_name.toLowerCase()] = item
}
}) })
}, },
updateUserRelationship (state, relationships) { updateUserRelationship (state, relationships) {
@ -239,12 +240,10 @@ export const mutations = {
export const getters = { export const getters = {
findUser: state => query => { findUser: state => query => {
const result = state.usersObject[query] return state.usersObject[query]
// In case it's a screen_name, we can try searching case-insensitive },
if (!result && typeof query === 'string') { findUserByName: state => query => {
return state.usersObject[query.toLowerCase()] return state.usersByNameObject[query.toLowerCase()]
}
return result
}, },
findUserByUrl: state => query => { findUserByUrl: state => query => {
return state.users return state.users
@ -263,6 +262,7 @@ export const defaultState = {
currentUser: false, currentUser: false,
users: [], users: [],
usersObject: {}, usersObject: {},
usersByNameObject: {},
signUpPending: false, signUpPending: false,
signUpErrors: [], signUpErrors: [],
relationships: {} relationships: {}
@ -285,6 +285,13 @@ const users = {
return user return user
}) })
}, },
fetchUserByName (store, name) {
return store.rootState.api.backendInteractor.fetchUserByName({ name })
.then((user) => {
store.commit('addNewUsers', [user])
return user
})
},
fetchUserRelationship (store, id) { fetchUserRelationship (store, id) {
if (store.state.currentUser) { if (store.state.currentUser) {
store.rootState.api.backendInteractor.fetchUserRelationship({ id }) store.rootState.api.backendInteractor.fetchUserRelationship({ id })
@ -525,6 +532,7 @@ const users = {
user.muteIds = [] user.muteIds = []
user.domainMutes = [] user.domainMutes = []
commit('setCurrentUser', user) commit('setCurrentUser', user)
commit('setServerSideStorage', user)
commit('addNewUsers', [user]) commit('addNewUsers', [user])
store.dispatch('fetchEmoji') store.dispatch('fetchEmoji')
@ -534,6 +542,7 @@ const users = {
// Set our new backend interactor // Set our new backend interactor
commit('setBackendInteractor', backendInteractorService(accessToken)) commit('setBackendInteractor', backendInteractorService(accessToken))
store.dispatch('pushServerSideStorage')
if (user.token) { if (user.token) {
store.dispatch('setWsToken', user.token) store.dispatch('setWsToken', user.token)

View File

@ -50,8 +50,12 @@ const MASTODON_USER_HOME_TIMELINE_URL = '/api/v1/timelines/home'
const MASTODON_STATUS_URL = id => `/api/v1/statuses/${id}` const MASTODON_STATUS_URL = id => `/api/v1/statuses/${id}`
const MASTODON_STATUS_CONTEXT_URL = id => `/api/v1/statuses/${id}/context` const MASTODON_STATUS_CONTEXT_URL = id => `/api/v1/statuses/${id}/context`
const MASTODON_USER_URL = '/api/v1/accounts' const MASTODON_USER_URL = '/api/v1/accounts'
const MASTODON_USER_LOOKUP_URL = '/api/v1/accounts/lookup'
const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships' const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses` const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
const MASTODON_LIST_URL = id => `/api/v1/lists/${id}`
const MASTODON_LIST_TIMELINE_URL = id => `/api/v1/timelines/list/${id}`
const MASTODON_LIST_ACCOUNTS_URL = id => `/api/v1/lists/${id}/accounts`
const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}` const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}`
const MASTODON_BOOKMARK_TIMELINE_URL = '/api/v1/bookmarks' const MASTODON_BOOKMARK_TIMELINE_URL = '/api/v1/bookmarks'
const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/' const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/'
@ -79,6 +83,7 @@ const MASTODON_UNMUTE_CONVERSATION = id => `/api/v1/statuses/${id}/unmute`
const MASTODON_SEARCH_2 = '/api/v2/search' const MASTODON_SEARCH_2 = '/api/v2/search'
const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search' const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search'
const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks' const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks'
const MASTODON_LISTS_URL = '/api/v1/lists'
const MASTODON_STREAMING = '/api/v1/streaming' const MASTODON_STREAMING = '/api/v1/streaming'
const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers' const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions` const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions`
@ -89,6 +94,7 @@ const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}`
const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages` const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages`
const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read` const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read`
const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}` const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
const PLEROMA_ADMIN_REPORTS = '/api/pleroma/admin/reports'
const PLEROMA_BACKUP_URL = '/api/v1/pleroma/backups' const PLEROMA_BACKUP_URL = '/api/v1/pleroma/backups'
const oldfetch = window.fetch const oldfetch = window.fetch
@ -313,6 +319,25 @@ const fetchUser = ({ id, credentials }) => {
.then((data) => parseUser(data)) .then((data) => parseUser(data))
} }
const fetchUserByName = ({ name, credentials }) => {
return promisedRequest({
url: MASTODON_USER_LOOKUP_URL,
credentials,
params: { acct: name }
})
.then(data => data.id)
.catch(error => {
if (error && error.statusCode === 404) {
// Either the backend does not support lookup endpoint,
// or there is no user with such name. Fallback and treat name as id.
return name
} else {
throw error
}
})
.then(id => fetchUser({ id, credentials }))
}
const fetchUserRelationship = ({ id, credentials }) => { const fetchUserRelationship = ({ id, credentials }) => {
const url = `${MASTODON_USER_RELATIONSHIPS_URL}/?id=${id}` const url = `${MASTODON_USER_RELATIONSHIPS_URL}/?id=${id}`
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
@ -385,6 +410,81 @@ const fetchFollowRequests = ({ credentials }) => {
.then((data) => data.map(parseUser)) .then((data) => data.map(parseUser))
} }
const fetchLists = ({ credentials }) => {
const url = MASTODON_LISTS_URL
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
}
const createList = ({ title, credentials }) => {
const url = MASTODON_LISTS_URL
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(url, {
headers,
method: 'POST',
body: JSON.stringify({ title })
}).then((data) => data.json())
}
const getList = ({ id, credentials }) => {
const url = MASTODON_LIST_URL(id)
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
}
const updateList = ({ id, title, credentials }) => {
const url = MASTODON_LIST_URL(id)
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(url, {
headers,
method: 'PUT',
body: JSON.stringify({ title })
})
}
const getListAccounts = ({ id, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(id)
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => data.map(({ id }) => id))
}
const addAccountsToList = ({ id, accountIds, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(id)
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(url, {
headers,
method: 'POST',
body: JSON.stringify({ account_ids: accountIds })
})
}
const removeAccountsFromList = ({ id, accountIds, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(id)
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(url, {
headers,
method: 'DELETE',
body: JSON.stringify({ account_ids: accountIds })
})
}
const deleteList = ({ id, credentials }) => {
const url = MASTODON_LIST_URL(id)
return fetch(url, {
method: 'DELETE',
headers: authHeaders(credentials)
})
}
const fetchConversation = ({ id, credentials }) => { const fetchConversation = ({ id, credentials }) => {
const urlContext = MASTODON_STATUS_CONTEXT_URL(id) const urlContext = MASTODON_STATUS_CONTEXT_URL(id)
return fetch(urlContext, { headers: authHeaders(credentials) }) return fetch(urlContext, { headers: authHeaders(credentials) })
@ -506,9 +606,11 @@ const fetchTimeline = ({
since = false, since = false,
until = false, until = false,
userId = false, userId = false,
listId = false,
tag = false, tag = false,
withMuted = false, withMuted = false,
replyVisibility = 'all' replyVisibility = 'all',
includeTypes = []
}) => { }) => {
const timelineUrls = { const timelineUrls = {
public: MASTODON_PUBLIC_TIMELINE, public: MASTODON_PUBLIC_TIMELINE,
@ -518,6 +620,7 @@ const fetchTimeline = ({
publicAndExternal: MASTODON_PUBLIC_TIMELINE, publicAndExternal: MASTODON_PUBLIC_TIMELINE,
user: MASTODON_USER_TIMELINE_URL, user: MASTODON_USER_TIMELINE_URL,
media: MASTODON_USER_TIMELINE_URL, media: MASTODON_USER_TIMELINE_URL,
list: MASTODON_LIST_TIMELINE_URL,
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL, favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
tag: MASTODON_TAG_TIMELINE_URL, tag: MASTODON_TAG_TIMELINE_URL,
bookmarks: MASTODON_BOOKMARK_TIMELINE_URL bookmarks: MASTODON_BOOKMARK_TIMELINE_URL
@ -531,6 +634,10 @@ const fetchTimeline = ({
url = url(userId) url = url(userId)
} }
if (timeline === 'list') {
url = url(listId)
}
if (since) { if (since) {
params.push(['since_id', since]) params.push(['since_id', since])
} }
@ -555,6 +662,11 @@ const fetchTimeline = ({
if (replyVisibility !== 'all') { if (replyVisibility !== 'all') {
params.push(['reply_visibility', replyVisibility]) params.push(['reply_visibility', replyVisibility])
} }
if (includeTypes.length > 0) {
includeTypes.forEach(type => {
params.push(['include_types[]', type])
})
}
params.push(['limit', 20]) params.push(['limit', 20])
@ -1339,6 +1451,38 @@ const deleteChatMessage = ({ chatId, messageId, credentials }) => {
}) })
} }
const setReportState = ({ id, state, credentials }) => {
// TODO: Can't use promisedRequest because on OK this does not return json
// See https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1322
return fetch(PLEROMA_ADMIN_REPORTS, {
headers: {
...authHeaders(credentials),
Accept: 'application/json',
'Content-Type': 'application/json'
},
method: 'PATCH',
body: JSON.stringify({
reports: [{
id,
state
}]
})
})
.then(data => {
if (data.status >= 500) {
throw Error(data.statusText)
} else if (data.status >= 400) {
return data.json()
}
return data
})
.then(data => {
if (data.errors) {
throw Error(data.errors[0].message)
}
})
}
const apiService = { const apiService = {
verifyCredentials, verifyCredentials,
fetchTimeline, fetchTimeline,
@ -1357,6 +1501,7 @@ const apiService = {
blockUser, blockUser,
unblockUser, unblockUser,
fetchUser, fetchUser,
fetchUserByName,
fetchUserRelationship, fetchUserRelationship,
favorite, favorite,
unfavorite, unfavorite,
@ -1405,6 +1550,14 @@ const apiService = {
addBackup, addBackup,
listBackups, listBackups,
fetchFollowRequests, fetchFollowRequests,
fetchLists,
createList,
getList,
updateList,
getListAccounts,
addAccountsToList,
removeAccountsFromList,
deleteList,
approveUser, approveUser,
denyUser, denyUser,
suggestions, suggestions,
@ -1430,7 +1583,8 @@ const apiService = {
chatMessages, chatMessages,
sendChatMessage, sendChatMessage,
readChat, readChat,
deleteChatMessage deleteChatMessage,
setReportState
} }
export default apiService export default apiService

View File

@ -2,10 +2,11 @@ import apiService, { getMastodonSocketURI, ProcessedWS } from '../api/api.servic
import timelineFetcher from '../timeline_fetcher/timeline_fetcher.service.js' import timelineFetcher from '../timeline_fetcher/timeline_fetcher.service.js'
import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js' import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js'
import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service' import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service'
import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js'
const backendInteractorService = credentials => ({ const backendInteractorService = credentials => ({
startFetchingTimeline ({ timeline, store, userId = false, tag }) { startFetchingTimeline ({ timeline, store, userId = false, listId = false, tag }) {
return timelineFetcher.startFetching({ timeline, store, credentials, userId, tag }) return timelineFetcher.startFetching({ timeline, store, credentials, userId, listId, tag })
}, },
fetchTimeline (args) { fetchTimeline (args) {
@ -24,6 +25,10 @@ const backendInteractorService = credentials => ({
return followRequestFetcher.startFetching({ store, credentials }) return followRequestFetcher.startFetching({ store, credentials })
}, },
startFetchingLists ({ store }) {
return listsFetcher.startFetching({ store, credentials })
},
startUserSocket ({ store }) { startUserSocket ({ store }) {
const serv = store.rootState.instance.server.replace('http', 'ws') const serv = store.rootState.instance.server.replace('http', 'ws')
const url = serv + getMastodonSocketURI({ credentials, stream: 'user' }) const url = serv + getMastodonSocketURI({ credentials, stream: 'user' })

View File

@ -90,6 +90,9 @@ export const parseUser = (data) => {
output.bot = data.bot output.bot = data.bot
if (data.pleroma) { if (data.pleroma) {
if (data.pleroma.settings_store) {
output.storage = data.pleroma.settings_store['pleroma-fe']
}
const relationship = data.pleroma.relationship const relationship = data.pleroma.relationship
output.background_image = data.pleroma.background_image output.background_image = data.pleroma.background_image
@ -387,6 +390,13 @@ export const parseNotification = (data) => {
: parseUser(data.target) : parseUser(data.target)
output.from_profile = parseUser(data.account) output.from_profile = parseUser(data.account)
output.emoji = data.emoji output.emoji = data.emoji
if (data.report) {
output.report = data.report
output.report.content = data.report.content
output.report.acct = parseUser(data.report.account)
output.report.actor = parseUser(data.report.actor)
output.report.statuses = data.report.statuses.map(parseStatus)
}
} else { } else {
const parsedNotice = parseStatus(data.notice) const parsedNotice = parseStatus(data.notice)
output.type = data.ntype output.type = data.ntype

View File

@ -0,0 +1,22 @@
import apiService from '../api/api.service.js'
import { promiseInterval } from '../promise_interval/promise_interval.js'
const fetchAndUpdate = ({ store, credentials }) => {
return apiService.fetchLists({ credentials })
.then(lists => {
store.commit('setLists', lists)
}, () => {})
.catch(() => {})
}
const startFetching = ({ credentials, store }) => {
const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store })
boundFetchAndUpdate()
return promiseInterval(boundFetchAndUpdate, 240000)
}
const listsFetcher = {
startFetching
}
export default listsFetcher

View File

@ -15,6 +15,7 @@ export const visibleTypes = store => {
rootState.config.notificationVisibility.followRequest && 'follow_request', rootState.config.notificationVisibility.followRequest && 'follow_request',
rootState.config.notificationVisibility.moves && 'move', rootState.config.notificationVisibility.moves && 'move',
rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction', rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction',
rootState.config.notificationVisibility.reports && 'pleroma:report',
rootState.config.notificationVisibility.polls && 'poll' rootState.config.notificationVisibility.polls && 'poll'
].filter(_ => _)) ].filter(_ => _))
} }
@ -99,6 +100,9 @@ export const prepareNotificationObject = (notification, i18n) => {
case 'follow_request': case 'follow_request':
i18nString = 'follow_request' i18nString = 'follow_request'
break break
case 'pleroma:report':
i18nString = 'submitted_report'
break
case 'poll': case 'poll':
i18nString = 'poll_ended' i18nString = 'poll_ended'
break break

View File

@ -1,6 +1,18 @@
import apiService from '../api/api.service.js' import apiService from '../api/api.service.js'
import { promiseInterval } from '../promise_interval/promise_interval.js' import { promiseInterval } from '../promise_interval/promise_interval.js'
// For using include_types when fetching notifications.
// Note: chat_mention excluded as pleroma-fe polls them separately
const mastoApiNotificationTypes = [
'mention',
'favourite',
'reblog',
'follow',
'move',
'pleroma:emoji_reaction',
'pleroma:report'
]
const update = ({ store, notifications, older }) => { const update = ({ store, notifications, older }) => {
store.dispatch('addNewNotifications', { notifications, older }) store.dispatch('addNewNotifications', { notifications, older })
} }
@ -12,6 +24,7 @@ const fetchAndUpdate = ({ store, credentials, older = false, since }) => {
const timelineData = rootState.statuses.notifications const timelineData = rootState.statuses.notifications
const hideMutedPosts = getters.mergedConfig.hideMutedPosts const hideMutedPosts = getters.mergedConfig.hideMutedPosts
args.includeTypes = mastoApiNotificationTypes
args.withMuted = !hideMutedPosts args.withMuted = !hideMutedPosts
args.timeline = 'notifications' args.timeline = 'notifications'
@ -63,6 +76,7 @@ const fetchNotifications = ({ store, args, older }) => {
messageArgs: [error.message], messageArgs: [error.message],
timeout: 5000 timeout: 5000
}) })
console.error(error)
}) })
} }

View File

@ -1,4 +1,4 @@
import runtime from 'serviceworker-webpack-plugin/lib/runtime' import runtime from 'serviceworker-webpack5-plugin/lib/runtime'
function urlBase64ToUint8Array (base64String) { function urlBase64ToUint8Array (base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4) const padding = '='.repeat((4 - base64String.length % 4) % 4)

View File

@ -1,6 +1,7 @@
import { convert } from 'chromatism' import { convert } from 'chromatism'
import { rgb2hex, hex2rgb, rgba2css, getCssColor, relativeLuminance } from '../color_convert/color_convert.js' import { rgb2hex, hex2rgb, rgba2css, getCssColor, relativeLuminance } from '../color_convert/color_convert.js'
import { getColors, computeDynamicColor, getOpacitySlot } from '../theme_data/theme_data.service.js' import { getColors, computeDynamicColor, getOpacitySlot } from '../theme_data/theme_data.service.js'
import { defaultState } from '../../modules/config.js'
export const applyTheme = (input) => { export const applyTheme = (input) => {
const { rules } = generatePreset(input) const { rules } = generatePreset(input)
@ -20,6 +21,36 @@ export const applyTheme = (input) => {
body.classList.remove('hidden') body.classList.remove('hidden')
} }
const configColumns = ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth }) =>
({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth })
const defaultConfigColumns = configColumns(defaultState)
export const applyConfig = (config) => {
const columns = configColumns(config)
if (columns === defaultConfigColumns) {
return
}
const head = document.head
const body = document.body
body.classList.add('hidden')
const rules = Object
.entries(columns)
.filter(([k, v]) => v)
.map(([k, v]) => `--${k}: ${v}`).join(';')
const styleEl = document.createElement('style')
head.appendChild(styleEl)
const styleSheet = styleEl.sheet
styleSheet.toString()
styleSheet.insertRule(`:root { ${rules} }`, 'index-max')
body.classList.remove('hidden')
}
export const getCssShadow = (input, usesDropShadow) => { export const getCssShadow = (input, usesDropShadow) => {
if (input.length === 0) { if (input.length === 0) {
return 'none' return 'none'

View File

@ -3,12 +3,13 @@ import { camelCase } from 'lodash'
import apiService from '../api/api.service.js' import apiService from '../api/api.service.js'
import { promiseInterval } from '../promise_interval/promise_interval.js' import { promiseInterval } from '../promise_interval/promise_interval.js'
const update = ({ store, statuses, timeline, showImmediately, userId, pagination }) => { const update = ({ store, statuses, timeline, showImmediately, userId, listId, pagination }) => {
const ccTimeline = camelCase(timeline) const ccTimeline = camelCase(timeline)
store.dispatch('addNewStatuses', { store.dispatch('addNewStatuses', {
timeline: ccTimeline, timeline: ccTimeline,
userId, userId,
listId,
statuses, statuses,
showImmediately, showImmediately,
pagination pagination
@ -22,6 +23,7 @@ const fetchAndUpdate = ({
older = false, older = false,
showImmediately = false, showImmediately = false,
userId = false, userId = false,
listId = false,
tag = false, tag = false,
until, until,
since since
@ -44,6 +46,7 @@ const fetchAndUpdate = ({
} }
args.userId = userId args.userId = userId
args.listId = listId
args.tag = tag args.tag = tag
args.withMuted = !hideMutedPosts args.withMuted = !hideMutedPosts
if (loggedIn && ['friends', 'public', 'publicAndExternal'].includes(timeline)) { if (loggedIn && ['friends', 'public', 'publicAndExternal'].includes(timeline)) {
@ -62,7 +65,7 @@ const fetchAndUpdate = ({
if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) { if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) {
store.dispatch('queueFlush', { timeline, id: timelineData.maxId }) store.dispatch('queueFlush', { timeline, id: timelineData.maxId })
} }
update({ store, statuses, timeline, showImmediately, userId, pagination }) update({ store, statuses, timeline, showImmediately, userId, listId, pagination })
return { statuses, pagination } return { statuses, pagination }
}) })
.catch((error) => { .catch((error) => {
@ -75,14 +78,15 @@ const fetchAndUpdate = ({
}) })
} }
const startFetching = ({ timeline = 'friends', credentials, store, userId = false, tag = false }) => { const startFetching = ({ timeline = 'friends', credentials, store, userId = false, listId = false, tag = false }) => {
const rootState = store.rootState || store.state const rootState = store.rootState || store.state
const timelineData = rootState.statuses.timelines[camelCase(timeline)] const timelineData = rootState.statuses.timelines[camelCase(timeline)]
const showImmediately = timelineData.visibleStatuses.length === 0 const showImmediately = timelineData.visibleStatuses.length === 0
timelineData.userId = userId timelineData.userId = userId
fetchAndUpdate({ timeline, credentials, store, showImmediately, userId, tag }) timelineData.listId = listId
fetchAndUpdate({ timeline, credentials, store, showImmediately, userId, listId, tag })
const boundFetchAndUpdate = () => const boundFetchAndUpdate = () =>
fetchAndUpdate({ timeline, credentials, store, userId, tag }) fetchAndUpdate({ timeline, credentials, store, userId, listId, tag })
return promiseInterval(boundFetchAndUpdate, 10000) return promiseInterval(boundFetchAndUpdate, 10000)
} }
const timelineFetcher = { const timelineFetcher = {

View File

@ -14,6 +14,7 @@
"logoMask": true, "logoMask": true,
"logoLeft": false, "logoLeft": false,
"minimalScopesMode": false, "minimalScopesMode": false,
"disableUpdateNotification": false,
"nsfwCensorImage": "", "nsfwCensorImage": "",
"postContentType": "text/plain", "postContentType": "text/plain",
"redirectRootLogin": "/main/friends", "redirectRootLogin": "/main/friends",

View File

@ -16,7 +16,7 @@ const webpackConfig = merge(baseConfig, {
module: { module: {
rules: utils.styleLoaders() rules: utils.styleLoaders()
}, },
devtool: '#inline-source-map', devtool: 'inline-source-map',
// vue: { // vue: {
// loaders: { // loaders: {
// js: 'isparta' // js: 'isparta'

View File

@ -40,4 +40,28 @@ describe('routes', () => {
// eslint-disable-next-line no-prototype-builtins // eslint-disable-next-line no-prototype-builtins
expect(matchedComponents[0].components.default.components.hasOwnProperty('UserCard')).to.eql(true) expect(matchedComponents[0].components.default.components.hasOwnProperty('UserCard')).to.eql(true)
}) })
it('list view', async () => {
await router.push('/lists')
const matchedComponents = router.currentRoute.value.matched
expect(Object.prototype.hasOwnProperty.call(matchedComponents[0].components.default.components, 'ListsCard')).to.eql(true)
})
it('list timeline', async () => {
await router.push('/lists/1')
const matchedComponents = router.currentRoute.value.matched
expect(Object.prototype.hasOwnProperty.call(matchedComponents[0].components.default.components, 'Timeline')).to.eql(true)
})
it('list edit', async () => {
await router.push('/lists/1/edit')
const matchedComponents = router.currentRoute.value.matched
expect(Object.prototype.hasOwnProperty.call(matchedComponents[0].components.default.components, 'BasicUserCard')).to.eql(true)
})
}) })

View File

@ -15,6 +15,7 @@ const actions = {
const testGetters = { const testGetters = {
findUser: state => getters.findUser(state.users), findUser: state => getters.findUser(state.users),
findUserByName: state => getters.findUserByName(state.users),
relationship: state => getters.relationship(state.users), relationship: state => getters.relationship(state.users),
mergedConfig: state => ({ mergedConfig: state => ({
colors: '', colors: '',
@ -95,6 +96,7 @@ const externalProfileStore = createStore({
credentials: '' credentials: ''
}, },
usersObject: { 100: extUser }, usersObject: { 100: extUser },
usersByNameObject: {},
users: [extUser], users: [extUser],
relationships: {} relationships: {}
} }
@ -163,7 +165,8 @@ const localProfileStore = createStore({
currentUser: { currentUser: {
credentials: '' credentials: ''
}, },
usersObject: { 100: localUser, testuser: localUser }, usersObject: { 100: localUser },
usersByNameObject: { testuser: localUser },
users: [localUser], users: [localUser],
relationships: {} relationships: {}
} }

View File

@ -0,0 +1,83 @@
import { cloneDeep } from 'lodash'
import { defaultState, mutations, getters } from '../../../../src/modules/lists.js'
describe('The lists module', () => {
describe('mutations', () => {
it('updates array of all lists', () => {
const state = cloneDeep(defaultState)
const list = { id: '1', title: 'testList' }
mutations.setLists(state, [list])
expect(state.allLists).to.have.length(1)
expect(state.allLists).to.eql([list])
})
it('adds a new list with a title, updating the title for existing lists', () => {
const state = cloneDeep(defaultState)
const list = { id: '1', title: 'testList' }
const modList = { id: '1', title: 'anotherTestTitle' }
mutations.setList(state, list)
expect(state.allListsObject[list.id]).to.eql({ title: list.title })
expect(state.allLists).to.have.length(1)
expect(state.allLists[0]).to.eql(list)
mutations.setList(state, modList)
expect(state.allListsObject[modList.id]).to.eql({ title: modList.title })
expect(state.allLists).to.have.length(1)
expect(state.allLists[0]).to.eql(modList)
})
it('adds a new list with an array of IDs, updating the IDs for existing lists', () => {
const state = cloneDeep(defaultState)
const list = { id: '1', accountIds: ['1', '2', '3'] }
const modList = { id: '1', accountIds: ['3', '4', '5'] }
mutations.setListAccounts(state, list)
expect(state.allListsObject[list.id]).to.eql({ accountIds: list.accountIds })
mutations.setListAccounts(state, modList)
expect(state.allListsObject[modList.id]).to.eql({ accountIds: modList.accountIds })
})
it('deletes a list', () => {
const state = {
allLists: [{ id: '1', title: 'testList' }],
allListsObject: {
1: { title: 'testList', accountIds: ['1', '2', '3'] }
}
}
const id = '1'
mutations.deleteList(state, { id })
expect(state.allLists).to.have.length(0)
expect(state.allListsObject).to.eql({})
})
})
describe('getters', () => {
it('returns list title', () => {
const state = {
allLists: [{ id: '1', title: 'testList' }],
allListsObject: {
1: { title: 'testList', accountIds: ['1', '2', '3'] }
}
}
const id = '1'
expect(getters.findListTitle(state)(id)).to.eql('testList')
})
it('returns list accounts', () => {
const state = {
allLists: [{ id: '1', title: 'testList' }],
allListsObject: {
1: { title: 'testList', accountIds: ['1', '2', '3'] }
}
}
const id = '1'
expect(getters.findListAccounts(state)(id)).to.eql(['1', '2', '3'])
})
})
})

View File

@ -0,0 +1,178 @@
import { cloneDeep } from 'lodash'
import {
VERSION,
COMMAND_TRIM_FLAGS,
COMMAND_TRIM_FLAGS_AND_RESET,
_getRecentData,
_getAllFlags,
_mergeFlags,
_resetFlags,
mutations,
defaultState,
newUserFlags
} from 'src/modules/serverSideStorage.js'
describe('The serverSideStorage module', () => {
describe('mutations', () => {
describe('setServerSideStorage', () => {
const { setServerSideStorage } = mutations
const user = {
created_at: new Date('1999-02-09'),
storage: {}
}
it('should initialize storage if none present', () => {
const state = cloneDeep(defaultState)
setServerSideStorage(state, user)
expect(state.cache._version).to.eql(VERSION)
expect(state.cache._timestamp).to.be.a('number')
expect(state.cache.flagStorage).to.eql(defaultState.flagStorage)
})
it('should initialize storage with proper flags for new users if none present', () => {
const state = cloneDeep(defaultState)
setServerSideStorage(state, { ...user, created_at: new Date() })
expect(state.cache._version).to.eql(VERSION)
expect(state.cache._timestamp).to.be.a('number')
expect(state.cache.flagStorage).to.eql(newUserFlags)
})
it('should merge flags even if remote timestamp is older', () => {
const state = {
...cloneDeep(defaultState),
cache: {
_timestamp: Date.now(),
_version: VERSION,
...cloneDeep(defaultState)
}
}
setServerSideStorage(
state,
{
...user,
storage: {
_timestamp: 123,
_version: VERSION,
flagStorage: {
...defaultState.flagStorage,
updateCounter: 1
}
}
}
)
expect(state.cache.flagStorage).to.eql({
...defaultState.flagStorage,
updateCounter: 1
})
})
it('should reset local timestamp to remote if contents are the same', () => {
const state = {
...cloneDeep(defaultState),
cache: null
}
setServerSideStorage(
state,
{
...user,
storage: {
_timestamp: 123,
_version: VERSION,
flagStorage: {
...defaultState.flagStorage,
updateCounter: 999
}
}
}
)
expect(state.cache._timestamp).to.eql(123)
expect(state.flagStorage.updateCounter).to.eql(999)
expect(state.cache.flagStorage.updateCounter).to.eql(999)
})
it('should remote version if local missing', () => {
const state = cloneDeep(defaultState)
setServerSideStorage(state, user)
expect(state.cache._version).to.eql(VERSION)
expect(state.cache._timestamp).to.be.a('number')
expect(state.cache.flagStorage).to.eql(defaultState.flagStorage)
})
})
})
describe('helper functions', () => {
describe('_getRecentData', () => {
it('should handle nulls correctly', () => {
expect(_getRecentData(null, null)).to.eql({ recent: null, stale: null, needUpload: true })
})
it('doesn\'t choke on invalid data', () => {
expect(_getRecentData({ a: 1 }, { b: 2 })).to.eql({ recent: null, stale: null, needUpload: true })
})
it('should prefer the valid non-null correctly, needUpload works properly', () => {
const nonNull = { _version: VERSION, _timestamp: 1 }
expect(_getRecentData(nonNull, null)).to.eql({ recent: nonNull, stale: null, needUpload: true })
expect(_getRecentData(null, nonNull)).to.eql({ recent: nonNull, stale: null, needUpload: false })
})
it('should prefer the one with higher timestamp', () => {
const a = { _version: VERSION, _timestamp: 1 }
const b = { _version: VERSION, _timestamp: 2 }
expect(_getRecentData(a, b)).to.eql({ recent: b, stale: a, needUpload: false })
expect(_getRecentData(b, a)).to.eql({ recent: b, stale: a, needUpload: false })
})
it('case where both are same', () => {
const a = { _version: VERSION, _timestamp: 3 }
const b = { _version: VERSION, _timestamp: 3 }
expect(_getRecentData(a, b)).to.eql({ recent: b, stale: a, needUpload: false })
expect(_getRecentData(b, a)).to.eql({ recent: b, stale: a, needUpload: false })
})
})
describe('_getAllFlags', () => {
it('should handle nulls properly', () => {
expect(_getAllFlags(null, null)).to.eql([])
})
it('should output list of keys if passed single object', () => {
expect(_getAllFlags({ flagStorage: { a: 1, b: 1, c: 1 } }, null)).to.eql(['a', 'b', 'c'])
})
it('should union keys of both objects', () => {
expect(_getAllFlags({ flagStorage: { a: 1, b: 1, c: 1 } }, { flagStorage: { c: 1, d: 1 } })).to.eql(['a', 'b', 'c', 'd'])
})
})
describe('_mergeFlags', () => {
it('should handle merge two flag sets correctly picking higher numbers', () => {
expect(
_mergeFlags(
{ flagStorage: { a: 0, b: 3 } },
{ flagStorage: { b: 1, c: 4, d: 9 } },
['a', 'b', 'c', 'd'])
).to.eql({ a: 0, b: 3, c: 4, d: 9 })
})
})
describe('_resetFlags', () => {
it('should reset all known flags to 0 when reset flag is set to > 0 and < 9000', () => {
const totalFlags = { a: 0, b: 3, reset: 1 }
expect(_resetFlags(totalFlags)).to.eql({ a: 0, b: 0, reset: 0 })
})
it('should trim all flags to known when reset is set to 1000', () => {
const totalFlags = { a: 0, b: 3, c: 33, reset: COMMAND_TRIM_FLAGS }
expect(_resetFlags(totalFlags, { a: 0, b: 0, reset: 0 })).to.eql({ a: 0, b: 3, reset: 0 })
})
it('should trim all flags to known and reset when reset is set to 1001', () => {
const totalFlags = { a: 0, b: 3, c: 33, reset: COMMAND_TRIM_FLAGS_AND_RESET }
expect(_resetFlags(totalFlags, { a: 0, b: 0, reset: 0 })).to.eql({ a: 0, b: 0, reset: 0 })
})
})
})
})

View File

@ -57,24 +57,27 @@ describe('The users module', () => {
}) })
describe('findUser', () => { describe('findUser', () => {
it('returns user with matching screen_name', () => { it('does not return user with matching screen_name', () => {
const user = { screen_name: 'Guy', id: '1' } const user = { screen_name: 'Guy', id: '1' }
const state = { const state = {
usersObject: { usersObject: {
1: user, 1: user
},
usersByNameObject: {
guy: user guy: user
} }
} }
const name = 'Guy' const name = 'Guy'
const expected = { screen_name: 'Guy', id: '1' } expect(getters.findUser(state)(name)).to.eql(undefined)
expect(getters.findUser(state)(name)).to.eql(expected)
}) })
it('returns user with matching id', () => { it('returns user with matching id', () => {
const user = { screen_name: 'Guy', id: '1' } const user = { screen_name: 'Guy', id: '1' }
const state = { const state = {
usersObject: { usersObject: {
1: user, 1: user
},
usersByNameObject: {
guy: user guy: user
} }
} }
@ -83,4 +86,35 @@ describe('The users module', () => {
expect(getters.findUser(state)(id)).to.eql(expected) expect(getters.findUser(state)(id)).to.eql(expected)
}) })
}) })
describe('findUserByName', () => {
it('returns user with matching screen_name', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
usersObject: {
1: user
},
usersByNameObject: {
guy: user
}
}
const name = 'Guy'
const expected = { screen_name: 'Guy', id: '1' }
expect(getters.findUserByName(state)(name)).to.eql(expected)
})
it('does not return user with matching id', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
usersObject: {
1: user
},
usersByNameObject: {
guy: user
}
}
const id = '1'
expect(getters.findUserByName(state)(id)).to.eql(undefined)
})
})
}) })

View File

@ -195,7 +195,7 @@ describe('API Entities normalizer', () => {
expect(parsedPost).to.have.property('type', 'status') expect(parsedPost).to.have.property('type', 'status')
expect(parsedRepeat).to.have.property('type', 'retweet') expect(parsedRepeat).to.have.property('type', 'retweet')
expect(parsedRepeat).to.have.property('retweeted_status') expect(parsedRepeat).to.have.property('retweeted_status')
expect(parsedRepeat).to.have.deep.property('retweeted_status.id', 'deadbeef') expect(parsedRepeat).to.have.nested.property('retweeted_status.id', 'deadbeef')
}) })
it('sets nsfw for statuses with the #nsfw tag', () => { it('sets nsfw for statuses with the #nsfw tag', () => {
@ -229,7 +229,7 @@ describe('API Entities normalizer', () => {
expect(parsedPost).to.have.property('type', 'status') expect(parsedPost).to.have.property('type', 'status')
expect(parsedRepeat).to.have.property('type', 'retweet') expect(parsedRepeat).to.have.property('type', 'retweet')
expect(parsedRepeat).to.have.property('retweeted_status') expect(parsedRepeat).to.have.property('retweeted_status')
expect(parsedRepeat).to.have.deep.property('retweeted_status.id', 'deadbeef') expect(parsedRepeat).to.have.nested.property('retweeted_status.id', 'deadbeef')
}) })
}) })
}) })
@ -284,9 +284,9 @@ describe('API Entities normalizer', () => {
}) })
expect(parseNotification(notif)).to.have.property('id', 123) expect(parseNotification(notif)).to.have.property('id', 123)
expect(parseNotification(notif)).to.have.property('seen', false) expect(parseNotification(notif)).to.have.property('seen', false)
expect(parseNotification(notif)).to.have.deep.property('status.id', '444') expect(parseNotification(notif)).to.have.nested.property('status.id', '444')
expect(parseNotification(notif)).to.have.deep.property('action.id', '444') expect(parseNotification(notif)).to.have.nested.property('action.id', '444')
expect(parseNotification(notif)).to.have.deep.property('from_profile.id', 'spurdo') expect(parseNotification(notif)).to.have.nested.property('from_profile.id', 'spurdo')
}) })
it('correctly normalizes favorite notifications', () => { it('correctly normalizes favorite notifications', () => {
@ -303,9 +303,9 @@ describe('API Entities normalizer', () => {
expect(parseNotification(notif)).to.have.property('id', 123) expect(parseNotification(notif)).to.have.property('id', 123)
expect(parseNotification(notif)).to.have.property('type', 'like') expect(parseNotification(notif)).to.have.property('type', 'like')
expect(parseNotification(notif)).to.have.property('seen', true) expect(parseNotification(notif)).to.have.property('seen', true)
expect(parseNotification(notif)).to.have.deep.property('status.id', '4412') expect(parseNotification(notif)).to.have.nested.property('status.id', '4412')
expect(parseNotification(notif)).to.have.deep.property('action.id', '444') expect(parseNotification(notif)).to.have.nested.property('action.id', '444')
expect(parseNotification(notif)).to.have.deep.property('from_profile.id', 'spurdo') expect(parseNotification(notif)).to.have.nested.property('from_profile.id', 'spurdo')
}) })
}) })

5558
yarn.lock

File diff suppressed because it is too large Load Diff