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)
// 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()
})
})
// FIXME: The statement below gives error about hooks being required in webpack 5.
// 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
Object.keys(proxyTable).forEach(function (context) {
@ -48,7 +50,7 @@ Object.keys(proxyTable).forEach(function (context) {
if (typeof options === 'string') {
options = { target: options }
}
app.use(proxyMiddleware(context, options))
app.use(proxyMiddleware.createProxyMiddleware(context, options))
})
// handle fallback for HTML5 history API

View File

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

View File

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

View File

@ -5,6 +5,7 @@ var webpack = require('webpack')
var merge = require('webpack-merge')
var baseWebpackConfig = require('./webpack.base.conf')
var MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin")
var HtmlWebpackPlugin = require('html-webpack-plugin')
var env = process.env.NODE_ENV === 'testing'
? require('../config/test.env')
@ -19,12 +20,16 @@ var webpackConfig = merge(baseWebpackConfig, {
module: {
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, extract: true })
},
devtool: config.build.productionSourceMap ? '#source-map' : false,
devtool: config.build.productionSourceMap ? 'source-map' : false,
optimization: {
minimize: true,
splitChunks: {
chunks: 'all'
}
},
minimizer: [
`...`,
new CssMinimizerPlugin()
]
},
output: {
path: config.build.assetsRoot,
@ -60,9 +65,7 @@ var webpackConfig = merge(baseWebpackConfig, {
ignoreCustomComments: [/server-generated-meta/]
// more options:
// 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
// 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/vue-fontawesome": "3.0.1",
"@kazvmoe-infra/pinch-zoom-element": "1.2.0",
"@ruffle-rs/ruffle": "^0.1.0-nightly.2022.7.12",
"@vuelidate/core": "2.0.0-alpha.43",
"@ruffle-rs/ruffle": "0.1.0-nightly.2022.7.12",
"@vuelidate/core": "2.0.0-alpha.44",
"@vuelidate/validators": "2.0.0-alpha.31",
"body-scroll-lock": "3.1.5",
"chromatism": "3.0.0",
@ -32,95 +32,95 @@
"cropperjs": "1.5.12",
"diff": "3.5.0",
"escape-html": "1.0.3",
"js-cookie": "^3.0.1",
"js-cookie": "3.0.1",
"localforage": "1.10.0",
"parse-link-header": "1.0.1",
"parse-link-header": "2.0.0",
"phoenix": "1.6.2",
"punycode.js": "2.1.0",
"qrcode": "1",
"utf8": "^3.0.0",
"qrcode": "1.5.0",
"querystring-es3": "0.2.1",
"url": "0.11.0",
"utf8": "3.0.0",
"vue": "3.2.37",
"vue-i18n": "9.2.0",
"vue-i18n": "9.2.2",
"vue-router": "4.1.3",
"vue-template-compiler": "2.7.8",
"vue-template-compiler": "2.7.9",
"vuex": "4.0.2"
},
"devDependencies": {
"@babel/core": "7.18.9",
"@babel/plugin-transform-runtime": "7.18.9",
"@babel/preset-env": "7.18.9",
"@babel/register": "7.18.9",
"@babel/core": "7.18.10",
"@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",
"@vue/babel-helper-vue-jsx-merge-props": "1.2.1",
"@vue/babel-plugin-jsx": "1.1.1",
"@vue/compiler-sfc": "3.2.37",
"@vue/test-utils": "2.0.2",
"autoprefixer": "6.7.7",
"autoprefixer": "10.4.8",
"babel-loader": "8.2.5",
"babel-plugin-lodash": "3.3.4",
"chai": "3.5.0",
"chai": "4.3.6",
"chalk": "1.1.3",
"chromedriver": "103.0.0",
"connect-history-api-fallback": "1.6.0",
"copy-webpack-plugin": "6.4.1",
"cross-spawn": "4.0.2",
"css-loader": "0.28.11",
"chromedriver": "104.0.0",
"connect-history-api-fallback": "2.0.0",
"copy-webpack-plugin": "11.0.0",
"cross-spawn": "7.0.3",
"css-loader": "6.7.1",
"css-minimizer-webpack-plugin": "4.0.0",
"custom-event-polyfill": "1.0.7",
"eslint": "8.20.0",
"eslint": "8.22.0",
"eslint-config-standard": "17.0.0",
"eslint-formatter-friendly": "7.0.0",
"eslint-webpack-plugin": "2.7.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-vue": "9.3.0",
"eslint-webpack-plugin": "3.2.0",
"eventsource-polyfill": "0.9.6",
"express": "4.18.1",
"file-loader": "3.0.1",
"function-bind": "1.1.1",
"html-webpack-plugin": "3.2.0",
"http-proxy-middleware": "0.21.0",
"inject-loader": "2.0.1",
"html-webpack-plugin": "5.5.0",
"http-proxy-middleware": "2.0.6",
"iso-639-1": "2.1.15",
"isparta-loader": "2.0.0",
"json-loader": "0.5.7",
"karma": "6.4.0",
"karma-coverage": "1.1.2",
"karma-firefox-launcher": "1.3.0",
"karma-coverage": "2.2.0",
"karma-firefox-launcher": "2.1.2",
"karma-mocha": "2.0.1",
"karma-mocha-reporter": "2.2.5",
"karma-sinon-chai": "2.0.2",
"karma-sourcemap-loader": "0.3.8",
"karma-spec-reporter": "0.0.34",
"karma-webpack": "4.0.2",
"karma-webpack": "5.0.0",
"lodash": "4.17.21",
"lolex": "1.6.0",
"mini-css-extract-plugin": "0.12.0",
"mocha": "3.5.3",
"nightwatch": "0.9.21",
"mini-css-extract-plugin": "2.6.1",
"mocha": "10.0.0",
"nightwatch": "2.3.3",
"opn": "4.0.2",
"ora": "0.4.1",
"postcss-loader": "3.0.0",
"raw-loader": "0.5.1",
"sass": "1.54.0",
"sass-loader": "7.3.1",
"postcss": "8.4.16",
"postcss-loader": "7.0.1",
"sass": "1.54.5",
"sass-loader": "13.0.2",
"selenium-server": "2.53.1",
"semver": "5.7.1",
"serviceworker-webpack-plugin": "1.0.1",
"serviceworker-webpack5-plugin": "2.0.0",
"shelljs": "0.8.5",
"sinon": "2.4.1",
"sinon-chai": "2.14.0",
"stylelint": "13.13.1",
"stylelint-config-standard": "20.0.0",
"stylelint-rscss": "0.4.0",
"url-loader": "1.1.2",
"vue-loader": "^16.0.0",
"vue-loader": "17.0.0",
"vue-style-loader": "4.1.3",
"webpack": "4.46.0",
"webpack": "5.74.0",
"webpack-dev-middleware": "3.7.3",
"webpack-hot-middleware": "2.25.1",
"webpack-hot-middleware": "2.25.2",
"webpack-merge": "0.20.0"
},
"engines": {

View File

@ -32,6 +32,7 @@ export default {
MobileNav,
DesktopNav,
SettingsModal: defineAsyncComponent(() => import('./components/settings_modal/settings_modal.vue')),
UpdateNotification: defineAsyncComponent(() => import('./components/update_notification/update_notification.vue')),
UserReportingModal,
PostStatusModal,
GlobalNoticeList
@ -59,6 +60,13 @@ export default {
'-' + this.layoutType
]
},
navClasses () {
const { navbarColumnStretch } = this.$store.getters.mergedConfig
return [
'-' + this.layoutType,
...(navbarColumnStretch ? ['-column-stretch'] : [])
]
},
currentUser () { return this.$store.state.users.currentUser },
userBackground () { return this.currentUser.background_image },
instanceBackground () {

View File

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

View File

@ -8,7 +8,10 @@
class="app-bg-wrapper"
/>
<MobileNav v-if="layoutType === 'mobile'" />
<DesktopNav v-else />
<DesktopNav
v-else
:class="navClasses"
/>
<Notifications v-if="currentUser" />
<div
id="content"
@ -65,6 +68,7 @@
<UserReportingModal />
<PostStatusModal />
<SettingsModal />
<UpdateNotification />
<div id="modal" />
<GlobalNoticeList />
<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 backendInteractorService from '../services/backend_interactor_service/backend_interactor_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'
let staticInitialResults = null
@ -360,6 +360,8 @@ const afterStoreSetup = async ({ store, i18n }) => {
console.error('Failed to load any theme!')
}
applyConfig(store.state.config)
// Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized
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 About from 'components/about/about.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) => {
const validateAuthenticatedRoute = (to, from, next) => {
@ -58,7 +61,7 @@ export default (store) => {
component: RemoteUserResolver,
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: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
{ 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: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ 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) {

View File

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

View File

@ -23,6 +23,26 @@
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 {
grid-template-columns: auto 2fr 2fr;
grid-template-areas: "logo sitename actions";

View File

@ -6,7 +6,9 @@ import {
faEyeSlash,
faThumbtack,
faShareAlt,
faExternalLinkAlt
faExternalLinkAlt,
faPlus,
faTimes
} from '@fortawesome/free-solid-svg-icons'
import {
faBookmark as faBookmarkReg,
@ -21,13 +23,26 @@ library.add(
faThumbtack,
faShareAlt,
faExternalLinkAlt,
faFlag
faFlag,
faPlus,
faTimes
)
const ExtraButtons = {
props: ['status'],
components: { Popover },
data () {
return {
expanded: false
}
},
methods: {
onShow () {
this.expanded = true
},
onClose () {
this.expanded = false
},
deleteStatus () {
const confirmed = window.confirm(this.$t('status.delete_confirm'))
if (confirmed) {

View File

@ -6,6 +6,8 @@
:offset="{ y: 5 }"
:bound-to="{ x: 'container' }"
remove-padding
@show="onShow"
@close="onClose"
>
<template #content="{close}">
<div class="dropdown-menu">
@ -122,10 +124,24 @@
</template>
<template #trigger>
<span class="button-unstyled popover-trigger">
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="ellipsis-h"
/>
<FALayers class="fa-old-padding-layer">
<FAIcon
class="fa-scale-110 "
icon="ellipsis-h"
/>
<FAIcon
v-show="!expanded"
class="focus-marker"
transform="shrink-6 up-8 right-16"
icon="plus"
/>
<FAIcon
v-show="expanded"
class="focus-marker"
transform="shrink-6 up-8 right-16"
icon="times"
/>
</FALayers>
</span>
</template>
</Popover>
@ -135,6 +151,7 @@
<style lang="scss">
@import '../../_variables.scss';
@import '../../_mixins.scss';
.ExtraButtons {
/* override of popover internal stuff */
@ -151,6 +168,21 @@
color: $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>

View File

@ -1,13 +1,21 @@
import { mapGetters } from 'vuex'
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 {
faStar as faStarRegular
} from '@fortawesome/free-regular-svg-icons'
library.add(
faStar,
faStarRegular
faStarRegular,
faPlus,
faMinus,
faCheck
)
const FavoriteButton = {

View File

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

View File

@ -5,6 +5,8 @@ const tabModeDict = {
mentions: ['mention'],
'likes+repeats': ['repeat', 'like'],
follows: ['follow'],
reactions: ['pleroma:emoji_reaction'],
reports: ['pleroma:report'],
moves: ['move']
}
@ -12,7 +14,8 @@ const Interactions = {
data () {
return {
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: {

View File

@ -21,6 +21,15 @@
key="follows"
:label="$t('interactions.follows')"
/>
<span
key="reactions"
:label="$t('interactions.emoji_reactions')"
/>
<span
v-if="canSeeReports"
key="reports"
:label="$t('interactions.reports')"
/>
<span
v-if="!allowFollowingMove"
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 ListsMenuContent from '../lists_menu/lists_menu_content.vue'
import { mapState, mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
@ -12,7 +13,8 @@ import {
faComments,
faBell,
faInfoCircle,
faStream
faStream,
faList
} from '@fortawesome/free-solid-svg-icons'
library.add(
@ -25,7 +27,8 @@ library.add(
faComments,
faBell,
faInfoCircle,
faStream
faStream,
faList
)
const NavPanel = {
@ -35,19 +38,27 @@ const NavPanel = {
}
},
components: {
TimelineMenuContent
TimelineMenuContent,
ListsMenuContent
},
data () {
return {
showTimelines: false
showTimelines: false,
showLists: false
}
},
methods: {
toggleTimelines () {
this.showTimelines = !this.showTimelines
},
toggleLists () {
this.showLists = !this.showLists
}
},
computed: {
listsNavigation () {
return this.$store.getters.mergedConfig.listsNavigation
},
...mapState({
currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length,

View File

@ -25,6 +25,51 @@
<TimelineMenuContent class="timelines" />
</div>
</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">
<router-link
class="menu-item"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -43,6 +43,9 @@ export default {
methods: {
update (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>
</Select>
<ModifiedIndicator :changed="isChanged" />
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
<ServerSideIndicator :server-side="isServerSide" />
</label>
</template>

View File

@ -36,6 +36,9 @@ export default {
methods: {
update (e) {
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"
>
{{ ' ' }}
<ModifiedIndicator :changed="isChanged" />
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
</span>
</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 ScopeSelector from 'src/components/scope_selector/scope_selector.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 SharedComputedObject from '../helpers/shared_computed_object.js'
@ -61,11 +62,15 @@ const GeneralTab = {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
SizeSetting,
InterfaceLanguageSwitcher,
ScopeSelector,
ServerSideIndicator
},
computed: {
horizontalUnits () {
return defaultHorizontalUnits
},
postFormats () {
return this.$store.state.instance.postFormats || []
},
@ -76,6 +81,17 @@ const GeneralTab = {
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 },
instanceWallpaperUsed () {
return this.$store.state.instance.background &&

View File

@ -15,11 +15,6 @@
{{ $t('settings.hide_isp') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="sidebarRight">
{{ $t('settings.right_sidebar') }}
</BooleanSetting>
</li>
<li v-if="instanceWallpaperUsed">
<BooleanSetting path="hideInstanceWallpaper">
{{ $t('settings.hide_wallpaper') }}
@ -64,16 +59,6 @@
{{ $t('settings.virtual_scrolling') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="disableStickyHeaders">
{{ $t('settings.disable_sticky_headers') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="showScrollbars">
{{ $t('settings.show_scrollbars') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
id="userPopoverAvatarAction"
@ -92,16 +77,6 @@
{{ $t('settings.user_popover_avatar_overlay') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
v-if="user"
id="thirdColumnMode"
path="thirdColumnMode"
:options="thirdColumnModeOptions"
>
{{ $t('settings.third_column_mode') }}
</ChoiceSetting>
</li>
<li>
<BooleanSetting
path="alwaysShowNewPostButton"
@ -126,6 +101,58 @@
{{ $t('settings.hide_shoutbox') }}
</BooleanSetting>
</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>
</div>
<div class="setting-item">
@ -435,3 +462,16 @@
</template>
<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,
faTachometerAlt,
faCog,
faInfoCircle
faInfoCircle,
faList
} from '@fortawesome/free-solid-svg-icons'
library.add(
@ -28,7 +29,8 @@ library.add(
faSearch,
faTachometerAlt,
faCog,
faInfoCircle
faInfoCircle,
faList
)
const SideDrawer = {

View File

@ -55,6 +55,18 @@
/> {{ $t("nav.timelines") }}
</router-link>
</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
v-if="currentUser && pleromaChatMessagesAvailable"
@click="toggleDrawer"

View File

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

View File

@ -58,6 +58,9 @@ const TimelineMenu = {
if (route === 'tag-timeline') {
return '#' + this.$route.params.tag
}
if (route === 'lists-timeline') {
return this.$store.getters.findListTitle(this.$route.params.id)
}
const i18nkey = timelineNames()[this.$route.name]
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 () {
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)
},
unmounted () {
@ -106,12 +106,17 @@ const UserProfile = {
this.userId = null
this.error = false
const maybeId = userNameOrId.id
const maybeName = userNameOrId.name
// 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) {
loadById(user.id)
} else {
this.$store.dispatch('fetchUser', userNameOrId)
(maybeId
? this.$store.dispatch('fetchUser', maybeId)
: this.$store.dispatch('fetchUserByName', maybeName))
.then(({ id }) => loadById(id))
.catch((reason) => {
const errorMessage = get(reason, 'error.error')
@ -150,12 +155,12 @@ const UserProfile = {
watch: {
'$route.params.id': function (newVal) {
if (newVal) {
this.switchUser(newVal)
this.switchUser({ id: newVal })
}
},
'$route.params.name': function (newVal) {
if (newVal) {
this.switchUser(newVal)
this.switchUser({ name: newVal })
}
},
'$route.query': function (newVal) {

View File

@ -1,4 +1,3 @@
import Status from '../status/status.vue'
import List from '../list/list.vue'
import Checkbox from '../checkbox/checkbox.vue'
@ -21,14 +20,17 @@ const UserReportingModal = {
}
},
computed: {
reportModal () {
return this.$store.state.reports.reportModal
},
isLoggedIn () {
return !!this.$store.state.users.currentUser
},
isOpen () {
return this.isLoggedIn && this.$store.state.reports.modalActivated
return this.isLoggedIn && this.reportModal.activated
},
userId () {
return this.$store.state.reports.userId
return this.reportModal.userId
},
user () {
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)
},
statuses () {
return this.$store.state.reports.statuses
return this.reportModal.statuses
},
preTickedIds () {
return this.$store.state.reports.preTickedIds
return this.reportModal.preTickedIds
}
},
watch: {

View File

@ -66,11 +66,13 @@
"more": "More",
"loading": "Loading…",
"generic_error": "An error occured",
"generic_error_message": "An error occured: {0}",
"error_retry": "Please try again",
"retry": "Try again",
"optional": "optional",
"show_more": "Show more",
"show_less": "Show less",
"never_show_again": "Never show again",
"dismiss": "Dismiss",
"cancel": "Cancel",
"disable": "Disable",
@ -146,7 +148,8 @@
"who_to_follow": "Who to follow",
"preferences": "Preferences",
"timelines": "Timelines",
"chats": "Chats"
"chats": "Chats",
"lists": "Lists"
},
"notifications": {
"broken_favorite": "Unknown status, searching for it…",
@ -161,6 +164,7 @@
"no_more_notifications": "No more notifications",
"migrated_to": "migrated to",
"reacted_with": "reacted with {0}",
"submitted_report": "submitted a report",
"poll_ended": "poll has ended"
},
"polls": {
@ -196,6 +200,8 @@
"interactions": {
"favs_repeats": "Repeats and favorites",
"follows": "New follows",
"emoji_reactions": "Emoji Reactions",
"reports": "Reports",
"moves": "User migrates",
"load_older": "Load older interactions"
},
@ -264,6 +270,16 @@
"searching_for": "Searching for",
"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": {
"select_all": "Select all"
},
@ -298,6 +314,7 @@
"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",
"attachmentRadius": "Attachments",
"attachments": "Attachments",
@ -395,6 +412,7 @@
"hide_isp": "Hide instance-specific panel",
"hide_shoutbox": "Hide instance shoutbox",
"right_sidebar": "Reverse order of columns",
"navbar_column_stretch": "Stretch navbar to columns width",
"always_show_post_button": "Always show floating New Post button",
"hide_wallpaper": "Hide instance wallpaper",
"preload_images": "Preload images",
@ -516,6 +534,11 @@
"third_column_mode_none": "Don't show third column at all",
"third_column_mode_notifications": "Notifications column",
"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_fade_ancestors": "Display ancestors of the current status in faint text",
"conversation_display_linear": "Linear-style",
@ -956,6 +979,16 @@
"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!"
},
"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": {
"audio": "Audio",
"video": "Video",
@ -964,5 +997,14 @@
},
"display_date": {
"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",
"follows": "Nieuwe gevolgden",
"moves": "Gebruikermigraties",
"emoji_reactions": "Emoji Reacties",
"reports": "Rapportages",
"load_older": "Oudere interacties laden"
},
"remote_user_resolver": {
@ -751,6 +753,17 @@
"error": "Niet gevonden.",
"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": {
"select_all": "Alles selecteren"
},

View File

@ -456,6 +456,15 @@
"subject_line_mastodon": "Как в Mastodon: скопировать как есть",
"subject_line_email": "Как в электронной почте: \"re: тема\"",
"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_blocks": "Нет блокировок",
"notification_visibility_emoji_reactions": "Реакции",

View File

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

View File

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

View File

@ -191,12 +191,13 @@ const api = {
startFetchingTimeline (store, {
timeline = 'friends',
tag = false,
userId = false
userId = false,
listId = false
}) {
if (store.state.fetchers[timeline]) return
const fetcher = store.state.backendInteractor.startFetchingTimeline({
timeline, store, userId, tag
timeline, store, userId, listId, tag
})
store.commit('addFetcher', { fetcherName: timeline, fetcher })
},
@ -248,6 +249,18 @@ const api = {
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
setWsToken (store, token) {
store.commit('setWsToken', token)

View File

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

View File

@ -41,6 +41,7 @@ const defaultState = {
logoMargin: '.2em',
logoMask: true,
logoLeft: false,
disableUpdateNotification: false,
minimalScopesMode: false,
nsfwCensorImage: undefined,
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 = {
state: {
userId: null,
statuses: [],
preTickedIds: [],
modalActivated: false
reportModal: {
userId: null,
statuses: [],
preTickedIds: [],
activated: false
},
reports: {}
},
mutations: {
openUserReportingModal (state, { userId, statuses, preTickedIds }) {
state.userId = userId
state.statuses = statuses
state.preTickedIds = preTickedIds
state.modalActivated = true
state.reportModal.userId = userId
state.reportModal.statuses = statuses
state.reportModal.preTickedIds = preTickedIds
state.reportModal.activated = true
},
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: {
@ -31,6 +40,23 @@ const reports = {
},
closeUserReportingModal ({ commit }) {
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(),
tag: 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
}
if (notification.type === 'pleroma:report') {
dispatch('addReport', notification.report)
}
if (notification.type === 'pleroma:emoji_reaction') {
dispatch('fetchEmojiReactionsBy', notification.status.id)
}

View File

@ -16,9 +16,6 @@ export const mergeOrAdd = (arr, obj, item) => {
// This is a new item, prepare it
arr.push(item)
obj[item.id] = item
if (item.screen_name && !item.screen_name.includes('@')) {
obj[item.screen_name.toLowerCase()] = item
}
return { item, new: true }
}
}
@ -162,7 +159,11 @@ export const mutations = {
if (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) {
@ -239,12 +240,10 @@ export const mutations = {
export const getters = {
findUser: state => query => {
const result = state.usersObject[query]
// In case it's a screen_name, we can try searching case-insensitive
if (!result && typeof query === 'string') {
return state.usersObject[query.toLowerCase()]
}
return result
return state.usersObject[query]
},
findUserByName: state => query => {
return state.usersByNameObject[query.toLowerCase()]
},
findUserByUrl: state => query => {
return state.users
@ -263,6 +262,7 @@ export const defaultState = {
currentUser: false,
users: [],
usersObject: {},
usersByNameObject: {},
signUpPending: false,
signUpErrors: [],
relationships: {}
@ -285,6 +285,13 @@ const users = {
return user
})
},
fetchUserByName (store, name) {
return store.rootState.api.backendInteractor.fetchUserByName({ name })
.then((user) => {
store.commit('addNewUsers', [user])
return user
})
},
fetchUserRelationship (store, id) {
if (store.state.currentUser) {
store.rootState.api.backendInteractor.fetchUserRelationship({ id })
@ -525,6 +532,7 @@ const users = {
user.muteIds = []
user.domainMutes = []
commit('setCurrentUser', user)
commit('setServerSideStorage', user)
commit('addNewUsers', [user])
store.dispatch('fetchEmoji')
@ -534,6 +542,7 @@ const users = {
// Set our new backend interactor
commit('setBackendInteractor', backendInteractorService(accessToken))
store.dispatch('pushServerSideStorage')
if (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_CONTEXT_URL = id => `/api/v1/statuses/${id}/context`
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_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_BOOKMARK_TIMELINE_URL = '/api/v1/bookmarks'
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_USER_SEARCH_URL = '/api/v1/accounts/search'
const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks'
const MASTODON_LISTS_URL = '/api/v1/lists'
const MASTODON_STREAMING = '/api/v1/streaming'
const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
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_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_ADMIN_REPORTS = '/api/pleroma/admin/reports'
const PLEROMA_BACKUP_URL = '/api/v1/pleroma/backups'
const oldfetch = window.fetch
@ -313,6 +319,25 @@ const fetchUser = ({ id, credentials }) => {
.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 url = `${MASTODON_USER_RELATIONSHIPS_URL}/?id=${id}`
return fetch(url, { headers: authHeaders(credentials) })
@ -385,6 +410,81 @@ const fetchFollowRequests = ({ credentials }) => {
.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 urlContext = MASTODON_STATUS_CONTEXT_URL(id)
return fetch(urlContext, { headers: authHeaders(credentials) })
@ -506,9 +606,11 @@ const fetchTimeline = ({
since = false,
until = false,
userId = false,
listId = false,
tag = false,
withMuted = false,
replyVisibility = 'all'
replyVisibility = 'all',
includeTypes = []
}) => {
const timelineUrls = {
public: MASTODON_PUBLIC_TIMELINE,
@ -518,6 +620,7 @@ const fetchTimeline = ({
publicAndExternal: MASTODON_PUBLIC_TIMELINE,
user: MASTODON_USER_TIMELINE_URL,
media: MASTODON_USER_TIMELINE_URL,
list: MASTODON_LIST_TIMELINE_URL,
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
tag: MASTODON_TAG_TIMELINE_URL,
bookmarks: MASTODON_BOOKMARK_TIMELINE_URL
@ -531,6 +634,10 @@ const fetchTimeline = ({
url = url(userId)
}
if (timeline === 'list') {
url = url(listId)
}
if (since) {
params.push(['since_id', since])
}
@ -555,6 +662,11 @@ const fetchTimeline = ({
if (replyVisibility !== 'all') {
params.push(['reply_visibility', replyVisibility])
}
if (includeTypes.length > 0) {
includeTypes.forEach(type => {
params.push(['include_types[]', type])
})
}
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 = {
verifyCredentials,
fetchTimeline,
@ -1357,6 +1501,7 @@ const apiService = {
blockUser,
unblockUser,
fetchUser,
fetchUserByName,
fetchUserRelationship,
favorite,
unfavorite,
@ -1405,6 +1550,14 @@ const apiService = {
addBackup,
listBackups,
fetchFollowRequests,
fetchLists,
createList,
getList,
updateList,
getListAccounts,
addAccountsToList,
removeAccountsFromList,
deleteList,
approveUser,
denyUser,
suggestions,
@ -1430,7 +1583,8 @@ const apiService = {
chatMessages,
sendChatMessage,
readChat,
deleteChatMessage
deleteChatMessage,
setReportState
}
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 notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js'
import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service'
import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js'
const backendInteractorService = credentials => ({
startFetchingTimeline ({ timeline, store, userId = false, tag }) {
return timelineFetcher.startFetching({ timeline, store, credentials, userId, tag })
startFetchingTimeline ({ timeline, store, userId = false, listId = false, tag }) {
return timelineFetcher.startFetching({ timeline, store, credentials, userId, listId, tag })
},
fetchTimeline (args) {
@ -24,6 +25,10 @@ const backendInteractorService = credentials => ({
return followRequestFetcher.startFetching({ store, credentials })
},
startFetchingLists ({ store }) {
return listsFetcher.startFetching({ store, credentials })
},
startUserSocket ({ store }) {
const serv = store.rootState.instance.server.replace('http', 'ws')
const url = serv + getMastodonSocketURI({ credentials, stream: 'user' })

View File

@ -90,6 +90,9 @@ export const parseUser = (data) => {
output.bot = data.bot
if (data.pleroma) {
if (data.pleroma.settings_store) {
output.storage = data.pleroma.settings_store['pleroma-fe']
}
const relationship = data.pleroma.relationship
output.background_image = data.pleroma.background_image
@ -387,6 +390,13 @@ export const parseNotification = (data) => {
: parseUser(data.target)
output.from_profile = parseUser(data.account)
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 {
const parsedNotice = parseStatus(data.notice)
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.moves && 'move',
rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction',
rootState.config.notificationVisibility.reports && 'pleroma:report',
rootState.config.notificationVisibility.polls && 'poll'
].filter(_ => _))
}
@ -99,6 +100,9 @@ export const prepareNotificationObject = (notification, i18n) => {
case 'follow_request':
i18nString = 'follow_request'
break
case 'pleroma:report':
i18nString = 'submitted_report'
break
case 'poll':
i18nString = 'poll_ended'
break

View File

@ -1,6 +1,18 @@
import apiService from '../api/api.service.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 }) => {
store.dispatch('addNewNotifications', { notifications, older })
}
@ -12,6 +24,7 @@ const fetchAndUpdate = ({ store, credentials, older = false, since }) => {
const timelineData = rootState.statuses.notifications
const hideMutedPosts = getters.mergedConfig.hideMutedPosts
args.includeTypes = mastoApiNotificationTypes
args.withMuted = !hideMutedPosts
args.timeline = 'notifications'
@ -63,6 +76,7 @@ const fetchNotifications = ({ store, args, older }) => {
messageArgs: [error.message],
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) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)

View File

@ -1,6 +1,7 @@
import { convert } from 'chromatism'
import { rgb2hex, hex2rgb, rgba2css, getCssColor, relativeLuminance } from '../color_convert/color_convert.js'
import { getColors, computeDynamicColor, getOpacitySlot } from '../theme_data/theme_data.service.js'
import { defaultState } from '../../modules/config.js'
export const applyTheme = (input) => {
const { rules } = generatePreset(input)
@ -20,6 +21,36 @@ export const applyTheme = (input) => {
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) => {
if (input.length === 0) {
return 'none'

View File

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

View File

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

View File

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

View File

@ -40,4 +40,28 @@ describe('routes', () => {
// eslint-disable-next-line no-prototype-builtins
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 = {
findUser: state => getters.findUser(state.users),
findUserByName: state => getters.findUserByName(state.users),
relationship: state => getters.relationship(state.users),
mergedConfig: state => ({
colors: '',
@ -95,6 +96,7 @@ const externalProfileStore = createStore({
credentials: ''
},
usersObject: { 100: extUser },
usersByNameObject: {},
users: [extUser],
relationships: {}
}
@ -163,7 +165,8 @@ const localProfileStore = createStore({
currentUser: {
credentials: ''
},
usersObject: { 100: localUser, testuser: localUser },
usersObject: { 100: localUser },
usersByNameObject: { testuser: localUser },
users: [localUser],
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', () => {
it('returns user with matching screen_name', () => {
it('does not return user with matching screen_name', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
usersObject: {
1: user,
1: user
},
usersByNameObject: {
guy: user
}
}
const name = 'Guy'
const expected = { screen_name: 'Guy', id: '1' }
expect(getters.findUser(state)(name)).to.eql(expected)
expect(getters.findUser(state)(name)).to.eql(undefined)
})
it('returns user with matching id', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
usersObject: {
1: user,
1: user
},
usersByNameObject: {
guy: user
}
}
@ -83,4 +86,35 @@ describe('The users module', () => {
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(parsedRepeat).to.have.property('type', 'retweet')
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', () => {
@ -229,7 +229,7 @@ describe('API Entities normalizer', () => {
expect(parsedPost).to.have.property('type', 'status')
expect(parsedRepeat).to.have.property('type', 'retweet')
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('seen', false)
expect(parseNotification(notif)).to.have.deep.property('status.id', '444')
expect(parseNotification(notif)).to.have.deep.property('action.id', '444')
expect(parseNotification(notif)).to.have.deep.property('from_profile.id', 'spurdo')
expect(parseNotification(notif)).to.have.nested.property('status.id', '444')
expect(parseNotification(notif)).to.have.nested.property('action.id', '444')
expect(parseNotification(notif)).to.have.nested.property('from_profile.id', 'spurdo')
})
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('type', 'like')
expect(parseNotification(notif)).to.have.property('seen', true)
expect(parseNotification(notif)).to.have.deep.property('status.id', '4412')
expect(parseNotification(notif)).to.have.deep.property('action.id', '444')
expect(parseNotification(notif)).to.have.deep.property('from_profile.id', 'spurdo')
expect(parseNotification(notif)).to.have.nested.property('status.id', '4412')
expect(parseNotification(notif)).to.have.nested.property('action.id', '444')
expect(parseNotification(notif)).to.have.nested.property('from_profile.id', 'spurdo')
})
})

5558
yarn.lock

File diff suppressed because it is too large Load Diff