Merge branch 'develop' into snoot

This commit is contained in:
Vivian Lim 2019-04-24 00:36:45 -07:00
commit b3be1ef6ff
271 changed files with 27168 additions and 5913 deletions

View File

@ -1,5 +1,5 @@
{
"presets": ["es2015", "stage-2"],
"plugins": ["transform-runtime", "lodash"],
"presets": ["es2015", "stage-2", "env"],
"plugins": ["transform-runtime", "lodash", "transform-vue-jsx"],
"comments": false
}

View File

@ -11,7 +11,7 @@ module.exports = {
'html'
],
// add your custom rules here
'rules': {
rules: {
// allow paren-less arrow functions
'arrow-parens': 0,
// allow async-await

1
.gitignore vendored
View File

@ -6,3 +6,4 @@ test/unit/coverage
test/e2e/reports
selenium-debug.log
.idea/
config/local.json

View File

@ -3,32 +3,10 @@
# https://hub.docker.com/r/library/node/tags/
image: node:7
before_script:
# Install ssh-agent if not already installed, it is required by Docker.
# (change apt-get to yum if you use a CentOS-based image)
- 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
# Run ssh-agent (inside the build environment)
- eval $(ssh-agent -s)
# For Docker builds disable host key checking. Be aware that by adding that
# you are suspectible to man-in-the-middle attacks.
# WARNING: Use this only with the Docker executor, if you use it with shell
# you will overwrite your user's SSH config.
- mkdir -p ~/.ssh
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
# This folder is cached between builds
# http://docs.gitlab.com/ce/ci/yaml/README.html#cache
#cache:
# paths:
# - node_modules/
stages:
- lint
- build
- test
- deploy
lint:
stage: lint
@ -50,14 +28,3 @@ build:
artifacts:
paths:
- dist/
deploy:
stage: deploy
environment: dev
only:
- develop
script:
- yarn
- npm run build
- ssh-add <(echo "$SSH_PRIVATE_KEY")
- scp -r dist/* pleroma@tenshi.heldscal.la:~/pleroma

10
BREAKING_CHANGES.md Normal file
View File

@ -0,0 +1,10 @@
# v1.0
## Removed features/radically changed behavior
### minimalScopesMode
As of !633, `scopeOptions` is no longer available and instead is changed for `minimalScopesMode` (default: `false`)
Reasoning is that scopeOptions option originally existed mostly as a backwards-compatibility with GNU Social which only had `public` scope available and using scope selector would''t work. Since at some point we dropped GNU Social support, this option was mostly a nuisance (being default `false`'), however some people think scopes are an annoyance to a certain degree and want as less of that feature as possible.
Solution - to only show minimal set among: *Direct*, *User default* and *Scope of post replying to*. This also makes it impossible to reply to a DM with a non-DM post from UI.
*This setting is admin-default, user-configurable. Admin can choose different default for their instance but user can override it.*

View File

@ -8,3 +8,4 @@ Contributors of this project.
- hakui (hakui@freezepeach.xyz): CSS and styling
- shpuld (shpuld@shitposter.club): CSS and styling
- Vincent Guth (https://unsplash.com/photos/XrwVIFy6rTw): Background images.
- hj (hj@shigusegubu.club): Code

View File

@ -6,11 +6,12 @@
# For Translators
To translate Pleroma, add your language to [src/i18n/messages.js](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/i18n/messages.js). Pleroma will set your language by your browser locale, but you can temporarily force it in the code by changing the locale in main.js.
To translate Pleroma-FE, add your language to [src/i18n/messages.js](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/i18n/messages.js). Pleroma-FE will set your language by your browser locale, but you can temporarily force it in the code by changing the locale in main.js.
# FOR ADMINS
You don't need to build Pleroma yourself. Check out https://git.pleroma.social/pleroma/pleroma-fe/wikis/dual-boot-with-qvitter to see how to run Pleroma and Qvitter at the same time.
You don't need to build Pleroma-FE yourself. Those using the Pleroma backend will be able to use it out of the box.
For the GNU social backend, check out https://git.pleroma.social/pleroma/pleroma-fe/wikis/dual-boot-with-qvitter to see how to run Pleroma-FE and Qvitter at the same time.
## Build Setup
@ -29,6 +30,21 @@ npm run build
npm run unit
```
# For Contributors:
You can create file `/config/local.json` (see [example](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/config/local.example.json)) to enable some convenience dev options:
* `target`: makes local dev server redirect to some existing instance's BE instead of local BE, useful for testing things in near-production environment and searching for real-life use-cases.
* `staticConfigPreference`: makes FE's `/static/config.json` take preference of BE-served `/api/statusnet/config.json`. Only works in dev mode.
FE Build process also leaves current commit hash in global variable `___pleromafe_commit_hash` so that you can easily see which pleroma-fe commit instance is running, also helps pinpointing which commit was used when FE was bundled into BE.
# Configuration
Edit config.json for configuration. scopeOptionsEnabled gives you input fields for CWs and the scope settings.
Edit config.json for configuration.
## Options
### Login methods
```loginMethod``` can be set to either ```password``` (the default) or ```token```, which will use the full oauth redirection flow, which is useful for SSO situations.

View File

@ -2,6 +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 env = process.env.NODE_ENV
// check env & config/index.js to decide weither to enable CSS Sourcemaps for the
@ -54,7 +55,7 @@ module.exports = {
loader: 'vue'
},
{
test: /\.js$/,
test: /\.jsx?$/,
loader: 'babel',
include: projectRoot,
exclude: /node_modules\/(?!tributejs)/
@ -91,5 +92,11 @@ module.exports = {
browsers: ['last 2 versions']
})
]
}
},
plugins: [
new ServiceWorkerWebpackPlugin({
entry: path.join(__dirname, '..', 'src/sw.js'),
filename: 'sw-pleroma.js'
})
]
}

View File

@ -18,7 +18,9 @@ module.exports = merge(baseWebpackConfig, {
devtool: '#eval-source-map',
plugins: [
new webpack.DefinePlugin({
'process.env': config.dev.env
'process.env': config.dev.env,
'COMMIT_HASH': JSON.stringify('DEV'),
'DEV_OVERRIDES': JSON.stringify(config.dev.settings)
}),
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage
new webpack.optimize.OccurenceOrderPlugin(),

View File

@ -7,8 +7,13 @@ var baseWebpackConfig = require('./webpack.base.conf')
var ExtractTextPlugin = require('extract-text-webpack-plugin')
var HtmlWebpackPlugin = require('html-webpack-plugin')
var env = process.env.NODE_ENV === 'testing'
? require('../config/test.env')
: config.build.env
? require('../config/test.env')
: config.build.env
let commitHash = require('child_process')
.execSync('git rev-parse --short HEAD')
.toString();
console.log(commitHash)
var webpackConfig = merge(baseWebpackConfig, {
module: {
@ -29,7 +34,9 @@ var webpackConfig = merge(baseWebpackConfig, {
plugins: [
// http://vuejs.github.io/vue-loader/workflow/production.html
new webpack.DefinePlugin({
'process.env': env
'process.env': env,
'COMMIT_HASH': JSON.stringify(commitHash),
'DEV_OVERRIDES': JSON.stringify(undefined)
}),
new webpack.optimize.UglifyJsPlugin({
compress: {
@ -51,7 +58,8 @@ var webpackConfig = merge(baseWebpackConfig, {
minify: {
removeComments: true,
collapseWhitespace: true,
removeAttributeQuotes: true
removeAttributeQuotes: true,
ignoreCustomComments: [/server-generated-meta/]
// more options:
// https://github.com/kangax/html-minifier#options-quick-reference
},

View File

@ -1,5 +1,15 @@
// see http://vuejs-templates.github.io/webpack for documentation.
var path = require('path')
const path = require('path')
let settings = {}
try {
settings = require('./local.json')
console.log('Using local dev server settings (/config/local.json):')
console.log(JSON.stringify(settings, null, 2))
} catch (e) {
console.log('Local dev server settings not found (/config/local.json)')
}
const target = settings.target || 'http://localhost:4000/'
module.exports = {
build: {
@ -19,16 +29,22 @@ module.exports = {
dev: {
env: require('./dev.env'),
port: 8080,
settings,
assetsSubDirectory: 'static',
assetsPublicPath: '/',
proxyTable: {
'/api': {
target: 'htts://localhost:4000/',
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost'
},
'/nodeinfo': {
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost'
},
'/socket': {
target: 'htts://localhost:4000/',
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost',
ws: true

View File

@ -0,0 +1,4 @@
{
"target": "https://pleroma.soykaf.com/",
"staticConfigPreference": false
}

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pleroma</title>
<!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="stylesheet" href="/static/font/css/fontello.css">
<link rel="stylesheet" href="/static/font/css/animation.css">

View File

@ -16,29 +16,41 @@
"dependencies": {
"babel-plugin-add-module-exports": "^0.2.1",
"babel-plugin-lodash": "^3.2.11",
"chromatism": "^3.0.0",
"cropperjs": "^1.4.3",
"diff": "^3.0.1",
"karma-mocha-reporter": "^2.2.1",
"localforage": "^1.5.0",
"node-sass": "^3.10.1",
"object-path": "^0.11.3",
"phoenix": "^1.3.0",
"popper.js": "^1.14.7",
"sanitize-html": "^1.13.0",
"sass-loader": "^4.0.2",
"v-click-outside": "^2.1.1",
"vue": "^2.5.13",
"vue-chat-scroll": "^1.2.1",
"vue-i18n": "^7.3.2",
"vue-popperjs": "^2.0.3",
"vue-router": "^3.0.1",
"vue-template-compiler": "^2.3.4",
"vue-timeago": "^3.1.2",
"vuelidate": "^0.7.4",
"vuex": "^3.0.1",
"whatwg-fetch": "^2.0.3"
},
"devDependencies": {
"@babel/polyfill": "^7.0.0",
"@vue/test-utils": "^1.0.0-beta.26",
"autoprefixer": "^6.4.0",
"babel-core": "^6.0.0",
"babel-eslint": "^7.0.0",
"babel-helper-vue-jsx-merge-props": "^2.0.3",
"babel-loader": "^6.0.0",
"babel-plugin-syntax-jsx": "^6.18.0",
"babel-plugin-transform-runtime": "^6.0.0",
"babel-plugin-transform-vue-jsx": "3",
"babel-preset-env": "^1.7.0",
"babel-preset-es2015": "^6.0.0",
"babel-preset-stage-2": "^6.0.0",
"babel-register": "^6.0.0",
@ -63,6 +75,7 @@
"html-webpack-plugin": "^2.8.1",
"http-proxy-middleware": "^0.17.2",
"inject-loader": "^2.0.1",
"iso-639-1": "^2.0.3",
"isparta-loader": "^2.0.0",
"json-loader": "^0.5.4",
"karma": "^1.3.0",
@ -83,6 +96,7 @@
"raw-loader": "^0.5.1",
"selenium-server": "2.53.1",
"semver": "^5.3.0",
"serviceworker-webpack-plugin": "0.2.3",
"shelljs": "^0.7.4",
"sinon": "^1.17.3",
"sinon-chai": "^2.8.0",

View File

@ -2,9 +2,15 @@ import UserPanel from './components/user_panel/user_panel.vue'
import NavPanel from './components/nav_panel/nav_panel.vue'
import Notifications from './components/notifications/notifications.vue'
import UserFinder from './components/user_finder/user_finder.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import ChatPanel from './components/chat_panel/chat_panel.vue'
import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import MobilePostStatusModal from './components/mobile_post_status_modal/mobile_post_status_modal.vue'
import MobileNav from './components/mobile_nav/mobile_nav.vue'
import { windowWidth } from './services/window_utils/window_utils'
export default {
name: 'app',
@ -13,34 +19,95 @@ export default {
NavPanel,
Notifications,
UserFinder,
WhoToFollowPanel,
InstanceSpecificPanel,
ChatPanel
FeaturesPanel,
WhoToFollowPanel,
ChatPanel,
MediaModal,
SideDrawer,
MobilePostStatusModal,
MobileNav
},
data: () => ({
mobileActivePanel: 'timeline'
mobileActivePanel: 'timeline',
finderHidden: true,
supportsMask: window.CSS && window.CSS.supports && (
window.CSS.supports('mask-size', 'contain') ||
window.CSS.supports('-webkit-mask-size', 'contain') ||
window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain')
)
}),
created () {
// Load the locale from the storage
this.$i18n.locale = this.$store.state.config.interfaceLanguage
window.addEventListener('resize', this.updateMobileState)
},
destroyed () {
window.removeEventListener('resize', this.updateMobileState)
},
computed: {
currentUser () { return this.$store.state.users.currentUser },
background () {
return this.currentUser.background_image || this.$store.state.config.background
return this.currentUser.background_image || this.$store.state.instance.background
},
logoStyle () { return { 'background-image': `url(${this.$store.state.config.logo})` } },
style () { return { 'background-image': `url(${this.background})` } },
sitename () { return this.$store.state.config.name },
enableMask () { return this.supportsMask && this.$store.state.instance.logoMask },
logoStyle () {
return {
'visibility': this.enableMask ? 'hidden' : 'visible'
}
},
logoMaskStyle () {
return this.enableMask ? {
'mask-image': `url(${this.$store.state.instance.logo})`
} : {
'background-color': this.enableMask ? '' : 'transparent'
}
},
logoBgStyle () {
return Object.assign({
'margin': `${this.$store.state.instance.logoMargin} 0`,
opacity: this.finderHidden ? 1 : 0
}, this.enableMask ? {} : {
'background-color': this.enableMask ? '' : 'transparent'
})
},
logo () { return this.$store.state.instance.logo },
bgStyle () {
return {
'background-image': `url(${this.background})`
}
},
bgAppStyle () {
return {
'--body-background-image': `url(${this.background})`
}
},
sitename () { return this.$store.state.instance.name },
chat () { return this.$store.state.chat.channel.state === 'joined' },
showWhoToFollowPanel () { return this.$store.state.config.showWhoToFollowPanel },
showInstanceSpecificPanel () { return this.$store.state.config.showInstanceSpecificPanel }
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () { return this.$store.state.instance.showInstanceSpecificPanel },
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
isMobileLayout () { return this.$store.state.interface.mobileLayout }
},
methods: {
activatePanel (panelName) {
this.mobileActivePanel = panelName
},
scrollToTop () {
window.scrollTo(0, 0)
},
logout () {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
},
onFinderToggled (hidden) {
this.finderHidden = hidden
},
updateMobileState () {
const mobileLayout = windowWidth() <= 800
const changed = mobileLayout !== this.isMobileLayout
if (changed) {
this.$store.dispatch('setMobileLayout', mobileLayout)
}
}
}
}

View File

@ -1,15 +1,21 @@
@import './_variables.scss';
#app {
background-size: cover;
background-attachment: fixed;
background-repeat: no-repeat;
background-position: 0 50px;
min-height: 100vh;
max-width: 100%;
overflow: hidden;
}
.app-bg-wrapper {
position: fixed;
z-index: -1;
height: 100%;
width: 100%;
background-size: cover;
background-repeat: no-repeat;
background-position: 0 50%;
}
i {
user-select: none;
}
@ -34,10 +40,11 @@ h4 {
body {
font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif);
font-size: 14px;
margin: 0;
color: $fallback--fg;
color: var(--fg, $fallback--fg);
color: $fallback--text;
color: var(--text, $fallback--text);
max-width: 100vw;
overflow-x: hidden;
}
@ -48,24 +55,39 @@ a {
color: var(--link, $fallback--link);
}
button{
button {
user-select: none;
color: $fallback--fg;
color: var(--fg, $fallback--fg);
background-color: $fallback--btn;
background-color: var(--btn, $fallback--btn);
color: $fallback--text;
color: var(--btnText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
border: none;
border-radius: $fallback--btnRadius;
border-radius: var(--btnRadius, $fallback--btnRadius);
cursor: pointer;
border-top: 1px solid rgba(255, 255, 255, 0.2);
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
box-shadow: 0px 0px 2px black;
box-shadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
box-shadow: var(--buttonShadow);
font-size: 14px;
font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif);
i[class*=icon-] {
color: $fallback--text;
color: var(--btnText, $fallback--text);
}
&::-moz-focus-inner {
border: none;
}
&:hover {
box-shadow: 0px 0px 4px rgba(255, 255, 255, 0.3);
box-shadow: var(--buttonHoverShadow);
}
&:active {
box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset;
box-shadow: var(--buttonPressedShadow);
}
&:disabled {
@ -79,6 +101,14 @@ button{
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg)
}
&.danger {
// TODO: add better color variable
color: $fallback--text;
color: var(--alertErrorPanelText, $fallback--text);
background-color: $fallback--alertError;
background-color: var(--alertError, $fallback--alertError);
}
}
label.select {
@ -90,21 +120,27 @@ input, textarea, .select {
border: none;
border-radius: $fallback--inputRadius;
border-radius: var(--inputRadius, $fallback--inputRadius);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
border-top: 1px solid rgba(0, 0, 0, 0.2);
box-shadow: 0px 0px 2px black inset;
background-color: $fallback--input;
background-color: var(--input, $fallback--input);
color: $fallback--lightFg;
color: var(--lightFg, $fallback--lightFg);
box-shadow: 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px 0px 2px 0px rgba(0, 0, 0, 1) inset;
box-shadow: var(--inputShadow);
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
color: $fallback--lightText;
color: var(--inputText, $fallback--lightText);
font-family: sans-serif;
font-family: var(--inputFont, sans-serif);
font-size: 14px;
padding: 8px 7px;
padding: 8px .5em;
box-sizing: border-box;
display: inline-block;
position: relative;
height: 29px;
height: 28px;
line-height: 16px;
hyphens: none;
&:disabled, &[disabled=disabled] {
cursor: not-allowed;
opacity: 0.5;
}
.icon-down-open {
position: absolute;
@ -112,9 +148,9 @@ input, textarea, .select {
bottom: 0;
right: 5px;
height: 100%;
color: $fallback--fg;
color: var(--fg, $fallback--fg);
line-height: 29px;
color: $fallback--text;
color: var(--text, $fallback--text);
line-height: 28px;
z-index: 0;
pointer-events: none;
}
@ -125,22 +161,40 @@ input, textarea, .select {
appearance: none;
background: transparent;
border: none;
color: $fallback--text;
color: var(--inputText, --text, $fallback--text);
margin: 0;
color: $fallback--fg;
color: var(--fg, $fallback--fg);
padding: 4px 2em 3px 3px;
padding: 0 2em 0 .2em;
font-family: sans-serif;
font-family: var(--inputFont, sans-serif);
font-size: 14px;
width: 100%;
z-index: 1;
height: 29px;
height: 28px;
line-height: 16px;
}
&[type=range] {
background: none;
border: none;
margin: 0;
box-shadow: none;
flex: 1;
}
&[type=radio],
&[type=checkbox] {
display: none;
&:checked + label::before {
color: $fallback--fg;
color: var(--fg, $fallback--fg);
color: $fallback--text;
color: var(--text, $fallback--text);
}
&:disabled {
&,
& + label,
& + label::before {
opacity: .5;
}
}
+ label::before {
display: inline-block;
@ -148,14 +202,13 @@ input, textarea, .select {
transition: color 200ms;
width: 1.1em;
height: 1.1em;
border-radius: $fallback--checkBoxRadius;
border-radius: var(--checkBoxRadius, $fallback--checkBoxRadius);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
border-top: 1px solid rgba(0, 0, 0, 0.2);
border-radius: $fallback--checkboxRadius;
border-radius: var(--checkboxRadius, $fallback--checkboxRadius);
box-shadow: 0px 0px 2px black inset;
box-shadow: var(--inputShadow);
margin-right: .5em;
background-color: $fallback--input;
background-color: var(--input, $fallback--input);
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
vertical-align: top;
text-align: center;
line-height: 1.1em;
@ -168,6 +221,13 @@ input, textarea, .select {
}
}
option {
color: $fallback--text;
color: var(--text, $fallback--text);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
}
i[class*=icon-] {
color: $fallback--icon;
color: var(--icon, $fallback--icon)
@ -181,51 +241,84 @@ i[class*=icon-] {
padding: 0 10px 0 10px;
}
.gaps {
margin: -1em 0 0 -1em;
}
.item {
flex: 1;
line-height: 50px;
height: 50px;
overflow: hidden;
display: flex;
flex-wrap: wrap;
.nav-icon {
font-size: 1.1em;
margin-left: 0.4em;
}
}
.gaps > .item {
padding: 1em 0 0 1em;
&.right {
justify-content: flex-end;
}
}
.auto-size {
flex: 1
}
nav {
.nav-bar {
padding: 0;
width: 100%;
align-items: center;
position: fixed;
height: 50px;
.logo {
display: flex;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
align-items: stretch;
justify-content: center;
flex: 0 0 auto;
z-index: -1;
transition: opacity;
transition-timing-function: ease-out;
transition-duration: 100ms;
.mask {
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
background-color: $fallback--fg;
background-color: var(--topBarText, $fallback--fg);
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
img {
height: 100%;
object-fit: contain;
display: block;
flex: 0;
}
}
.inner-nav {
padding-left: 20px;
padding-right: 20px;
margin: auto;
box-sizing: border-box;
padding-left: 10px;
padding-right: 10px;
display: flex;
align-items: center;
flex-basis: 970px;
margin: auto;
height: 50px;
background-repeat: no-repeat;
background-position: center;
background-size: auto 80%;
a i {
a, a i {
color: $fallback--link;
color: var(--link, $fallback--link);
color: var(--topBarLink, $fallback--link);
}
}
}
@ -248,15 +341,33 @@ main-router {
.panel {
display: flex;
position: relative;
flex-direction: column;
margin: 0.5em;
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
border-radius: $fallback--panelRadius;
border-radius: var(--panelRadius, $fallback--panelRadius);
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
&::after, & {
border-radius: $fallback--panelRadius;
border-radius: var(--panelRadius, $fallback--panelRadius);
}
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
pointer-events: none;
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
box-shadow: var(--panelShadow);
}
}
.panel-body:empty::before {
@ -267,15 +378,55 @@ main-router {
}
.panel-heading {
display: flex;
border-radius: $fallback--panelRadius $fallback--panelRadius 0 0;
border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0;
background-size: cover;
padding: 0.6em 1.0em;
padding: .6em .6em;
text-align: left;
font-size: 1.3em;
line-height: 24px;
background-color: $fallback--btn;
background-color: var(--btn, $fallback--btn);
line-height: 28px;
color: var(--panelText);
background-color: $fallback--fg;
background-color: var(--panel, $fallback--fg);
align-items: baseline;
box-shadow: var(--panelHeaderShadow);
.title {
flex: 1 0 auto;
font-size: 1.3em;
}
.faint {
background-color: transparent;
color: $fallback--faint;
color: var(--panelFaint, $fallback--faint);
}
.alert {
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
}
button {
flex-shrink: 0;
}
button, .alert {
// height: 100%;
line-height: 21px;
min-height: 0;
box-sizing: border-box;
margin: 0;
margin-left: .25em;
min-width: 1px;
align-self: stretch;
}
a {
color: $fallback--link;
color: var(--panelLink, $fallback--link)
}
}
.panel-heading.stub {
@ -286,6 +437,17 @@ main-router {
.panel-footer {
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
.faint {
color: $fallback--faint;
color: var(--panelFaint, $fallback--faint);
}
a {
color: $fallback--link;
color: var(--panelLink, $fallback--link)
}
}
.panel-body > p {
@ -304,11 +466,30 @@ main-router {
nav {
z-index: 1000;
background-color: $fallback--btn;
background-color: var(--btn, $fallback--btn);
color: var(--topBarText);
background-color: $fallback--fg;
background-color: var(--topBar, $fallback--fg);
color: $fallback--faint;
color: var(--faint, $fallback--faint);
box-shadow: 0px 0px 4px rgba(0,0,0,.6);
box-shadow: var(--topBarShadow);
.back-button {
display: block;
max-width: 99px;
transition-property: opacity, max-width;
transition-duration: 300ms;
transition-timing-function: ease-out;
i {
margin: 0 1em;
}
&.hidden {
opacity: 0;
max-width: 5px;
}
}
}
.fade-enter-active, .fade-leave-active {
@ -319,7 +500,7 @@ nav {
}
.main {
flex-basis: 60%;
flex-basis: 50%;
flex-grow: 1;
flex-shrink: 1;
}
@ -339,23 +520,17 @@ nav {
display: none;
}
.panel-switcher {
display: none;
width: 100%;
height: 46px;
button {
display: block;
flex: 1;
max-height: 32px;
margin: 0.5em;
padding: 0.5em;
}
}
@media all and (min-width: 960px) {
@media all and (min-width: 800px) {
body {
overflow-y: scroll;
}
nav {
.back-button {
display: none;
}
}
.sidebar-bounds {
overflow: hidden;
max-height: 100vh;
@ -382,20 +557,46 @@ nav {
flex-grow: 0;
}
}
.badge {
display: inline-block;
border-radius: 99px;
min-width: 22px;
max-width: 22px;
min-height: 22px;
max-height: 22px;
font-size: 15px;
line-height: 22px;
text-align: center;
vertical-align: middle;
white-space: nowrap;
padding: 0;
&.badge-notification {
background-color: $fallback--cRed;
background-color: var(--badgeNotification, $fallback--cRed);
color: white;
color: var(--badgeNotificationText, white);
}
}
.alert {
margin: 0.35em;
padding: 0.25em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
color: $fallback--faint;
color: var(--faint, $fallback--faint);
min-height: 28px;
line-height: 28px;
&.error {
background-color: $fallback--cAlertRed;
background-color: var(--cAlertRed, $fallback--cAlertRed);
background-color: $fallback--alertError;
background-color: var(--alertError, $fallback--alertError);
color: $fallback--text;
color: var(--alertErrorText, $fallback--text);
.panel-heading & {
color: $fallback--text;
color: var(--alertErrorPanelText, $fallback--text);
}
}
}
@ -404,7 +605,105 @@ nav {
color: var(--faint, $fallback--faint);
}
@media all and (max-width: 959px) {
.faint-link {
color: $fallback--faint;
color: var(--faint, $fallback--faint);
&:hover {
text-decoration: underline;
}
}
@media all and (min-width: 800px) {
.logo {
opacity: 1 !important;
}
}
.item.right {
text-align: right;
}
.visibility-tray {
font-size: 1.2em;
padding: 3px;
cursor: pointer;
.selected {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
div {
padding-top: 5px;
}
}
.visibility-notice {
padding: .5em;
border: 1px solid $fallback--faint;
border: 1px solid var(--faint, $fallback--faint);
border-radius: $fallback--inputRadius;
border-radius: var(--inputRadius, $fallback--inputRadius);
}
@keyframes modal-background-fadein {
from {
background-color: rgba(0, 0, 0, 0);
}
to {
background-color: rgba(0, 0, 0, 0.5);
}
}
.modal-view {
z-index: 1000;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
animation-duration: 0.2s;
background-color: rgba(0, 0, 0, 0.5);
animation-name: modal-background-fadein;
}
.button-icon {
font-size: 1.2em;
}
@keyframes shakeError {
0% {
transform: translateX(0);
}
15% {
transform: translateX(0.375rem);
}
30% {
transform: translateX(-0.375rem);
}
45% {
transform: translateX(0.375rem);
}
60% {
transform: translateX(-0.375rem);
}
75% {
transform: translateX(0.375rem);
}
90% {
transform: translateX(-0.375rem);
}
100% {
transform: translateX(0);
}
}
@media all and (max-width: 800px) {
.mobile-hidden {
display: none;
}
@ -414,15 +713,84 @@ nav {
}
.container {
padding: 0 0 0 0;
padding: 0;
}
.panel {
margin: 0.5em 0 0.5em 0;
}
.menu-button {
display: block;
margin-right: 0.8em;
}
}
.item.right {
text-align: right;
padding-right: 20px;
.login-hint {
text-align: center;
@media all and (min-width: 801px) {
display: none;
}
a {
display: inline-block;
padding: 1em 0px;
width: 100%;
}
}
.btn.btn-default {
min-height: 28px;
}
.autocomplete {
&-panel {
position: relative;
&-body {
margin: 0 0.5em 0 0.5em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
position: absolute;
z-index: 1;
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
// this doesn't match original but i don't care, making it uniform.
box-shadow: var(--popupShadow);
min-width: 75%;
background: $fallback--bg;
background: var(--bg, $fallback--bg);
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
}
&-item {
cursor: pointer;
padding: 0.2em 0.4em 0.2em 0.4em;
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
display: flex;
img {
width: 24px;
height: 24px;
object-fit: contain;
}
span {
line-height: 24px;
margin: 0 0.1em 0 0.2em;
}
small {
margin-left: .5em;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
&.highlighted {
background-color: $fallback--fg;
background-color: var(--lightBg, $fallback--fg);
}
}
}

View File

@ -1,42 +1,51 @@
<template>
<div id="app" v-bind:style="style">
<nav class='container' @click="scrollToTop()" id="nav">
<div class='inner-nav' :style="logoStyle">
<div id="app" v-bind:style="bgAppStyle">
<div class="app-bg-wrapper" v-bind:style="bgStyle"></div>
<MobileNav v-if="isMobileLayout" />
<nav v-else class='nav-bar container' @click="scrollToTop()" id="nav">
<div class='logo' :style='logoBgStyle'>
<div class='mask' :style='logoMaskStyle'></div>
<img :src='logo' :style='logoStyle'>
</div>
<div class='inner-nav'>
<div class='item'>
<router-link :to="{ name: 'root'}">{{sitename}}</router-link>
<router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link>
</div>
<div class='item right'>
<user-finder class="nav-icon"></user-finder>
<router-link :to="{ name: 'settings'}"><i class="icon-cog nav-icon"></i></router-link>
<a href="#" v-if="currentUser" @click.prevent="logout"><i class="icon-logout nav-icon" :title="$t('login.logout')"></i></a>
<user-finder class="button-icon nav-icon mobile-hidden" @toggled="onFinderToggled"></user-finder>
<router-link class="mobile-hidden" :to="{ name: 'settings'}"><i class="button-icon icon-cog nav-icon" :title="$t('nav.preferences')"></i></router-link>
<a href="#" class="mobile-hidden" v-if="currentUser" @click.prevent="logout"><i class="button-icon icon-logout nav-icon" :title="$t('login.logout')"></i></a>
</div>
</div>
</nav>
<div class="container" id="content">
<div class="panel-switcher">
<button @click="activatePanel('sidebar')">Sidebar</button>
<button @click="activatePanel('timeline')">Timeline</button>
</div>
<div class="sidebar-flexer" :class="{ 'mobile-hidden': mobileActivePanel != 'sidebar'}">
<div v-if="" class="container" id="content">
<div class="sidebar-flexer mobile-hidden" v-if="!isMobileLayout">
<div class="sidebar-bounds">
<div class="sidebar-scroller">
<div class="sidebar">
<user-panel></user-panel>
<nav-panel></nav-panel>
<instance-specific-panel v-if="showInstanceSpecificPanel"></instance-specific-panel>
<who-to-follow-panel v-if="currentUser && showWhoToFollowPanel"></who-to-follow-panel>
<features-panel v-if="!currentUser && showFeaturesPanel"></features-panel>
<who-to-follow-panel v-if="currentUser && suggestionsEnabled"></who-to-follow-panel>
<notifications v-if="currentUser"></notifications>
</div>
</div>
</div>
</div>
<div class="main" :class="{ 'mobile-hidden': mobileActivePanel != 'timeline' }">
<div class="main">
<div v-if="!currentUser" class="login-hint panel panel-default">
<router-link :to="{ name: 'login' }" class="panel-body">
{{ $t("login.hint") }}
</router-link>
</div>
<transition name="fade">
<router-view></router-view>
</transition>
</div>
<media-modal></media-modal>
</div>
<chat-panel v-if="currentUser && chat" class="floating-chat mobile-hidden"></chat-panel>
<chat-panel :floating="true" v-if="currentUser && chat" class="floating-chat mobile-hidden"></chat-panel>
</div>
</template>

View File

@ -3,24 +3,23 @@ $main-background: white;
$darkened-background: whitesmoke;
$fallback--bg: #121a24;
$fallback--btn: #182230;
$fallback--input: #182230;
$fallback--fg: #182230;
$fallback--faint: rgba(185, 185, 186, .5);
$fallback--fg: #b9b9ba;
$fallback--text: #b9b9ba;
$fallback--link: #d8a070;
$fallback--icon: #666;
$fallback--lightBg: rgb(21, 30, 42);
$fallback--lightFg: #b9b9ba;
$fallback--lightText: #b9b9ba;
$fallback--border: #222;
$fallback--cRed: #ff0000;
$fallback--cBlue: #0095ff;
$fallback--cGreen: #0fa00f;
$fallback--cOrange: orange;
$fallback--cAlertRed: rgba(211,16,20,.5);
$fallback--alertError: rgba(211,16,20,.5);
$fallback--panelRadius: 10px;
$fallback--checkBoxRadius: 2px;
$fallback--checkboxRadius: 2px;
$fallback--btnRadius: 4px;
$fallback--inputRadius: 4px;
$fallback--tooltipRadius: 5px;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 34 KiB

293
src/boot/after_store.js Normal file
View File

@ -0,0 +1,293 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import routes from './routes'
import App from '../App.vue'
import { windowWidth } from '../services/window_utils/window_utils'
const getStatusnetConfig = async ({ store }) => {
try {
const res = await window.fetch('/api/statusnet/config.json')
if (res.ok) {
const data = await res.json()
const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey, safeDMMentionsEnabled } = data.site
store.dispatch('setInstanceOption', { name: 'name', value: name })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') })
store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) })
store.dispatch('setInstanceOption', { name: 'server', value: server })
store.dispatch('setInstanceOption', { name: 'safeDM', value: safeDMMentionsEnabled !== '0' })
// TODO: default values for this stuff, added if to not make it break on
// my dev config out of the box.
if (uploadlimit) {
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadlimit.uploadlimit) })
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadlimit.avatarlimit) })
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadlimit.backgroundlimit) })
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadlimit.bannerlimit) })
}
if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
}
return data.site.pleromafe
} else {
throw (res)
}
} catch (error) {
console.error('Could not load statusnet config, potentially fatal')
console.error(error)
}
}
const getStaticConfig = async () => {
try {
const res = await window.fetch('/static/config.json')
if (res.ok) {
return res.json()
} else {
throw (res)
}
} catch (error) {
console.warn('Failed to load static/config.json, continuing without it.')
console.warn(error)
return {}
}
}
const setSettings = async ({ apiConfig, staticConfig, store }) => {
const overrides = window.___pleromafe_dev_overrides || {}
const env = window.___pleromafe_mode.NODE_ENV
// This takes static config and overrides properties that are present in apiConfig
let config = {}
if (overrides.staticConfigPreference && env === 'development') {
console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
config = Object.assign({}, apiConfig, staticConfig)
} else {
config = Object.assign({}, staticConfig, apiConfig)
}
const copyInstanceOption = (name) => {
store.dispatch('setInstanceOption', { name, value: config[name] })
}
copyInstanceOption('nsfwCensorImage')
copyInstanceOption('background')
copyInstanceOption('hidePostStats')
copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('logo')
store.dispatch('setInstanceOption', {
name: 'logoMask',
value: typeof config.logoMask === 'undefined'
? true
: config.logoMask
})
store.dispatch('setInstanceOption', {
name: 'logoMargin',
value: typeof config.logoMargin === 'undefined'
? 0
: config.logoMargin
})
copyInstanceOption('redirectRootNoLogin')
copyInstanceOption('redirectRootLogin')
copyInstanceOption('showInstanceSpecificPanel')
copyInstanceOption('minimalScopesMode')
copyInstanceOption('formattingOptionsEnabled')
copyInstanceOption('hideMutedPosts')
copyInstanceOption('collapseMessageWithSubject')
copyInstanceOption('loginMethod')
copyInstanceOption('scopeCopy')
copyInstanceOption('subjectLineBehavior')
copyInstanceOption('postContentType')
copyInstanceOption('alwaysShowSubjectInput')
copyInstanceOption('noAttachmentLinks')
copyInstanceOption('showFeaturesPanel')
if ((config.chatDisabled)) {
store.dispatch('disableChat')
} else {
store.dispatch('initializeSocket')
}
return store.dispatch('setTheme', config['theme'])
}
const getTOS = async ({ store }) => {
try {
const res = await window.fetch('/static/terms-of-service.html')
if (res.ok) {
const html = await res.text()
store.dispatch('setInstanceOption', { name: 'tos', value: html })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load TOS")
console.warn(e)
}
}
const getInstancePanel = async ({ store }) => {
try {
const res = await window.fetch('/instance/panel.html')
if (res.ok) {
const html = await res.text()
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load instance panel")
console.warn(e)
}
}
const getStaticEmoji = async ({ store }) => {
try {
const res = await window.fetch('/static/emoji.json')
if (res.ok) {
const values = await res.json()
const emoji = Object.keys(values).map((key) => {
return { shortcode: key, image_url: false, 'utf': values[key] }
})
store.dispatch('setInstanceOption', { name: 'emoji', value: emoji })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load static emoji")
console.warn(e)
}
}
// This is also used to indicate if we have a 'pleroma backend' or not.
// Somewhat weird, should probably be somewhere else.
const getCustomEmoji = async ({ store }) => {
try {
const res = await window.fetch('/api/pleroma/emoji.json')
if (res.ok) {
const result = await res.json()
const values = Array.isArray(result) ? Object.assign({}, ...result) : result
const emoji = Object.keys(values).map((key) => {
return { shortcode: key, image_url: values[key].image_url || values[key] }
})
store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true })
} else {
throw (res)
}
} catch (e) {
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: false })
console.warn("Can't load custom emojis, maybe not a Pleroma instance?")
console.warn(e)
}
}
const getNodeInfo = async ({ store }) => {
try {
const res = await window.fetch('/nodeinfo/2.0.json')
if (res.ok) {
const data = await res.json()
const metadata = data.metadata
const features = metadata.features
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
const suggestions = metadata.suggestions
store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled })
store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web })
const software = data.software
store.dispatch('setInstanceOption', { name: 'backendVersion', value: software.version })
const frontendVersion = window.___pleromafe_commit_hash
store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion })
store.dispatch('setInstanceOption', { name: 'tagPolicyAvailable', value: metadata.federation.mrf_policies.includes('TagPolicy') })
} else {
throw (res)
}
} catch (e) {
console.warn('Could not load nodeinfo')
console.warn(e)
}
}
const setConfig = async ({ store }) => {
// apiConfig, staticConfig
const configInfos = await Promise.all([getStatusnetConfig({ store }), getStaticConfig()])
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
await setSettings({ store, apiConfig, staticConfig })
}
const checkOAuthToken = async ({ store }) => {
return new Promise(async (resolve, reject) => {
if (store.state.oauth.token) {
try {
await store.dispatch('loginUser', store.state.oauth.token)
} catch (e) {
console.log(e)
}
}
resolve()
})
}
const afterStoreSetup = async ({ store, i18n }) => {
if (store.state.config.customTheme) {
// This is a hack to deal with async loading of config.json and themes
// See: style_setter.js, setPreset()
window.themeLoaded = true
store.dispatch('setOption', {
name: 'customTheme',
value: store.state.config.customTheme
})
}
const width = windowWidth()
store.dispatch('setMobileLayout', width <= 800)
// Now we can try getting the server settings and logging in
await Promise.all([
checkOAuthToken({ store }),
setConfig({ store }),
getTOS({ store }),
getInstancePanel({ store }),
getStaticEmoji({ store }),
getCustomEmoji({ store }),
getNodeInfo({ store })
])
const router = new VueRouter({
mode: 'history',
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some(m => m.meta.dontScroll)) {
return false
}
return savedPosition || { x: 0, y: 0 }
}
})
/* eslint-disable no-new */
return new Vue({
router,
store,
i18n,
el: '#app',
render: h => h(App)
})
}
export default afterStoreSetup

53
src/boot/routes.js Normal file
View File

@ -0,0 +1,53 @@
import PublicTimeline from 'components/public_timeline/public_timeline.vue'
import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
import ConversationPage from 'components/conversation-page/conversation-page.vue'
import Mentions from 'components/mentions/mentions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue'
import UserProfile from 'components/user_profile/user_profile.vue'
import Settings from 'components/settings/settings.vue'
import Registration from 'components/registration/registration.vue'
import UserSettings from 'components/user_settings/user_settings.vue'
import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import UserSearch from 'components/user_search/user_search.vue'
import Notifications from 'components/notifications/notifications.vue'
import LoginForm from 'components/login_form/login_form.vue'
import ChatPanel from 'components/chat_panel/chat_panel.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import About from 'components/about/about.vue'
export default (store) => {
return [
{ name: 'root',
path: '/',
redirect: _to => {
return (store.state.users.currentUser
? store.state.instance.redirectRootLogin
: store.state.instance.redirectRootNoLogin) || '/main/all'
}
},
{ name: 'public-external-timeline', path: '/main/all', component: PublicAndExternalTimeline },
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile },
{ name: 'mentions', path: '/users/:username/mentions', component: Mentions },
{ name: 'dms', path: '/users/:username/dms', component: DMs },
{ name: 'settings', path: '/settings', component: Settings },
{ name: 'registration', path: '/registration', component: Registration },
{ name: 'registration-token', path: '/registration/:token', component: Registration },
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests },
{ name: 'user-settings', path: '/user-settings', component: UserSettings },
{ name: 'notifications', path: '/:username/notifications', component: Notifications },
{ name: 'login', path: '/login', component: LoginForm },
{ name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ name: 'user-search', path: '/user-search', component: UserSearch, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow },
{ name: 'about', path: '/about', component: About },
{ name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
]
}

View File

@ -0,0 +1,16 @@
import InstanceSpecificPanel from '../instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from '../features_panel/features_panel.vue'
import TermsOfServicePanel from '../terms_of_service_panel/terms_of_service_panel.vue'
const About = {
components: {
InstanceSpecificPanel,
FeaturesPanel,
TermsOfServicePanel
},
computed: {
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }
}
}
export default About

View File

@ -0,0 +1,12 @@
<template>
<div class="sidebar">
<instance-specific-panel></instance-specific-panel>
<features-panel v-if="showFeaturesPanel"></features-panel>
<terms-of-service-panel></terms-of-service-panel>
</div>
</template>
<script src="./about.js" ></script>
<style lang="scss">
</style>

View File

@ -1,4 +1,5 @@
import StillImage from '../still-image/still-image.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import nsfwImage from '../../assets/nsfw.png'
import fileTypeService from '../../services/file_type/file_type.service.js'
@ -7,21 +8,32 @@ const Attachment = {
'attachment',
'nsfw',
'statusId',
'size'
'size',
'allowPlay',
'setMedia'
],
data () {
return {
nsfwImage,
nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage,
hideNsfwLocal: this.$store.state.config.hideNsfw,
showHidden: false,
preloadImage: this.$store.state.config.preloadImage,
loading: false,
img: document.createElement('img')
img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
modalOpen: false,
showHidden: false
}
},
components: {
StillImage
StillImage,
VideoAttachment
},
computed: {
usePlaceHolder () {
return this.size === 'hide' || this.type === 'unknown'
},
referrerpolicy () {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
},
type () {
return fileTypeService.fileType(this.attachment.mimetype)
},
@ -35,7 +47,7 @@ const Attachment = {
return this.size === 'small'
},
fullwidth () {
return fileTypeService.fileType(this.attachment.mimetype) === 'html'
return this.type === 'html' || this.type === 'audio'
}
},
methods: {
@ -44,16 +56,37 @@ const Attachment = {
window.open(target.href, '_blank')
}
},
toggleHidden () {
if (this.img.onload) {
this.img.onload()
} else {
this.loading = true
this.img.src = this.attachment.url
this.img.onload = () => {
this.loading = false
this.showHidden = !this.showHidden
openModal (event) {
const modalTypes = this.$store.state.config.playVideosInModal
? ['image', 'video']
: ['image']
if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) ||
this.usePlaceHolder
) {
event.stopPropagation()
event.preventDefault()
this.setMedia()
this.$store.dispatch('setCurrent', this.attachment)
}
},
toggleHidden (event) {
if (this.$store.state.config.useOneClickNsfw && !this.showHidden) {
this.openModal(event)
return
}
if (this.img && !this.preloadImage) {
if (this.img.onload) {
this.img.onload()
} else {
this.loading = true
this.img.src = this.attachment.url
this.img.onload = () => {
this.loading = false
this.showHidden = !this.showHidden
}
}
} else {
this.showHidden = !this.showHidden
}
}
}

View File

@ -1,20 +1,44 @@
<template>
<div v-if="size==='hide'">
<a class="placeholder" v-if="type !== 'html'" target="_blank" :href="attachment.url">[{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}]</a>
<div v-if="usePlaceHolder" @click="openModal">
<a class="placeholder"
v-if="type !== 'html'"
target="_blank" :href="attachment.url"
>
[{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}]
</a>
</div>
<div v-else class="attachment" :class="{[type]: true, loading, 'small-attachment': isSmall, 'fullwidth': fullwidth}" v-show="!isEmpty">
<a class="image-attachment" v-if="hidden" @click.prevent="toggleHidden()">
<img :key="nsfwImage" :src="nsfwImage"/>
<div
v-else class="attachment"
:class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
v-show="!isEmpty"
>
<a class="image-attachment" v-if="hidden" :href="attachment.url" @click.prevent="toggleHidden">
<img class="nsfw" :key="nsfwImage" :src="nsfwImage" :class="{'small': isSmall}"/>
<i v-if="type === 'video'" class="play-icon icon-play-circled"></i>
</a>
<div class="hider" v-if="nsfw && hideNsfwLocal && !hidden">
<a href="#" @click.prevent="toggleHidden()">Hide</a>
<a href="#" @click.prevent="toggleHidden">Hide</a>
</div>
<a v-if="type === 'image' && !hidden" class="image-attachment" :href="attachment.url" target="_blank">
<StillImage :class="{'small': isSmall}" referrerpolicy="no-referrer" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/>
<a v-if="type === 'image' && (!hidden || preloadImage)"
@click="openModal"
class="image-attachment"
:class="{'hidden': hidden && preloadImage }"
:href="attachment.url" target="_blank"
:title="attachment.description"
>
<StillImage :referrerpolicy="referrerpolicy" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/>
</a>
<video :class="{'small': isSmall}" v-if="type === 'video' && !hidden" :src="attachment.url" controls loop></video>
<a class="video-container"
@click="openModal"
v-if="type === 'video' && !hidden"
:class="{'small': isSmall}"
:href="allowPlay ? undefined : attachment.url"
>
<VideoAttachment class="video" :attachment="attachment" :controls="allowPlay" />
<i v-if="!allowPlay" class="play-icon icon-play-circled"></i>
</a>
<audio v-if="type === 'audio'" :src="attachment.url" controls></audio>
@ -38,28 +62,33 @@
.attachments {
display: flex;
flex-wrap: wrap;
margin-right: -0.7em;
.attachment.media-upload-container {
flex: 0 0 auto;
max-height: 300px;
max-height: 200px;
max-width: 100%;
display: flex;
video {
max-width: 100%;
}
}
.placeholder {
margin-right: 0.5em;
margin-right: 8px;
margin-bottom: 4px;
}
.small-attachment {
&.image, &.video {
max-width: 35%;
.nsfw-placeholder {
cursor: pointer;
&.loading {
cursor: progress;
}
max-height: 100px;
}
.attachment {
flex: 1 0 30%;
margin: 0.5em 0.7em 0.6em 0.0em;
position: relative;
margin-top: 0.5em;
align-self: flex-start;
line-height: 0;
@ -71,6 +100,28 @@
border-color: var(--border, $fallback--border);
overflow: hidden;
}
.non-gallery.attachment {
&.video {
flex: 1 0 40%;
}
.nsfw {
height: 260px;
}
.small {
height: 120px;
flex-grow: 0;
}
.video {
height: 260px;
display: flex;
}
video {
max-height: 100%;
object-fit: contain;
}
}
.fullwidth {
flex-basis: 100%;
}
@ -79,18 +130,38 @@
line-height: 0;
}
.video-container {
display: flex;
max-height: 100%;
}
.video {
width: 100%;
}
.play-icon {
position: absolute;
font-size: 64px;
top: calc(50% - 32px);
left: calc(50% - 32px);
color: rgba(255, 255, 255, 0.75);
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
}
.play-icon::before {
margin: 0;
}
&.html {
flex-basis: 90%;
width: 100%;
display: flex;
}
&.loading {
cursor: progress;
}
.hider {
position: absolute;
right: 0;
white-space: nowrap;
margin: 10px;
padding: 5px;
background: rgba(230,230,230,0.6);
@ -101,13 +172,7 @@
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
}
.small {
max-height: 100px;
}
video {
max-height: 500px;
height: 100%;
width: 100%;
z-index: 0;
}
@ -117,7 +182,7 @@
img.media-upload {
line-height: 0;
max-height: 300px;
max-height: 200px;
max-width: 100%;
}
@ -154,25 +219,20 @@
}
.image-attachment {
display: flex;
flex: 1;
width: 100%;
height: 100%;
.still-image {
&.hidden {
display: none;
}
.nsfw {
object-fit: cover;
width: 100%;
height: 100%;
}
.small {
img {
max-height: 100px;
}
}
img {
object-fit: contain;
width: 100%;
height: 100%; /* If this isn't here, chrome will stretch the images */
max-height: 500px;
image-orientation: from-image;
}
}

View File

@ -0,0 +1,52 @@
const debounceMilliseconds = 500
export default {
props: {
query: { // function to query results and return a promise
type: Function,
required: true
},
filter: { // function to filter results in real time
type: Function
},
placeholder: {
type: String,
default: 'Search...'
}
},
data () {
return {
term: '',
timeout: null,
results: [],
resultsVisible: false
}
},
computed: {
filtered () {
return this.filter ? this.filter(this.results) : this.results
}
},
watch: {
term (val) {
this.fetchResults(val)
}
},
methods: {
fetchResults (term) {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.results = []
if (term) {
this.query(term).then((results) => { this.results = results })
}
}, debounceMilliseconds)
},
onInputClick () {
this.resultsVisible = true
},
onClickOutside () {
this.resultsVisible = false
}
}
}

View File

@ -0,0 +1,45 @@
<template>
<div class="autosuggest" v-click-outside="onClickOutside">
<input v-model="term" :placeholder="placeholder" @click="onInputClick" class="autosuggest-input" />
<div class="autosuggest-results" v-if="resultsVisible && filtered.length > 0">
<slot v-for="item in filtered" :item="item" />
</div>
</div>
</template>
<script src="./autosuggest.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.autosuggest {
position: relative;
&-input {
display: block;
width: 100%;
}
&-results {
position: absolute;
left: 0;
top: 100%;
right: 0;
max-height: 400px;
background-color: $fallback--lightBg;
background-color: var(--lightBg, $fallback--lightBg);
border-style: solid;
border-width: 1px;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
border-radius: $fallback--inputRadius;
border-radius: var(--inputRadius, $fallback--inputRadius);
border-top-left-radius: 0;
border-top-right-radius: 0;
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
box-shadow: var(--panelShadow);
overflow-y: auto;
z-index: 1;
}
}
</style>

View File

@ -0,0 +1,28 @@
import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const BasicUserCard = {
props: [
'user'
],
data () {
return {
userExpanded: false
}
},
components: {
UserCard,
UserAvatar
},
methods: {
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}
}
}
export default BasicUserCard

View File

@ -0,0 +1,62 @@
<template>
<div class="basic-user-card">
<router-link :to="userProfileLink(user)">
<UserAvatar class="avatar" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/>
</router-link>
<div class="basic-user-card-expanded-content" v-if="userExpanded">
<UserCard :user="user" :rounded="true" :bordered="true"/>
</div>
<div class="basic-user-card-collapsed-content" v-else>
<div :title="user.name" class="basic-user-card-user-name">
<span v-if="user.name_html" class="basic-user-card-user-name-value" v-html="user.name_html"></span>
<span v-else class="basic-user-card-user-name-value">{{ user.name }}</span>
</div>
<div>
<router-link class="basic-user-card-screen-name" :to="userProfileLink(user)">
@{{user.screen_name}}
</router-link>
</div>
<slot></slot>
</div>
</div>
</template>
<script src="./basic_user_card.js"></script>
<style lang="scss">
.basic-user-card {
display: flex;
flex: 1 0;
margin: 0;
padding: 0.6em 1em;
&-collapsed-content {
margin-left: 0.7em;
text-align: left;
flex: 1;
min-width: 0;
}
&-user-name {
img {
object-fit: contain;
height: 16px;
width: 16px;
vertical-align: middle;
}
&-value {
display: inline-block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
&-expanded-content {
flex: 1;
margin-left: 0.7em;
}
}
</style>

View File

@ -0,0 +1,37 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const BlockCard = {
props: ['userId'],
data () {
return {
progress: false
}
},
computed: {
user () {
return this.$store.getters.findUser(this.userId)
},
blocked () {
return this.user.statusnet_blocking
}
},
components: {
BasicUserCard
},
methods: {
unblockUser () {
this.progress = true
this.$store.dispatch('unblockUser', this.user.id).then(() => {
this.progress = false
})
},
blockUser () {
this.progress = true
this.$store.dispatch('blockUser', this.user.id).then(() => {
this.progress = false
})
}
}
}
export default BlockCard

View File

@ -0,0 +1,34 @@
<template>
<basic-user-card :user="user">
<div class="block-card-content-container">
<button class="btn btn-default" @click="unblockUser" :disabled="progress" v-if="blocked">
<template v-if="progress">
{{ $t('user_card.unblock_progress') }}
</template>
<template v-else>
{{ $t('user_card.unblock') }}
</template>
</button>
<button class="btn btn-default" @click="blockUser" :disabled="progress" v-else>
<template v-if="progress">
{{ $t('user_card.block_progress') }}
</template>
<template v-else>
{{ $t('user_card.block') }}
</template>
</button>
</div>
</basic-user-card>
</template>
<script src="./block_card.js"></script>
<style lang="scss">
.block-card-content-container {
margin-top: 0.5em;
text-align: right;
button {
width: 10em;
}
}
</style>

View File

@ -1,4 +1,7 @@
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const chatPanel = {
props: [ 'floating' ],
data () {
return {
currentMessage: '',
@ -18,6 +21,9 @@ const chatPanel = {
},
togglePanel () {
this.collapsed = !this.collapsed
},
userProfileLink (user) {
return generateProfileLink(user.id, user.username, this.$store.state.instance.restrictedNicknames)
}
}
}

View File

@ -1,10 +1,10 @@
<template>
<div class="chat-panel" v-if="!this.collapsed">
<div class="chat-panel" v-if="!this.collapsed || !this.floating">
<div class="panel panel-default">
<div class="panel-heading timeline-heading chat-heading" @click.stop.prevent="togglePanel">
<div class="panel-heading timeline-heading" :class="{ 'chat-heading': floating }" @click.stop.prevent="togglePanel">
<div class="title">
{{$t('chat.title')}}
<i class="icon-cancel" style="float: right;"></i>
<span>{{$t('chat.title')}}</span>
<i class="icon-cancel" v-if="floating"></i>
</div>
</div>
<div class="chat-window" v-chat-scroll>
@ -13,8 +13,10 @@
<img :src="message.author.avatar" />
</span>
<div class="chat-content">
<router-link class="chat-name" :to="{ name: 'user-profile', params: { id: message.author.id } }">
{{message.author.username}}
<router-link
class="chat-name"
:to="userProfileLink(message.author)">
{{message.author.username}}
</router-link>
<br>
<span class="chat-text">
@ -50,24 +52,25 @@
right: 0px;
bottom: 0px;
z-index: 1000;
max-width: 25em;
}
.chat-heading {
cursor: pointer;
.icon-comment-empty {
color: $fallback--fg;
color: var(--fg, $fallback--fg);
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
.chat-window {
width: 345px;
max-height: 40vh;
overflow-y: auto;
overflow-x: hidden;
max-height: 20em;
}
.chat-name {
.chat-window-container {
height: 100%;
}
.chat-message {
@ -95,4 +98,11 @@
resize: none;
}
}
.chat-panel {
.title {
display: flex;
justify-content: space-between;
}
}
</style>

View File

@ -0,0 +1,75 @@
<template>
<label class="checkbox">
<input type="checkbox" :checked="checked" @change="$emit('change', $event.target.checked)" :indeterminate.prop="indeterminate">
<i class="checkbox-indicator" />
<span v-if="!!$slots.default"><slot></slot></span>
</label>
</template>
<script>
export default {
model: {
prop: 'checked',
event: 'change'
},
props: ['checked', 'indeterminate']
}
</script>
<style lang="scss">
@import '../../_variables.scss';
.checkbox {
position: relative;
display: inline-block;
padding-left: 1.2em;
min-height: 1.2em;
&-indicator::before {
position: absolute;
left: 0;
top: 0;
display: block;
content: '✔';
transition: color 200ms;
width: 1.1em;
height: 1.1em;
border-radius: $fallback--checkboxRadius;
border-radius: var(--checkboxRadius, $fallback--checkboxRadius);
box-shadow: 0px 0px 2px black inset;
box-shadow: var(--inputShadow);
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
vertical-align: top;
text-align: center;
line-height: 1.1em;
font-size: 1.1em;
color: transparent;
overflow: hidden;
box-sizing: border-box;
}
input[type=checkbox] {
display: none;
&:checked + .checkbox-indicator::before {
color: $fallback--text;
color: var(--text, $fallback--text);
}
&:indeterminate + .checkbox-indicator::before {
content: '';
color: $fallback--text;
color: var(--text, $fallback--text);
}
&:disabled + .checkbox-indicator::before {
opacity: .5;
}
}
& > span {
margin-left: .5em;
}
}
</style>

View File

@ -0,0 +1,53 @@
<template>
<div class="color-control style-control" :class="{ disabled: !present || disabled }">
<label :for="name" class="label">
{{label}}
</label>
<input
v-if="typeof fallback !== 'undefined'"
class="opt exlcude-disabled"
:id="name + '-o'"
type="checkbox"
:checked="present"
@input="$emit('input', typeof value === 'undefined' ? fallback : undefined)">
<label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label>
<input
:id="name"
class="color-input"
type="color"
:value="value || fallback"
:disabled="!present || disabled"
@input="$emit('input', $event.target.value)"
>
<input
:id="name + '-t'"
class="text-input"
type="text"
:value="value || fallback"
:disabled="!present || disabled"
@input="$emit('input', $event.target.value)"
>
</div>
</template>
<script>
export default {
props: [
'name', 'label', 'value', 'fallback', 'disabled'
],
computed: {
present () {
return typeof this.value !== 'undefined'
}
}
}
</script>
<style lang="scss">
.color-control {
input.text-input {
max-width: 7em;
flex: 1;
}
}
</style>

View File

@ -0,0 +1,69 @@
<template>
<span v-if="contrast" class="contrast-ratio">
<span :title="hint" class="rating">
<span v-if="contrast.aaa">
<i class="icon-thumbs-up-alt"/>
</span>
<span v-if="!contrast.aaa && contrast.aa">
<i class="icon-adjust"/>
</span>
<span v-if="!contrast.aaa && !contrast.aa">
<i class="icon-attention"/>
</span>
</span>
<span class="rating" v-if="contrast && large" :title="hint_18pt">
<span v-if="contrast.laaa">
<i class="icon-thumbs-up-alt"/>
</span>
<span v-if="!contrast.laaa && contrast.laa">
<i class="icon-adjust"/>
</span>
<span v-if="!contrast.laaa && !contrast.laa">
<i class="icon-attention"/>
</span>
</span>
</span>
</template>
<script>
export default {
props: [
'large', 'contrast'
],
computed: {
hint () {
const levelVal = this.contrast.aaa ? 'aaa' : (this.contrast.aa ? 'aa' : 'bad')
const level = this.$t(`settings.style.common.contrast.level.${levelVal}`)
const context = this.$t('settings.style.common.contrast.context.text')
const ratio = this.contrast.text
return this.$t('settings.style.common.contrast.hint', { level, context, ratio })
},
hint_18pt () {
const levelVal = this.contrast.laaa ? 'aaa' : (this.contrast.laa ? 'aa' : 'bad')
const level = this.$t(`settings.style.common.contrast.level.${levelVal}`)
const context = this.$t('settings.style.common.contrast.context.18pt')
const ratio = this.contrast.text
return this.$t('settings.style.common.contrast.hint', { level, context, ratio })
}
}
}
</script>
<style lang="scss">
.contrast-ratio {
display: flex;
justify-content: flex-end;
margin-top: -4px;
margin-bottom: 5px;
.label {
margin-right: 1em;
}
.rating {
display: inline-block;
text-align: center;
}
}
</style>

View File

@ -1,5 +1,4 @@
import Conversation from '../conversation/conversation.vue'
import { find, toInteger } from 'lodash'
const conversationPage = {
components: {
@ -7,9 +6,9 @@ const conversationPage = {
},
computed: {
statusoid () {
const id = toInteger(this.$route.params.id)
const statuses = this.$store.state.statuses.allStatuses
const status = find(statuses, {id})
const id = this.$route.params.id
const statuses = this.$store.state.statuses.allStatusesObject
const status = statuses[id]
return status
}

View File

@ -1,5 +1,9 @@
<template>
<conversation :collapsable="false" :statusoid="statusoid"></conversation>
<conversation
:collapsable="false"
isPage="true"
:statusoid="statusoid"
></conversation>
</template>
<script src="./conversation-page.js"></script>

View File

@ -1,38 +1,94 @@
import { reduce, filter, sortBy } from 'lodash'
import { statusType } from '../../modules/statuses.js'
import { reduce, filter, findIndex, clone } from 'lodash'
import Status from '../status/status.vue'
const sortAndFilterConversation = (conversation) => {
conversation = filter(conversation, (status) => statusType(status) !== 'retweet')
return sortBy(conversation, 'id')
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id
const seqA = Number(idA)
const seqB = Number(idB)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA < seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return -1
} else if (!isSeqA && isSeqB) {
return 1
} else {
return idA < idB ? -1 : 1
}
}
const sortAndFilterConversation = (conversation, statusoid) => {
if (statusoid.type === 'retweet') {
conversation = filter(
conversation,
(status) => (status.type === 'retweet' || status.id !== statusoid.retweeted_status.id)
)
} else {
conversation = filter(conversation, (status) => status.type !== 'retweet')
}
return conversation.filter(_ => _).sort(sortById)
}
const conversation = {
data () {
return {
highlight: null
highlight: null,
expanded: false
}
},
props: [
'statusoid',
'collapsable'
'collapsable',
'isPage'
],
created () {
if (this.isPage) {
this.fetchConversation()
}
},
computed: {
status () { return this.statusoid },
status () {
return this.statusoid
},
statusId () {
if (this.statusoid.retweeted_status) {
return this.statusoid.retweeted_status.id
} else {
return this.statusoid.id
}
},
conversationId () {
if (this.statusoid.retweeted_status) {
return this.statusoid.retweeted_status.statusnet_conversation_id
} else {
return this.statusoid.statusnet_conversation_id
}
},
conversation () {
if (!this.status) {
return false
return []
}
const conversationId = this.status.statusnet_conversation_id
const statuses = this.$store.state.statuses.allStatuses
const conversation = filter(statuses, { statusnet_conversation_id: conversationId })
return sortAndFilterConversation(conversation)
if (!this.isExpanded) {
return [this.status]
}
const conversation = clone(this.$store.state.statuses.conversationsObject[this.conversationId])
const statusIndex = findIndex(conversation, { id: this.statusId })
if (statusIndex !== -1) {
conversation[statusIndex] = this.status
}
return sortAndFilterConversation(conversation, this.status)
},
replies () {
let i = 1
return reduce(this.conversation, (result, {id, in_reply_to_status_id}) => {
const irid = Number(in_reply_to_status_id)
/* eslint-disable camelcase */
const irid = in_reply_to_status_id
/* eslint-enable camelcase */
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
@ -43,24 +99,31 @@ const conversation = {
i++
return result
}, {})
},
isExpanded () {
return this.expanded || this.isPage
}
},
components: {
Status
},
created () {
this.fetchConversation()
},
watch: {
'$route': 'fetchConversation'
'$route': 'fetchConversation',
expanded (value) {
if (value) {
this.fetchConversation()
}
}
},
methods: {
fetchConversation () {
if (this.status) {
const conversationId = this.status.statusnet_conversation_id
this.$store.state.api.backendInteractor.fetchConversation({id: conversationId})
.then((statuses) => this.$store.dispatch('addNewStatuses', { statuses }))
.then(() => this.setHighlight(this.statusoid.id))
this.$store.state.api.backendInteractor.fetchConversation({id: this.status.id})
.then(({ancestors, descendants}) => {
this.$store.dispatch('addNewStatuses', { statuses: ancestors })
this.$store.dispatch('addNewStatuses', { statuses: descendants })
})
.then(() => this.setHighlight(this.statusId))
} else {
const id = this.$route.params.id
this.$store.state.api.backendInteractor.fetchStatus({id})
@ -69,18 +132,22 @@ const conversation = {
}
},
getReplies (id) {
id = Number(id)
return this.replies[id] || []
},
focused (id) {
if (this.statusoid.retweeted_status) {
return (id === this.statusoid.retweeted_status.id)
} else {
return (id === this.statusoid.id)
}
return (this.isExpanded) && id === this.status.id
},
setHighlight (id) {
this.highlight = Number(id)
this.highlight = id
},
getHighlight () {
return this.isExpanded ? this.highlight : null
},
toggleExpanded () {
this.expanded = !this.expanded
if (!this.expanded) {
this.setHighlight(null)
}
}
}
}

View File

@ -1,26 +1,42 @@
<template>
<div class="timeline panel panel-default">
<div class="panel-heading conversation-heading">
{{ $t('timeline.conversation') }}
<span v-if="collapsable" style="float:right;">
<small><a href="#" @click.prevent="$emit('toggleExpanded')">{{ $t('timeline.collapse') }}</a></small>
<div class="timeline panel-default" :class="[isExpanded ? 'panel' : 'panel-disabled']">
<div v-if="isExpanded" class="panel-heading conversation-heading">
<span class="title"> {{ $t('timeline.conversation') }} </span>
<span v-if="collapsable">
<a href="#" @click.prevent="toggleExpanded">{{ $t('timeline.collapse') }}</a>
</span>
</div>
<div class="panel-body">
<div class="timeline">
<status
v-for="status in conversation"
@goto="setHighlight" :key="status.id"
:inlineExpanded="collapsable" :statusoid="status"
:expandable='false' :focused="focused(status.id)"
:inConversation='true'
:highlight="highlight"
:replies="getReplies(status.id)"
class="status-fadein">
</status>
</div>
</div>
<status
v-for="status in conversation"
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
:key="status.id"
:inlineExpanded="collapsable"
:statusoid="status"
:expandable='!isExpanded'
:focused="focused(status.id)"
:inConversation="isExpanded"
:highlight="getHighlight()"
:replies="getReplies(status.id)"
class="status-fadein panel-body"
/>
</div>
</template>
<script src="./conversation.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.timeline {
.panel-disabled {
.status-el {
border-left: none;
border-bottom-width: 1px;
border-bottom-style: solid;
border-color: var(--border, $fallback--border);
border-radius: 0;
}
}
}
</style>

View File

@ -10,7 +10,11 @@ const DeleteButton = {
},
computed: {
currentUser () { return this.$store.state.users.currentUser },
canDelete () { return this.currentUser && this.currentUser.rights.delete_others_notice || this.status.user.id === this.currentUser.id }
canDelete () {
if (!this.currentUser) { return }
const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin
return superuser || this.status.user.id === this.currentUser.id
}
}
}

View File

@ -1,7 +1,7 @@
<template>
<div v-if="canDelete">
<a href="#" v-on:click.prevent="deleteStatus()">
<i class='icon-cancel delete-status'></i>
<i class='button-icon icon-cancel delete-status'></i>
</a>
</div>
</template>
@ -14,8 +14,8 @@
.icon-cancel,.delete-status {
cursor: pointer;
&:hover {
color: var(--cRed, $fallback--cRed);
color: $fallback--cRed;
color: var(--cRed, $fallback--cRed);
}
}
</style>

View File

@ -0,0 +1,14 @@
const DialogModal = {
props: {
darkOverlay: {
default: true,
type: Boolean
},
onCancel: {
default: () => {},
type: Function
}
}
}
export default DialogModal

View File

@ -0,0 +1,92 @@
<template>
<span v-bind:class="{ 'dark-overlay': darkOverlay }" @click.self.stop='onCancel()'>
<div class="dialog-modal panel panel-default" @click.stop=''>
<div class="panel-heading dialog-modal-heading">
<div class="title">
<slot name="header"></slot>
</div>
</div>
<div class="dialog-modal-content">
<slot name="default"></slot>
</div>
<div class="dialog-modal-footer user-interactions panel-footer">
<slot name="footer"></slot>
</div>
</div>
</span>
</template>
<script src="./dialog_modal.js"></script>
<style lang="scss">
@import '../../_variables.scss';
// TODO: unify with other modals.
.dark-overlay {
&::before {
bottom: 0;
content: " ";
display: block;
cursor: default;
left: 0;
position: fixed;
right: 0;
top: 0;
background: rgba(27,31,35,.5);
z-index: 99;
}
}
.dialog-modal.panel {
top: 0;
left: 50%;
max-height: 80vh;
max-width: 90vw;
margin: 15vh auto;
position: fixed;
transform: translateX(-50%);
z-index: 999;
cursor: default;
display: block;
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
.dialog-modal-heading {
padding: .5em .5em;
margin-right: auto;
margin-bottom: 0;
white-space: nowrap;
color: var(--panelText);
background-color: $fallback--fg;
background-color: var(--panel, $fallback--fg);
.title {
margin-bottom: 0;
}
}
.dialog-modal-content {
margin: 0;
padding: 1rem 1rem;
background-color: $fallback--lightBg;
background-color: var(--lightBg, $fallback--lightBg);
white-space: normal;
}
.dialog-modal-footer {
margin: 0;
padding: .5em .5em;
background-color: $fallback--lightBg;
background-color: var(--lightBg, $fallback--lightBg);
border-top: 1px solid $fallback--bg;
border-top: 1px solid var(--bg, $fallback--bg);
justify-content: flex-end;
button {
width: auto;
margin-left: .5rem;
}
}
}
</style>

View File

@ -0,0 +1,14 @@
import Timeline from '../timeline/timeline.vue'
const DMs = {
computed: {
timeline () {
return this.$store.state.statuses.timelines.dms
}
},
components: {
Timeline
}
}
export default DMs

View File

@ -0,0 +1,5 @@
<template>
<Timeline :title="$t('nav.dms')" v-bind:timeline="timeline" v-bind:timeline-name="'dms'"/>
</template>
<script src="./dm_timeline.js"></script>

View File

@ -0,0 +1,107 @@
import Completion from '../../services/completion/completion.js'
import { take, filter, map } from 'lodash'
const EmojiInput = {
props: [
'value',
'placeholder',
'type',
'classname'
],
data () {
return {
highlighted: 0,
caret: 0
}
},
computed: {
suggestions () {
const firstchar = this.textAtCaret.charAt(0)
if (firstchar === ':') {
if (this.textAtCaret === ':') { return }
const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1)))
if (matchedEmoji.length <= 0) {
return false
}
return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({
shortcode: `:${shortcode}:`,
utf: utf || '',
// eslint-disable-next-line camelcase
img: utf ? '' : this.$store.state.instance.server + image_url,
highlighted: index === this.highlighted
}))
} else {
return false
}
},
textAtCaret () {
return (this.wordAtCaret || {}).word || ''
},
wordAtCaret () {
const word = Completion.wordAtPosition(this.value, this.caret - 1) || {}
return word
},
emoji () {
return this.$store.state.instance.emoji || []
},
customEmoji () {
return this.$store.state.instance.customEmoji || []
}
},
methods: {
replace (replacement) {
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
this.caret = 0
},
replaceEmoji (e) {
const len = this.suggestions.length || 0
if (this.textAtCaret === ':' || e.ctrlKey) { return }
if (len > 0) {
e.preventDefault()
const emoji = this.suggestions[this.highlighted]
const replacement = emoji.utf || (emoji.shortcode + ' ')
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
this.caret = 0
this.highlighted = 0
}
},
cycleBackward (e) {
const len = this.suggestions.length || 0
if (len > 0) {
e.preventDefault()
this.highlighted -= 1
if (this.highlighted < 0) {
this.highlighted = this.suggestions.length - 1
}
} else {
this.highlighted = 0
}
},
cycleForward (e) {
const len = this.suggestions.length || 0
if (len > 0) {
if (e.shiftKey) { return }
e.preventDefault()
this.highlighted += 1
if (this.highlighted >= len) {
this.highlighted = 0
}
} else {
this.highlighted = 0
}
},
onKeydown (e) {
e.stopPropagation()
},
onInput (e) {
this.$emit('input', e.target.value)
},
setCaret ({target: {selectionStart}}) {
this.caret = selectionStart
}
}
}
export default EmojiInput

View File

@ -0,0 +1,64 @@
<template>
<div class="emoji-input">
<input
v-if="type !== 'textarea'"
:class="classname"
:type="type"
:value="value"
:placeholder="placeholder"
@input="onInput"
@click="setCaret"
@keyup="setCaret"
@keydown="onKeydown"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceEmoji"
/>
<textarea
v-else
:class="classname"
:value="value"
:placeholder="placeholder"
@input="onInput"
@click="setCaret"
@keyup="setCaret"
@keydown="onKeydown"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceEmoji"
></textarea>
<div class="autocomplete-panel" v-if="suggestions">
<div class="autocomplete-panel-body">
<div
v-for="(emoji, index) in suggestions"
:key="index"
@click="replace(emoji.utf || (emoji.shortcode + ' '))"
class="autocomplete-item"
:class="{ highlighted: emoji.highlighted }"
>
<span v-if="emoji.img">
<img :src="emoji.img" />
</span>
<span v-else>{{emoji.utf}}</span>
<span>{{emoji.shortcode}}</span>
</div>
</div>
</div>
</div>
</template>
<script src="./emoji-input.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.emoji-input {
.form-control {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,87 @@
<template>
<div class="import-export-container">
<slot name="before"/>
<button class="btn" @click="exportData">{{ exportLabel }}</button>
<button class="btn" @click="importData">{{ importLabel }}</button>
<slot name="afterButtons"/>
<p v-if="importFailed" class="alert error">{{ importFailedText }}</p>
<slot name="afterError"/>
</div>
</template>
<script>
export default {
props: [
'exportObject',
'importLabel',
'exportLabel',
'importFailedText',
'validator',
'onImport',
'onImportFailure'
],
data () {
return {
importFailed: false
}
},
methods: {
exportData () {
const stringified = JSON.stringify(this.exportObject) // Pretty-print and indent with 2 spaces
// Create an invisible link with a data url and simulate a click
const e = document.createElement('a')
e.setAttribute('download', 'pleroma_theme.json')
e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified))
e.style.display = 'none'
document.body.appendChild(e)
e.click()
document.body.removeChild(e)
},
importData () {
this.importFailed = false
const filePicker = document.createElement('input')
filePicker.setAttribute('type', 'file')
filePicker.setAttribute('accept', '.json')
filePicker.addEventListener('change', event => {
if (event.target.files[0]) {
// eslint-disable-next-line no-undef
const reader = new FileReader()
reader.onload = ({target}) => {
try {
const parsed = JSON.parse(target.result)
const valid = this.validator(parsed)
if (valid) {
this.onImport(parsed)
} else {
this.importFailed = true
// this.onImportFailure(valid)
}
} catch (e) {
// This will happen both if there is a JSON syntax error or the theme is missing components
this.importFailed = true
// this.onImportFailure(e)
}
}
reader.readAsText(event.target.files[0])
}
})
document.body.appendChild(filePicker)
filePicker.click()
document.body.removeChild(filePicker)
}
}
}
</script>
<style lang="scss">
.import-export-container {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: center;
}
</style>

View File

@ -2,6 +2,9 @@ const FavoriteButton = {
props: ['status', 'loggedIn'],
data () {
return {
hidePostStatsLocal: typeof this.$store.state.config.hidePostStats === 'undefined'
? this.$store.state.instance.hidePostStats
: this.$store.state.config.hidePostStats,
animated: false
}
},

View File

@ -1,11 +1,11 @@
<template>
<div v-if="loggedIn">
<i :class='classes' class='favorite-button fav-active' @click.prevent='favorite()'/>
<span v-if='status.fave_num > 0'>{{status.fave_num}}</span>
<i :class='classes' class='button-icon favorite-button fav-active' @click.prevent='favorite()' :title="$t('tool_tip.favorite')"/>
<span v-if='!hidePostStatsLocal && status.fave_num > 0'>{{status.fave_num}}</span>
</div>
<div v-else>
<i :class='classes' class='favorite-button'/>
<span v-if='status.fave_num > 0'>{{status.fave_num}}</span>
<i :class='classes' class='button-icon favorite-button' :title="$t('tool_tip.favorite')"/>
<span v-if='!hidePostStatsLocal && status.fave_num > 0'>{{status.fave_num}}</span>
</div>
</template>

View File

@ -0,0 +1,14 @@
const FeaturesPanel = {
computed: {
chat: function () {
return this.$store.state.instance.chatAvailable && (!this.$store.state.chatDisabled)
},
gopher: function () { return this.$store.state.instance.gopherAvailable },
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },
minimalScopesMode: function () { return this.$store.state.instance.minimalScopesMode },
textlimit: function () { return this.$store.state.instance.textlimit }
}
}
export default FeaturesPanel

View File

@ -0,0 +1,29 @@
<template>
<div class="features-panel">
<div class="panel panel-default base01-background">
<div class="panel-heading timeline-heading base02-background base04">
<div class="title">
{{$t('features_panel.title')}}
</div>
</div>
<div class="panel-body features-panel">
<ul>
<li v-if="chat">{{$t('features_panel.chat')}}</li>
<li v-if="gopher">{{$t('features_panel.gopher')}}</li>
<li v-if="whoToFollow">{{$t('features_panel.who_to_follow')}}</li>
<li v-if="mediaProxy">{{$t('features_panel.media_proxy')}}</li>
<li>{{$t('features_panel.scope_options')}}</li>
<li>{{$t('features_panel.text_limit')}} = {{textlimit}}</li>
</ul>
</div>
</div>
</div>
</template>
<script src="./features_panel.js" ></script>
<style lang="scss">
.features-panel li {
line-height: 24px;
}
</style>

View File

@ -0,0 +1,50 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
const FollowCard = {
props: [
'user',
'noFollowsYou'
],
data () {
return {
inProgress: false,
requestSent: false,
updated: false
}
},
components: {
BasicUserCard,
RemoteFollow
},
computed: {
isMe () { return this.$store.state.users.currentUser.id === this.user.id },
following () { return this.updated ? this.updated.following : this.user.following },
showFollow () {
return !this.following || this.updated && !this.updated.following
},
loggedIn () {
return this.$store.state.users.currentUser
}
},
methods: {
followUser () {
this.inProgress = true
requestFollow(this.user, this.$store).then(({ sent, updated }) => {
this.inProgress = false
this.requestSent = sent
this.updated = updated
})
},
unfollowUser () {
this.inProgress = true
requestUnfollow(this.user, this.$store).then(({ updated }) => {
this.inProgress = false
this.updated = updated
})
}
}
}
export default FollowCard

View File

@ -0,0 +1,58 @@
<template>
<basic-user-card :user="user">
<div class="follow-card-content-container">
<span class="faint" v-if="!noFollowsYou && user.follows_you">
{{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }}
</span>
<div class="follow-card-follow-button" v-if="showFollow && !loggedIn">
<RemoteFollow :user="user" />
</div>
<button
v-if="showFollow && loggedIn"
class="btn btn-default follow-card-follow-button"
@click="followUser"
:disabled="inProgress"
:title="requestSent ? $t('user_card.follow_again') : ''"
>
<template v-if="inProgress">
{{ $t('user_card.follow_progress') }}
</template>
<template v-else-if="requestSent">
{{ $t('user_card.follow_sent') }}
</template>
<template v-else>
{{ $t('user_card.follow') }}
</template>
</button>
<button v-if="following" class="btn btn-default follow-card-follow-button pressed" @click="unfollowUser" :disabled="inProgress">
<template v-if="inProgress">
{{ $t('user_card.follow_progress') }}
</template>
<template v-else>
{{ $t('user_card.follow_unfollow') }}
</template>
</button>
</div>
</basic-user-card>
</template>
<script src="./follow_card.js"></script>
<style lang="scss">
.follow-card {
&-content-container {
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
flex-wrap: wrap;
line-height: 1.5em;
}
&-follow-button {
margin-top: 0.5em;
margin-left: auto;
width: 10em;
}
}
</style>

View File

@ -0,0 +1,20 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const FollowRequestCard = {
props: ['user'],
components: {
BasicUserCard
},
methods: {
approveUser () {
this.$store.state.api.backendInteractor.approveUser(this.user.id)
this.$store.dispatch('removeFollowRequest', this.user)
},
denyUser () {
this.$store.state.api.backendInteractor.denyUser(this.user.id)
this.$store.dispatch('removeFollowRequest', this.user)
}
}
}
export default FollowRequestCard

View File

@ -0,0 +1,29 @@
<template>
<basic-user-card :user="user">
<div class="follow-request-card-content-container">
<button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button>
<button class="btn btn-default" @click="denyUser">{{ $t('user_card.deny') }}</button>
</div>
</basic-user-card>
</template>
<script src="./follow_request_card.js"></script>
<style lang="scss">
.follow-request-card-content-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
button {
margin-top: 0.5em;
margin-right: 0.5em;
flex: 1 1;
max-width: 12em;
min-width: 8em;
&:last-child {
margin-right: 0;
}
}
}
</style>

View File

@ -0,0 +1,14 @@
import FollowRequestCard from '../follow_request_card/follow_request_card.vue'
const FollowRequests = {
components: {
FollowRequestCard
},
computed: {
requests () {
return this.$store.state.api.followRequests
}
}
}
export default FollowRequests

View File

@ -0,0 +1,12 @@
<template>
<div class="settings panel panel-default">
<div class="panel-heading">
{{$t('nav.friend_requests')}}
</div>
<div class="panel-body">
<FollowRequestCard v-for="request in requests" :key="request.id" :user="request" class="list-item"/>
</div>
</div>
</template>
<script src="./follow_requests.js"></script>

View File

@ -0,0 +1,58 @@
import { set } from 'vue'
export default {
props: [
'name', 'label', 'value', 'fallback', 'options', 'no-inherit'
],
data () {
return {
lValue: this.value,
availableOptions: [
this.noInherit ? '' : 'inherit',
'custom',
...(this.options || []),
'serif',
'monospace',
'sans-serif'
].filter(_ => _)
}
},
beforeUpdate () {
this.lValue = this.value
},
computed: {
present () {
return typeof this.lValue !== 'undefined'
},
dValue () {
return this.lValue || this.fallback || {}
},
family: {
get () {
return this.dValue.family
},
set (v) {
set(this.lValue, 'family', v)
this.$emit('input', this.lValue)
}
},
isCustom () {
return this.preset === 'custom'
},
preset: {
get () {
if (this.family === 'serif' ||
this.family === 'sans-serif' ||
this.family === 'monospace' ||
this.family === 'inherit') {
return this.family
} else {
return 'custom'
}
},
set (v) {
this.family = v === 'custom' ? '' : v
}
}
}
}

View File

@ -0,0 +1,54 @@
<template>
<div class="font-control style-control" :class="{ custom: isCustom }">
<label :for="preset === 'custom' ? name : name + '-font-switcher'" class="label">
{{label}}
</label>
<input
v-if="typeof fallback !== 'undefined'"
class="opt exlcude-disabled"
type="checkbox"
:id="name + '-o'"
:checked="present"
@input="$emit('input', typeof value === 'undefined' ? fallback : undefined)">
<label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label>
<label :for="name + '-font-switcher'" class="select" :disabled="!present">
<select
:disabled="!present"
v-model="preset"
class="font-switcher"
:id="name + '-font-switcher'">
<option v-for="option in availableOptions" :value="option">
{{ option === 'custom' ? $t('settings.style.fonts.custom') : option }}
</option>
</select>
<i class="icon-down-open"/>
</label>
<input
v-if="isCustom"
class="custom-font"
type="text"
:id="name"
v-model="family">
</div>
</template>
<script src="./font_control.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
.font-control {
input.custom-font {
min-width: 10em;
}
&.custom {
.select {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.custom-font {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
}
</style>

View File

@ -0,0 +1,55 @@
import Attachment from '../attachment/attachment.vue'
import { chunk, last, dropRight } from 'lodash'
const Gallery = {
data: () => ({
width: 500
}),
props: [
'attachments',
'nsfw',
'setMedia'
],
components: { Attachment },
mounted () {
this.resize()
window.addEventListener('resize', this.resize)
},
destroyed () {
window.removeEventListener('resize', this.resize)
},
computed: {
rows () {
if (!this.attachments) {
return []
}
const rows = chunk(this.attachments, 3)
if (last(rows).length === 1 && rows.length > 1) {
// if 1 attachment on last row -> add it to the previous row instead
const lastAttachment = last(rows)[0]
const allButLastRow = dropRight(rows)
last(allButLastRow).push(lastAttachment)
return allButLastRow
}
return rows
},
rowHeight () {
return itemsPerRow => ({ 'height': `${(this.width / (itemsPerRow + 0.6))}px` })
},
useContainFit () {
return this.$store.state.config.useContainFit
}
},
methods: {
resize () {
// Quick optimization to make resizing not always trigger state change,
// only update attachment size in 10px steps
const width = Math.floor(this.$el.getBoundingClientRect().width / 10) * 10
if (this.width !== width) {
this.width = width
}
}
}
}
export default Gallery

View File

@ -0,0 +1,65 @@
<template>
<div ref="galleryContainer" style="width: 100%;">
<div class="gallery-row" v-for="row in rows" :style="rowHeight(row.length)" :class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }">
<attachment
v-for="attachment in row"
:setMedia="setMedia"
:nsfw="nsfw"
:attachment="attachment"
:allowPlay="false"
:key="attachment.id"
/>
</div>
</div>
</template>
<script src='./gallery.js'></script>
<style lang="scss">
@import '../../_variables.scss';
.gallery-row {
height: 200px;
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-content: stretch;
flex-grow: 1;
margin-top: 0.5em;
.attachments, .attachment {
margin: 0 0.5em 0 0;
flex-grow: 1;
height: 100%;
box-sizing: border-box;
// to make failed images a bit more noticeable on chromium
min-width: 2em;
&:last-child {
margin: 0;
}
}
.image-attachment {
width: 100%;
height: 100%;
}
.video-container {
height: 100%;
}
&.contain-fit {
img, video {
object-fit: contain;
}
}
&.cover-fit {
img, video {
object-fit: cover;
}
}
}
</style>

View File

@ -0,0 +1,146 @@
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
const ImageCropper = {
props: {
trigger: {
type: [String, window.Element],
required: true
},
submitHandler: {
type: Function,
required: true
},
cropperOptions: {
type: Object,
default () {
return {
aspectRatio: 1,
autoCropArea: 1,
viewMode: 1,
movable: false,
zoomable: false,
guides: false
}
}
},
mimes: {
type: String,
default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon'
},
saveButtonLabel: {
type: String
},
saveWithoutCroppingButtonlabel: {
type: String
},
cancelButtonLabel: {
type: String
}
},
data () {
return {
cropper: undefined,
dataUrl: undefined,
filename: undefined,
submitting: false,
submitError: null
}
},
computed: {
saveText () {
return this.saveButtonLabel || this.$t('image_cropper.save')
},
saveWithoutCroppingText () {
return this.saveWithoutCroppingButtonlabel || this.$t('image_cropper.save_without_cropping')
},
cancelText () {
return this.cancelButtonLabel || this.$t('image_cropper.cancel')
},
submitErrorMsg () {
return this.submitError && this.submitError instanceof Error ? this.submitError.toString() : this.submitError
}
},
methods: {
destroy () {
if (this.cropper) {
this.cropper.destroy()
}
this.$refs.input.value = ''
this.dataUrl = undefined
this.$emit('close')
},
submit () {
this.submitting = true
this.avatarUploadError = null
this.submitHandler(this.cropper, this.file)
.then(() => this.destroy())
.catch((err) => {
this.submitError = err
})
.finally(() => {
this.submitting = false
})
},
submitWithoutCropping () {
this.submitting = true
this.avatarUploadError = null
this.submitHandler(false, this.dataUrl)
.then(() => this.destroy())
.catch((err) => {
this.submitError = err
})
.finally(() => {
this.submitting = false
})
},
pickImage () {
this.$refs.input.click()
},
createCropper () {
this.cropper = new Cropper(this.$refs.img, this.cropperOptions)
},
getTriggerDOM () {
return typeof this.trigger === 'object' ? this.trigger : document.querySelector(this.trigger)
},
readFile () {
const fileInput = this.$refs.input
if (fileInput.files != null && fileInput.files[0] != null) {
this.file = fileInput.files[0]
let reader = new window.FileReader()
reader.onload = (e) => {
this.dataUrl = e.target.result
this.$emit('open')
}
reader.readAsDataURL(this.file)
this.$emit('changed', this.file, reader)
}
},
clearError () {
this.submitError = null
}
},
mounted () {
// listen for click event on trigger
const trigger = this.getTriggerDOM()
if (!trigger) {
this.$emit('error', 'No image make trigger found.', 'user')
} else {
trigger.addEventListener('click', this.pickImage)
}
// listen for input file changes
const fileInput = this.$refs.input
fileInput.addEventListener('change', this.readFile)
},
beforeDestroy: function () {
// remove the event listeners
const trigger = this.getTriggerDOM()
if (trigger) {
trigger.removeEventListener('click', this.pickImage)
}
const fileInput = this.$refs.input
fileInput.removeEventListener('change', this.readFile)
}
}
export default ImageCropper

View File

@ -0,0 +1,47 @@
<template>
<div class="image-cropper">
<div v-if="dataUrl">
<div class="image-cropper-image-container">
<img ref="img" :src="dataUrl" alt="" @load.stop="createCropper" />
</div>
<div class="image-cropper-buttons-wrapper">
<button class="btn" type="button" :disabled="submitting" @click="submit" v-text="saveText"></button>
<button class="btn" type="button" :disabled="submitting" @click="destroy" v-text="cancelText"></button>
<button class="btn" type="button" :disabled="submitting" @click="submitWithoutCropping" v-text="saveWithoutCroppingText"></button>
<i class="icon-spin4 animate-spin" v-if="submitting"></i>
</div>
<div class="alert error" v-if="submitError">
{{submitErrorMsg}}
<i class="button-icon icon-cancel" @click="clearError"></i>
</div>
</div>
<input ref="input" type="file" class="image-cropper-img-input" :accept="mimes">
</div>
</template>
<script src="./image_cropper.js"></script>
<style lang="scss">
.image-cropper {
&-img-input {
display: none;
}
&-image-container {
position: relative;
img {
display: block;
max-width: 100%;
}
}
&-buttons-wrapper {
margin-top: 10px;
button {
margin-top: 5px;
}
}
}
</style>

View File

@ -1,7 +1,10 @@
const InstanceSpecificPanel = {
computed: {
instanceSpecificPanelContent () {
return this.$store.state.config.instanceSpecificPanelContent
return this.$store.state.instance.instanceSpecificPanelContent
},
show () {
return !this.$store.state.config.hideISP
}
}
}

View File

@ -1,5 +1,5 @@
<template>
<div class="instance-specific-panel">
<div v-if="show" class="instance-specific-panel">
<div class="panel panel-default">
<div class="panel-body">
<div v-html="instanceSpecificPanelContent">

View File

@ -0,0 +1,41 @@
<template>
<div>
<label for="interface-language-switcher">
{{ $t('settings.interfaceLanguage') }}
</label>
<label for="interface-language-switcher" class='select'>
<select id="interface-language-switcher" v-model="language">
<option v-for="(langCode, i) in languageCodes" :value="langCode">
{{ languageNames[i] }}
</option>
</select>
<i class="icon-down-open"/>
</label>
</div>
</template>
<script>
import languagesObject from '../../i18n/messages'
import ISO6391 from 'iso-639-1'
import _ from 'lodash'
export default {
computed: {
languageCodes () {
return Object.keys(languagesObject)
},
languageNames () {
return _.map(this.languageCodes, ISO6391.getName)
},
language: {
get: function () { return this.$store.state.config.interfaceLanguage },
set: function (val) {
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
this.$i18n.locale = val
}
}
}
}
</script>

View File

@ -0,0 +1,21 @@
const LinkPreview = {
name: 'LinkPreview',
props: [
'card',
'size',
'nsfw'
],
computed: {
useImage () {
// Currently BE shoudn't give cards if tagged NSFW, this is a bit paranoid
// as it makes sure to hide the image if somehow NSFW tagged preview can
// exist.
return this.card.image && !this.nsfw && this.size !== 'hide'
},
useDescription () {
return this.card.description && /\S/.test(this.card.description)
}
}
}
export default LinkPreview

View File

@ -0,0 +1,76 @@
<template>
<div>
<a class="link-preview-card" :href="card.url" target="_blank" rel="noopener">
<div class="card-image" :class="{ 'small-image': size === 'small' }" v-if="useImage">
<img :src="card.image"></img>
</div>
<div class="card-content">
<span class="card-host faint">{{ card.provider_name }}</span>
<h4 class="card-title">{{ card.title }}</h4>
<p class="card-description" v-if="useDescription">{{ card.description }}</p>
</div>
</a>
</div>
</template>
<script src="./link-preview.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.link-preview-card {
display: flex;
flex-direction: row;
cursor: pointer;
overflow: hidden;
margin-top: 0.5em;
.card-image {
flex-shrink: 0;
width: 120px;
max-width: 25%;
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: $fallback--attachmentRadius;
border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
}
}
.small-image {
width: 80px;
}
.card-content {
max-height: 100%;
margin: 0.5em;
display: flex;
flex-direction: column;
}
.card-host {
font-size: 12px;
}
.card-description {
margin: 0.5em 0 0 0;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
line-height: 1.2em;
// cap description at 3 lines, the 1px is to clean up some stray pixels
// TODO: fancier fade-out at the bottom to show off that it's too long?
max-height: calc(1.2em * 3 - 1px);
}
color: $fallback--text;
color: var(--text, $fallback--text);
border-style: solid;
border-width: 1px;
border-radius: $fallback--attachmentRadius;
border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
}
</style>

View File

@ -0,0 +1,42 @@
<template>
<div class="list">
<div v-for="item in items" class="list-item" :key="getKey(item)">
<slot name="item" :item="item" />
</div>
<div class="list-empty-content faint" v-if="items.length === 0 && !!$slots.empty">
<slot name="empty" />
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
getKey: {
type: Function,
default: item => item.id
}
}
}
</script>
<style lang="scss">
@import '../../_variables.scss';
.list {
&-item:not(:last-child) {
border-bottom: 1px solid;
border-bottom-color: $fallback--border;
border-bottom-color: var(--border, $fallback--border);
}
&-empty-content {
text-align: center;
padding: 10px;
}
}
</style>

View File

@ -1,22 +1,54 @@
import oauthApi from '../../services/new_api/oauth.js'
const LoginForm = {
data: () => ({
user: {},
authError: false
}),
computed: {
loginMethod () { return this.$store.state.instance.loginMethod },
loggingIn () { return this.$store.state.users.loggingIn },
registrationOpen () { return this.$store.state.config.registrationOpen }
registrationOpen () { return this.$store.state.instance.registrationOpen }
},
methods: {
oAuthLogin () {
oauthApi.login({
oauth: this.$store.state.oauth,
instance: this.$store.state.instance.server,
commit: this.$store.commit
})
},
submit () {
this.$store.dispatch('loginUser', this.user).then(
() => {},
(error) => {
this.authError = error
this.user.username = ''
this.user.password = ''
}
)
const data = {
oauth: this.$store.state.oauth,
instance: this.$store.state.instance.server
}
this.clearError()
oauthApi.getOrCreateApp(data).then((app) => {
oauthApi.getTokenWithCredentials(
{
app,
instance: data.instance,
username: this.user.username,
password: this.user.password
}
).then(async (result) => {
if (result.error) {
this.authError = result.error
this.user.password = ''
return
}
this.$store.commit('setToken', result.access_token)
try {
await this.$store.dispatch('loginUser', result.access_token)
this.$router.push({name: 'friends'})
} catch (e) {
console.log(e)
}
})
})
},
clearError () {
this.authError = false
}
}
}

View File

@ -5,7 +5,7 @@
{{$t('login.login')}}
</div>
<div class="panel-body">
<form v-on:submit.prevent='submit(user)' class='login-form'>
<form v-if="loginMethod == 'password'" v-on:submit.prevent='submit(user)' class='login-form'>
<div class='form-group'>
<label for='username'>{{$t('login.username')}}</label>
<input :disabled="loggingIn" v-model='user.username' class='form-control' id='username' v-bind:placeholder="$t('login.placeholder')">
@ -20,10 +20,26 @@
<button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button>
</div>
</div>
<div v-if="authError" class='form-group'>
<div class='alert error'>{{authError}}</div>
</form>
<form v-if="loginMethod == 'token'" v-on:submit.prevent='oAuthLogin' class="login-form">
<div class="form-group">
<p>{{$t('login.description')}}</p>
</div>
<div class='form-group'>
<div class='login-bottom'>
<div><router-link :to="{name: 'registration'}" v-if='registrationOpen' class='register'>{{$t('login.register')}}</router-link></div>
<button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button>
</div>
</div>
</form>
<div v-if="authError" class='form-group'>
<div class='alert error'>
{{authError}}
<i class="button-icon icon-cancel" @click="clearError"></i>
</div>
</div>
</div>
</div>
</template>
@ -39,10 +55,6 @@
width: 10em;
}
.error {
text-align: center;
}
.register {
flex: 1 1;
}
@ -55,4 +67,14 @@
justify-content: space-between;
}
}
.login {
.error {
text-align: center;
animation-name: shakeError;
animation-duration: 0.4s;
animation-timing-function: ease-in-out;
}
}
</style>

View File

@ -0,0 +1,73 @@
import StillImage from '../still-image/still-image.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
const MediaModal = {
components: {
StillImage,
VideoAttachment
},
computed: {
showing () {
return this.$store.state.mediaViewer.activated
},
media () {
return this.$store.state.mediaViewer.media
},
currentIndex () {
return this.$store.state.mediaViewer.currentIndex
},
currentMedia () {
return this.media[this.currentIndex]
},
canNavigate () {
return this.media.length > 1
},
type () {
return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null
}
},
methods: {
hide () {
this.$store.dispatch('closeMediaViewer')
},
goPrev () {
if (this.canNavigate) {
const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1)
this.$store.dispatch('setCurrent', this.media[prevIndex])
}
},
goNext () {
if (this.canNavigate) {
const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1)
this.$store.dispatch('setCurrent', this.media[nextIndex])
}
},
handleKeyupEvent (e) {
if (this.showing && e.keyCode === 27) { // escape
this.hide()
}
},
handleKeydownEvent (e) {
if (!this.showing) {
return
}
if (e.keyCode === 39) { // arrow right
this.goNext()
} else if (e.keyCode === 37) { // arrow left
this.goPrev()
}
}
},
mounted () {
document.addEventListener('keyup', this.handleKeyupEvent)
document.addEventListener('keydown', this.handleKeydownEvent)
},
destroyed () {
document.removeEventListener('keyup', this.handleKeyupEvent)
document.removeEventListener('keydown', this.handleKeydownEvent)
}
}
export default MediaModal

View File

@ -0,0 +1,102 @@
<template>
<div class="modal-view media-modal-view" v-if="showing" @click.prevent="hide">
<img class="modal-image" v-if="type === 'image'" :src="currentMedia.url"></img>
<VideoAttachment
class="modal-image"
v-if="type === 'video'"
:attachment="currentMedia"
:controls="true"
@click.stop.native="">
</VideoAttachment>
<button
:title="$t('media_modal.previous')"
class="modal-view-button-arrow modal-view-button-arrow--prev"
v-if="canNavigate"
@click.stop.prevent="goPrev"
>
<i class="icon-left-open arrow-icon" />
</button>
<button
:title="$t('media_modal.next')"
class="modal-view-button-arrow modal-view-button-arrow--next"
v-if="canNavigate"
@click.stop.prevent="goNext"
>
<i class="icon-right-open arrow-icon" />
</button>
</div>
</template>
<script src="./media_modal.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.media-modal-view {
&:hover {
.modal-view-button-arrow {
opacity: 0.75;
&:focus,
&:hover {
outline: none;
box-shadow: none;
}
&:hover {
opacity: 1;
}
}
}
}
.modal-image {
max-width: 90%;
max-height: 90%;
box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
}
.modal-view-button-arrow {
position: absolute;
display: block;
top: 50%;
margin-top: -50px;
width: 70px;
height: 100px;
border: 0;
padding: 0;
opacity: 0;
box-shadow: none;
background: none;
appearance: none;
overflow: visible;
cursor: pointer;
transition: opacity 333ms cubic-bezier(.4,0,.22,1);
.arrow-icon {
position: absolute;
top: 35px;
height: 30px;
width: 32px;
font-size: 14px;
line-height: 30px;
color: #FFF;
text-align: center;
background-color: rgba(0,0,0,.3);
}
&--prev {
left: 0;
.arrow-icon {
left: 6px;
}
}
&--next {
right: 0;
.arrow-icon {
right: 6px;
}
}
}
</style>

View File

@ -1,26 +1,26 @@
/* eslint-env browser */
import statusPosterService from '../../services/status_poster/status_poster.service.js'
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
const mediaUpload = {
mounted () {
const input = this.$el.querySelector('input')
input.addEventListener('change', ({target}) => {
const file = target.files[0]
this.uploadFile(file)
})
},
data () {
return {
uploading: false
uploading: false,
uploadReady: true
}
},
methods: {
uploadFile (file) {
const self = this
const store = this.$store
if (file.size > store.state.instance.uploadlimit) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(store.state.instance.uploadlimit)
self.$emit('upload-failed', 'file_too_big', {filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit})
return
}
const formData = new FormData()
formData.append('media', file)
formData.append('file', file)
self.$emit('uploading')
self.uploading = true
@ -30,7 +30,7 @@ const mediaUpload = {
self.$emit('uploaded', fileData)
self.uploading = false
}, (error) => { // eslint-disable-line handle-callback-err
self.$emit('upload-failed')
self.$emit('upload-failed', 'default')
self.uploading = false
})
},
@ -47,6 +47,18 @@ const mediaUpload = {
} else {
e.dataTransfer.dropEffect = 'none'
}
},
clearFile () {
this.uploadReady = false
this.$nextTick(() => {
this.uploadReady = true
})
},
change ({target}) {
for (var i = 0; i < target.files.length; i++) {
let file = target.files[i]
this.uploadFile(file)
}
}
},
props: [

View File

@ -1,9 +1,9 @@
<template>
<div class="media-upload" @drop.prevent @dragover.prevent="fileDrag" @drop="fileDrop">
<label class="btn btn-default">
<label class="btn btn-default" :title="$t('tool_tip.media_upload')">
<i class="icon-spin4 animate-spin" v-if="uploading"></i>
<i class="icon-upload" v-if="!uploading"></i>
<input type=file style="position: fixed; top: -100em"></input>
<input type="file" v-if="uploadReady" @change="change" style="position: fixed; top: -100em" multiple="true"></input>
</label>
</div>
</template>

View File

@ -0,0 +1,77 @@
import SideDrawer from '../side_drawer/side_drawer.vue'
import Notifications from '../notifications/notifications.vue'
import MobilePostStatusModal from '../mobile_post_status_modal/mobile_post_status_modal.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service'
const MobileNav = {
components: {
SideDrawer,
Notifications,
MobilePostStatusModal
},
data: () => ({
notificationsCloseGesture: undefined,
notificationsOpen: false
}),
created () {
this.notificationsCloseGesture = GestureService.swipeGesture(
GestureService.DIRECTION_RIGHT,
this.closeMobileNotifications,
50
)
},
computed: {
currentUser () {
return this.$store.state.users.currentUser
},
unseenNotifications () {
return unseenNotificationsFromStore(this.$store)
},
unseenNotificationsCount () {
return this.unseenNotifications.length
},
sitename () { return this.$store.state.instance.name }
},
methods: {
toggleMobileSidebar () {
this.$refs.sideDrawer.toggleDrawer()
},
openMobileNotifications () {
this.notificationsOpen = true
},
closeMobileNotifications () {
if (this.notificationsOpen) {
// make sure to mark notifs seen only when the notifs were open and not
// from close-calls.
this.notificationsOpen = false
this.markNotificationsAsSeen()
}
},
notificationsTouchStart (e) {
GestureService.beginSwipe(e, this.notificationsCloseGesture)
},
notificationsTouchMove (e) {
GestureService.updateSwipe(e, this.notificationsCloseGesture)
},
scrollToTop () {
window.scrollTo(0, 0)
},
logout () {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
},
markNotificationsAsSeen () {
this.$refs.notifications.markAsSeen()
}
},
watch: {
$route () {
// handles closing notificaitons when you press any router-link on the
// notifications.
this.closeMobileNotifications()
}
}
}
export default MobileNav

View File

@ -0,0 +1,140 @@
<template>
<nav class='nav-bar container' id="nav">
<div class='mobile-inner-nav' @click="scrollToTop()">
<div class='item'>
<a href="#" class="mobile-nav-button" @click.stop.prevent="toggleMobileSidebar()">
<i class="button-icon icon-menu"></i>
</a>
<router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link>
</div>
<div class='item right'>
<a class="mobile-nav-button" v-if="currentUser" href="#" @click.stop.prevent="openMobileNotifications()">
<i class="button-icon icon-bell-alt"></i>
<div class="alert-dot" v-if="unseenNotificationsCount"></div>
</a>
</div>
</div>
<SideDrawer ref="sideDrawer" :logout="logout"/>
<div v-if="currentUser"
class="mobile-notifications-drawer"
:class="{ 'closed': !notificationsOpen }"
@touchstart="notificationsTouchStart"
@touchmove="notificationsTouchMove"
>
<div class="mobile-notifications-header">
<span class="title">{{$t('notifications.notifications')}}</span>
<a class="mobile-nav-button" @click.stop.prevent="closeMobileNotifications()">
<i class="button-icon icon-cancel"/>
</a>
</div>
<div v-if="currentUser" class="mobile-notifications">
<Notifications ref="notifications" noHeading="true"/>
</div>
</div>
<MobilePostStatusModal />
</nav>
</template>
<script src="./mobile_nav.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.mobile-inner-nav {
width: 100%;
display: flex;
align-items: center;
}
.mobile-nav-button {
display: flex;
justify-content: center;
width: 50px;
position: relative;
cursor: pointer;
}
.alert-dot {
border-radius: 100%;
height: 8px;
width: 8px;
position: absolute;
left: calc(50% - 4px);
top: calc(50% - 4px);
margin-left: 6px;
margin-top: -6px;
background-color: $fallback--cRed;
background-color: var(--badgeNotification, $fallback--cRed);
}
.mobile-notifications-drawer {
width: 100%;
height: 100vh;
overflow-x: hidden;
position: fixed;
top: 0;
left: 0;
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
box-shadow: var(--panelShadow);
transition-property: transform;
transition-duration: 0.25s;
transform: translateX(0);
&.closed {
transform: translateX(100%);
}
}
.mobile-notifications-header {
display: flex;
align-items: center;
justify-content: space-between;
z-index: 1;
width: 100%;
height: 50px;
line-height: 50px;
position: absolute;
color: var(--topBarText);
background-color: $fallback--fg;
background-color: var(--topBar, $fallback--fg);
box-shadow: 0px 0px 4px rgba(0,0,0,.6);
box-shadow: var(--topBarShadow);
.title {
font-size: 1.3em;
margin-left: 0.6em;
}
}
.mobile-notifications {
margin-top: 50px;
width: 100vw;
height: calc(100vh - 50px);
overflow-x: hidden;
overflow-y: scroll;
color: $fallback--text;
color: var(--text, $fallback--text);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
.notifications {
padding: 0;
border-radius: 0;
box-shadow: none;
.panel {
border-radius: 0;
margin: 0;
box-shadow: none;
}
.panel:after {
border-radius: 0;
}
.panel .panel-heading {
border-radius: 0;
box-shadow: none;
}
}
}
</style>

View File

@ -0,0 +1,91 @@
import PostStatusForm from '../post_status_form/post_status_form.vue'
import { throttle } from 'lodash'
const MobilePostStatusModal = {
components: {
PostStatusForm
},
data () {
return {
hidden: false,
postFormOpen: false,
scrollingDown: false,
inputActive: false,
oldScrollPos: 0,
amountScrolled: 0
}
},
created () {
window.addEventListener('scroll', this.handleScroll)
window.addEventListener('resize', this.handleOSK)
},
destroyed () {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('resize', this.handleOSK)
},
computed: {
currentUser () {
return this.$store.state.users.currentUser
},
isHidden () {
return this.hidden || this.inputActive
}
},
methods: {
openPostForm () {
this.postFormOpen = true
this.hidden = true
const el = this.$el.querySelector('textarea')
this.$nextTick(function () {
el.focus()
})
},
closePostForm () {
this.postFormOpen = false
this.hidden = false
},
handleOSK () {
// This is a big hack: we're guessing from changed window sizes if the
// on-screen keyboard is active or not. This is only really important
// for phones in portrait mode and it's more important to show the button
// in normal scenarios on all phones, than it is to hide it when the
// keyboard is active.
// Guesswork based on https://www.mydevice.io/#compare-devices
// for example, iphone 4 and android phones from the same time period
const smallPhone = window.innerWidth < 350
const smallPhoneKbOpen = smallPhone && window.innerHeight < 345
const biggerPhone = !smallPhone && window.innerWidth < 450
const biggerPhoneKbOpen = biggerPhone && window.innerHeight < 560
if (smallPhoneKbOpen || biggerPhoneKbOpen) {
this.inputActive = true
} else {
this.inputActive = false
}
},
handleScroll: throttle(function () {
const scrollAmount = window.scrollY - this.oldScrollPos
const scrollingDown = scrollAmount > 0
if (scrollingDown !== this.scrollingDown) {
this.amountScrolled = 0
this.scrollingDown = scrollingDown
if (!scrollingDown) {
this.hidden = false
}
} else if (scrollingDown) {
this.amountScrolled += scrollAmount
if (this.amountScrolled > 100 && !this.hidden) {
this.hidden = true
}
}
this.oldScrollPos = window.scrollY
this.scrollingDown = scrollingDown
}, 100)
}
}
export default MobilePostStatusModal

View File

@ -0,0 +1,76 @@
<template>
<div v-if="currentUser">
<div
class="post-form-modal-view modal-view"
v-show="postFormOpen"
@click="closePostForm"
>
<div class="post-form-modal-panel panel" @click.stop="">
<div class="panel-heading">{{$t('post_status.new_status')}}</div>
<PostStatusForm class="panel-body" @posted="closePostForm"/>
</div>
</div>
<button
class="new-status-button"
:class="{ 'hidden': isHidden }"
@click="openPostForm"
>
<i class="icon-edit" />
</button>
</div>
</template>
<script src="./mobile_post_status_modal.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.post-form-modal-view {
max-height: 100%;
display: block;
}
.post-form-modal-panel {
flex-shrink: 0;
margin: 25% 0 4em 0;
width: 100%;
}
.new-status-button {
width: 5em;
height: 5em;
border-radius: 100%;
position: fixed;
bottom: 1.5em;
right: 1.5em;
// TODO: this needs its own color, it has to stand out enough and link color
// is not very optimal for this particular use.
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3), 0px 4px 6px rgba(0, 0, 0, 0.3);
z-index: 10;
transition: 0.35s transform;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
&.hidden {
transform: translateY(150%);
}
i {
font-size: 1.5em;
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
@media all and (min-width: 801px) {
.new-status-button {
display: none;
}
}
</style>

View File

@ -0,0 +1,106 @@
import DialogModal from '../dialog_modal/dialog_modal.vue'
import Popper from 'vue-popperjs/src/component/popper.js.vue'
const FORCE_NSFW = 'mrf_tag:media-force-nsfw'
const STRIP_MEDIA = 'mrf_tag:media-strip'
const FORCE_UNLISTED = 'mrf_tag:force-unlisted'
const DISABLE_REMOTE_SUBSCRIPTION = 'mrf_tag:disable-remote-subscription'
const DISABLE_ANY_SUBSCRIPTION = 'mrf_tag:disable-any-subscription'
const SANDBOX = 'mrf_tag:sandbox'
const QUARANTINE = 'mrf_tag:quarantine'
const ModerationTools = {
props: [
'user'
],
data () {
return {
showDropDown: false,
tags: {
FORCE_NSFW,
STRIP_MEDIA,
FORCE_UNLISTED,
DISABLE_REMOTE_SUBSCRIPTION,
DISABLE_ANY_SUBSCRIPTION,
SANDBOX,
QUARANTINE
},
showDeleteUserDialog: false
}
},
components: {
DialogModal,
Popper
},
computed: {
tagsSet () {
return new Set(this.user.tags)
},
hasTagPolicy () {
return this.$store.state.instance.tagPolicyAvailable
}
},
methods: {
toggleMenu () {
this.showDropDown = !this.showDropDown
},
hasTag (tagName) {
return this.tagsSet.has(tagName)
},
toggleTag (tag) {
const store = this.$store
if (this.tagsSet.has(tag)) {
store.state.api.backendInteractor.untagUser(this.user, tag).then(response => {
if (!response.ok) { return }
store.commit('untagUser', {user: this.user, tag})
})
} else {
store.state.api.backendInteractor.tagUser(this.user, tag).then(response => {
if (!response.ok) { return }
store.commit('tagUser', {user: this.user, tag})
})
}
},
toggleRight (right) {
const store = this.$store
if (this.user.rights[right]) {
store.state.api.backendInteractor.deleteRight(this.user, right).then(response => {
if (!response.ok) { return }
store.commit('updateRight', {user: this.user, right: right, value: false})
})
} else {
store.state.api.backendInteractor.addRight(this.user, right).then(response => {
if (!response.ok) { return }
store.commit('updateRight', {user: this.user, right: right, value: true})
})
}
},
toggleActivationStatus () {
const store = this.$store
const status = !!this.user.deactivated
store.state.api.backendInteractor.setActivationStatus(this.user, status).then(response => {
if (!response.ok) { return }
store.commit('updateActivationStatus', {user: this.user, status: status})
})
},
deleteUserDialog (show) {
this.showDeleteUserDialog = show
},
deleteUser () {
const store = this.$store
const user = this.user
const {id, name} = user
store.state.api.backendInteractor.deleteUser(user)
.then(e => {
this.$store.dispatch('markStatusesAsDeleted', status => user.id === status.user.id)
const isProfile = this.$route.name === 'external-user-profile' || this.$route.name === 'user-profile'
const isTargetUser = this.$route.params.name === name || this.$route.params.id === id
if (isProfile && isTargetUser) {
window.history.back()
}
})
}
}
}
export default ModerationTools

View File

@ -0,0 +1,158 @@
<template>
<div class='block' style='position: relative'>
<Popper
trigger="click"
@hide='showDropDown = false'
append-to-body
:options="{
placement: 'bottom-end',
modifiers: {
arrow: { enabled: true },
offset: { offset: '0, 5px' },
}
}">
<div class="popper-wrapper">
<div class="dropdown-menu">
<span v-if='user.is_local'>
<button class="dropdown-item" @click='toggleRight("admin")'>
{{ $t(!!user.rights.admin ? 'user_card.admin_menu.revoke_admin' : 'user_card.admin_menu.grant_admin') }}
</button>
<button class="dropdown-item" @click='toggleRight("moderator")'>
{{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }}
</button>
<div role="separator" class="dropdown-divider"></div>
</span>
<button class="dropdown-item" @click='toggleActivationStatus()'>
{{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }}
</button>
<button class="dropdown-item" @click='deleteUserDialog(true)'>
{{ $t('user_card.admin_menu.delete_account') }}
</button>
<div role="separator" class="dropdown-divider" v-if='hasTagPolicy'></div>
<span v-if='hasTagPolicy'>
<button class="dropdown-item" @click='toggleTag(tags.FORCE_NSFW)'>
{{ $t('user_card.admin_menu.force_nsfw') }}
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }"></span>
</button>
<button class="dropdown-item" @click='toggleTag(tags.STRIP_MEDIA)'>
{{ $t('user_card.admin_menu.strip_media') }}
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }"></span>
</button>
<button class="dropdown-item" @click='toggleTag(tags.FORCE_UNLISTED)'>
{{ $t('user_card.admin_menu.force_unlisted') }}
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }"></span>
</button>
<button class="dropdown-item" @click='toggleTag(tags.SANDBOX)'>
{{ $t('user_card.admin_menu.sandbox') }}
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }"></span>
</button>
<button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)'>
{{ $t('user_card.admin_menu.disable_remote_subscription') }}
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }"></span>
</button>
<button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)'>
{{ $t('user_card.admin_menu.disable_any_subscription') }}
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }"></span>
</button>
<button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.QUARANTINE)'>
{{ $t('user_card.admin_menu.quarantine') }}
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }"></span>
</button>
</span>
</div>
</div>
<button slot="reference" v-bind:class="{ pressed: showDropDown }" @click='toggleMenu'>
{{ $t('user_card.admin_menu.moderation') }}
</button>
</Popper>
<DialogModal v-if="showDeleteUserDialog" :onCancel='deleteUserDialog.bind(this, false)'>
<span slot="header">{{ $t('user_card.admin_menu.delete_user') }}</span>
<p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p>
<span slot="footer">
<button @click='deleteUserDialog(false)'>
{{ $t('general.cancel') }}
</button>
<button class="danger" @click='deleteUser()'>
{{ $t('user_card.admin_menu.delete_user') }}
</button>
</span>
</DialogModal>
</div>
</template>
<script src="./moderation_tools.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@import '../popper/popper.scss';
.dropdown-menu {
display: block;
padding: .5rem 0;
font-size: 1rem;
text-align: left;
list-style: none;
max-width: 100vw;
z-index: 10;
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
box-shadow: var(--panelShadow);
border: none;
border-radius: $fallback--btnRadius;
border-radius: var(--btnRadius, $fallback--btnRadius);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
.dropdown-divider {
height: 0;
margin: .5rem 0;
overflow: hidden;
border-top: 1px solid $fallback--border;
border-top: 1px solid var(--border, $fallback--border);
}
.dropdown-item {
line-height: 21px;
margin-right: 5px;
overflow: auto;
display: block;
padding: .25rem 1.0rem .25rem 1.5rem;
clear: both;
font-weight: 400;
text-align: inherit;
white-space: normal;
border: none;
border-radius: 0px;
background-color: transparent;
box-shadow: none;
width: 100%;
height: 100%;
&:hover {
// TODO: improve the look on breeze themes
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
box-shadow: none;
}
}
}
.menu-checkbox {
float: right;
min-width: 22px;
max-width: 22px;
min-height: 22px;
max-height: 22px;
line-height: 22px;
text-align: center;
border-radius: 0px;
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
box-shadow: 0px 0px 2px black inset;
box-shadow: var(--inputShadow);
&.menu-checkbox-checked::after {
content: '✔';
}
}
</style>

View File

@ -0,0 +1,37 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const MuteCard = {
props: ['userId'],
data () {
return {
progress: false
}
},
computed: {
user () {
return this.$store.getters.findUser(this.userId)
},
muted () {
return this.user.muted
}
},
components: {
BasicUserCard
},
methods: {
unmuteUser () {
this.progress = true
this.$store.dispatch('unmuteUser', this.user.id).then(() => {
this.progress = false
})
},
muteUser () {
this.progress = true
this.$store.dispatch('muteUser', this.user.id).then(() => {
this.progress = false
})
}
}
}
export default MuteCard

View File

@ -0,0 +1,34 @@
<template>
<basic-user-card :user="user">
<div class="mute-card-content-container">
<button class="btn btn-default" @click="unmuteUser" :disabled="progress" v-if="muted">
<template v-if="progress">
{{ $t('user_card.unmute_progress') }}
</template>
<template v-else>
{{ $t('user_card.unmute') }}
</template>
</button>
<button class="btn btn-default" @click="muteUser" :disabled="progress" v-else>
<template v-if="progress">
{{ $t('user_card.mute_progress') }}
</template>
<template v-else>
{{ $t('user_card.mute') }}
</template>
</button>
</div>
</basic-user-card>
</template>
<script src="./mute_card.js"></script>
<style lang="scss">
.mute-card-content-container {
margin-top: 0.5em;
text-align: right;
button {
width: 10em;
}
}
</style>

View File

@ -1,10 +1,23 @@
import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service'
const NavPanel = {
created () {
if (this.currentUser && this.currentUser.locked) {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
followRequestFetcher.startFetching({ store, credentials })
}
},
computed: {
currentUser () {
return this.$store.state.users.currentUser
},
chat () {
return this.$store.state.chat.channel
},
followRequestCount () {
return this.$store.state.api.followRequests.length
}
}
}

View File

@ -3,7 +3,7 @@
<div class="panel panel-default">
<ul>
<li v-if='currentUser'>
<router-link to='/main/friends'>
<router-link :to="{ name: 'friends' }">
{{ $t("nav.timeline") }}
</router-link>
</li>
@ -12,13 +12,26 @@
{{ $t("nav.mentions") }}
</router-link>
</li>
<li v-if='currentUser'>
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
{{ $t("nav.dms") }}
</router-link>
</li>
<li v-if='currentUser && currentUser.locked'>
<router-link :to="{ name: 'friend-requests' }">
{{ $t("nav.friend_requests")}}
<span v-if='followRequestCount > 0' class="badge follow-request-count">
{{followRequestCount}}
</span>
</router-link>
</li>
<li>
<router-link to='/main/public'>
<router-link :to="{ name: 'public-timeline' }">
{{ $t("nav.public_tl") }}
</router-link>
</li>
<li>
<router-link to='/main/all'>
<router-link :to="{ name: 'public-external-timeline' }">
{{ $t("nav.twkn") }}
</router-link>
</li>
@ -34,6 +47,7 @@
.nav-panel .panel {
overflow: hidden;
box-shadow: var(--panelShadow);
}
.nav-panel ul {
list-style: none;
@ -41,6 +55,12 @@
padding: 0;
}
.follow-request-count {
margin: -6px 10px;
background-color: $fallback--bg;
background-color: var(--input, $fallback--faint);
}
.nav-panel li {
border-bottom: 1px solid;
border-color: $fallback--border;

View File

@ -1,22 +1,48 @@
import Status from '../status/status.vue'
import StillImage from '../still-image/still-image.vue'
import UserCardContent from '../user_card_content/user_card_content.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const Notification = {
data () {
return {
userExpanded: false
userExpanded: false,
betterShadow: this.$store.state.interface.browserSupport.cssFilter
}
},
props: [
'notification'
],
props: [ 'notification' ],
components: {
Status, StillImage, UserCardContent
Status, UserAvatar, UserCard
},
methods: {
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
},
getUser (notification) {
return this.$store.state.users.usersObject[notification.from_profile.id]
}
},
computed: {
userClass () {
return highlightClass(this.notification.from_profile)
},
userStyle () {
const highlight = this.$store.state.config.highlight
const user = this.notification.from_profile
return highlightStyle(highlight[user.screen_name])
},
userInStore () {
return this.$store.getters.findUser(this.notification.from_profile.id)
},
user () {
if (this.userInStore) {
return this.userInStore
}
return this.notification.from_profile
}
}
}

View File

@ -1,22 +1,26 @@
<template>
<status v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status"></status>
<div class="non-mention" v-else>
<a class='avatar-container' :href="notification.action.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded">
<StillImage class='avatar-compact' :src="notification.action.user.profile_image_url_original"/>
<status
v-if="notification.type === 'mention'"
:compact="true"
:statusoid="notification.status"
>
</status>
<div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]" v-else>
<a class='avatar-container' :href="notification.from_profile.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded">
<UserAvatar :compact="true" :betterShadow="betterShadow" :src="notification.from_profile.profile_image_url_original" />
</a>
<div class='notification-right'>
<div class="usercard notification-usercard" v-if="userExpanded">
<user-card-content :user="notification.action.user" :switcher="false"></user-card-content>
</div>
<UserCard :user="getUser(notification)" :rounded="true" :bordered="true" v-if="userExpanded" />
<span class="notification-details">
<div class="name-and-action">
<span class="username" :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span>
<span v-if="notification.type === 'favorite'">
<span class="username" v-if="!!notification.from_profile.name_html" :title="'@'+notification.from_profile.screen_name" v-html="notification.from_profile.name_html"></span>
<span class="username" v-else :title="'@'+notification.from_profile.screen_name">{{ notification.from_profile.name }}</span>
<span v-if="notification.type === 'like'">
<i class="fa icon-star lit"></i>
<small>{{$t('notifications.favorited_you')}}</small>
</span>
<span v-if="notification.type === 'repeat'">
<i class="fa icon-retweet lit"></i>
<i class="fa icon-retweet lit" :title="$t('tool_tip.repeat')"></i>
<small>{{$t('notifications.repeated_you')}}</small>
</span>
<span v-if="notification.type === 'follow'">
@ -24,12 +28,25 @@
<small>{{$t('notifications.followed_you')}}</small>
</span>
</div>
<small class="timeago"><router-link :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small>
<div class="timeago" v-if="notification.type === 'follow'">
<span class="faint">
<timeago :since="notification.created_at" :auto-update="240"></timeago>
</span>
</div>
<div class="timeago" v-else>
<router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }" class="faint-link">
<timeago :since="notification.created_at" :auto-update="240"></timeago>
</router-link>
</div>
</span>
<div class="follow-text" v-if="notification.type === 'follow'">
<router-link :to="{ name: 'user-profile', params: { id: notification.action.user.id } }">@{{notification.action.user.screen_name}}</router-link>
<router-link :to="userProfileLink(notification.from_profile)">
@{{notification.from_profile.screen_name}}
</router-link>
</div>
<status v-else class="faint" :compact="true" :statusoid="notification.status" :noHeading="true"></status>
<template v-else>
<status class="faint" :compact="true" :statusoid="notification.action" :noHeading="true"></status>
</template>
</div>
</div>
</template>

View File

@ -1,28 +1,38 @@
import Notification from '../notification/notification.vue'
import { sortBy, take, filter } from 'lodash'
import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
import {
notificationsFromStore,
visibleNotificationsFromStore,
unseenNotificationsFromStore
} from '../../services/notification_utils/notification_utils.js'
const Notifications = {
props: [
'noHeading'
],
data () {
return {
visibleNotificationCount: 20
bottomedOut: false
}
},
computed: {
notifications () {
return this.$store.state.statuses.notifications
return notificationsFromStore(this.$store)
},
error () {
return this.$store.state.statuses.notifications.error
},
unseenNotifications () {
return filter(this.notifications, ({seen}) => !seen)
return unseenNotificationsFromStore(this.$store)
},
visibleNotifications () {
// Don't know why, but sortBy([seen, -action.id]) doesn't work.
let sortedNotifications = sortBy(this.notifications, ({action}) => -action.id)
sortedNotifications = sortBy(sortedNotifications, 'seen')
return take(sortedNotifications, this.visibleNotificationCount)
return visibleNotificationsFromStore(this.$store)
},
unseenCount () {
return this.unseenNotifications.length
},
loading () {
return this.$store.state.statuses.notifications.loading
}
},
components: {
@ -39,7 +49,22 @@ const Notifications = {
},
methods: {
markAsSeen () {
this.$store.commit('markNotificationsAsSeen', this.visibleNotifications)
this.$store.dispatch('markNotificationsAsSeen')
},
fetchOlderNotifications () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
store.commit('setNotificationsLoading', { value: true })
notificationsFetcher.fetchAndUpdate({
store,
credentials,
older: true
}).then(notifs => {
store.commit('setNotificationsLoading', { value: false })
if (notifs.length === 0) {
this.bottomedOut = true
}
})
}
}
}

View File

@ -4,50 +4,28 @@
// a bit of a hack to allow scrolling below notifications
padding-bottom: 15em;
.panel {
background: $fallback--bg;
background: var(--bg, $fallback--bg)
.loadmore-error {
color: $fallback--text;
color: var(--text, $fallback--text);
}
.panel-body {
border-color: $fallback--border;
border-color: var(--border, $fallback--border)
}
.panel-heading {
// force the text to stay centered, while keeping
// the button in the right side of the panel heading
.notification {
position: relative;
background: $fallback--btn;
background: var(--btn, $fallback--btn);
color: $fallback--fg;
color: var(--fg, $fallback--fg);
.read-button {
.notification-overlay {
position: absolute;
right: 0.7em;
height: 1.8em;
line-height: 100%;
top: 0;
right: 0;
left: 0;
bottom: 0;
pointer-events: none;
}
}
.unseen-count {
display: inline-block;
background-color: $fallback--cRed;
background-color: var(--cRed, $fallback--cRed);
text-shadow: 0px 0px 3px rgba(0, 0, 0, 0.5);
min-width: 1.3em;
border-radius: 1.3em;
margin: 0 0.2em 0 -0.4em;
color: white;
font-size: 0.9em;
text-align: center;
line-height: 1.3em;
}
.unseen {
border-left: 4px solid $fallback--cRed;
border-left: 4px solid var(--cRed, $fallback--cRed);
padding-left: 0;
&.unseen {
.notification-overlay {
background-image: linear-gradient(135deg, var(--badgeNotification, $fallback--cRed) 4px, transparent 10px)
}
}
}
}
@ -55,21 +33,8 @@
box-sizing: border-box;
display: flex;
border-bottom: 1px solid;
border-bottom-color: inherit;
padding-left: 4px;
.avatar-compact {
width: 32px;
height: 32px;
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
overflow: hidden;
line-height: 0;
&.animated::before {
display: none;
}
}
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
&:hover .animated.avatar {
canvas {
@ -80,10 +45,6 @@
}
}
.notification-usercard {
margin: 0;
}
.non-mention {
display: flex;
flex: 1;
@ -99,6 +60,9 @@
padding: 0.25em 0;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
a {
color: var(--faintLink);
}
}
padding: 0;
.media-body {
@ -135,6 +99,7 @@
flex: 1 1 0;
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
.name-and-action {
flex: 1;
@ -147,10 +112,17 @@
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
img {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
.timeago {
float: right;
font-size: 12px;
margin-right: .2em;
}
.icon-retweet.lit {
@ -196,15 +168,4 @@
margin-bottom: 0.3em;
}
}
// ugly as heck
&:last-child {
border-bottom: none;
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
.status-el {
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
}
}
}

View File

@ -1,16 +1,33 @@
<template>
<div class="notifications">
<div class="panel panel-default">
<div class="panel-heading">
<span class="unseen-count" v-if="unseenCount">{{unseenCount}}</span>
{{$t('notifications.notifications')}}
<div v-if="!noHeading" class="panel-heading">
<div class="title">
{{$t('notifications.notifications')}}
<span class="badge badge-notification unseen-count" v-if="unseenCount">{{unseenCount}}</span>
</div>
<div @click.prevent class="loadmore-error alert error" v-if="error">
{{$t('timeline.error_fetching')}}
</div>
<button v-if="unseenCount" @click.prevent="markAsSeen" class="read-button">{{$t('notifications.read')}}</button>
</div>
<div class="panel-body">
<div v-for="notification in visibleNotifications" :key="notification.action.id" class="notification" :class='{"unseen": !notification.seen}'>
<div v-for="notification in visibleNotifications" :key="notification.id" class="notification" :class='{"unseen": !notification.seen}'>
<div class="notification-overlay"></div>
<notification :notification="notification"></notification>
</div>
</div>
<div class="panel-footer">
<div v-if="bottomedOut" class="new-status-notification text-center panel-footer faint">
{{$t('notifications.no_more_notifications')}}
</div>
<a v-else-if="!loading" href="#" v-on:click.prevent="fetchOlderNotifications()">
<div class="new-status-notification text-center panel-footer">{{$t('notifications.load_older')}}</div>
</a>
<div v-else class="new-status-notification text-center panel-footer">
<i class="icon-spin3 animate-spin"/>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,20 @@
import oauth from '../../services/new_api/oauth.js'
const oac = {
props: ['code'],
mounted () {
if (this.code) {
oauth.getToken({
app: this.$store.state.oauth,
instance: this.$store.state.instance.server,
code: this.code
}).then((result) => {
this.$store.commit('setToken', result.access_token)
this.$store.dispatch('loginUser', result.access_token)
this.$router.push({name: 'friends'})
})
}
}
}
export default oac

View File

@ -0,0 +1,5 @@
<template>
<h1>...</h1>
</template>
<script src="./oauth_callback.js"></script>

View File

@ -0,0 +1,38 @@
<template>
<div class="opacity-control style-control" :class="{ disabled: !present || disabled }">
<label :for="name" class="label">
{{$t('settings.style.common.opacity')}}
</label>
<input
v-if="typeof fallback !== 'undefined'"
class="opt exclude-disabled"
:id="name + '-o'"
type="checkbox"
:checked="present"
@input="$emit('input', !present ? fallback : undefined)">
<label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label>
<input
:id="name"
class="input-number"
type="number"
:value="value || fallback"
:disabled="!present || disabled"
@input="$emit('input', $event.target.value)"
max="1"
min="0"
step=".05">
</div>
</template>
<script>
export default {
props: [
'name', 'value', 'fallback', 'disabled'
],
computed: {
present () {
return typeof this.value !== 'undefined'
}
}
}
</script>

View File

@ -0,0 +1,70 @@
@import '../../_variables.scss';
.popper-wrapper {
z-index: 8;
}
.popper-wrapper .popper__arrow {
width: 0;
height: 0;
border-style: solid;
position: absolute;
margin: 5px;
}
.popper-wrapper[x-placement^="top"] {
margin-bottom: 5px;
}
.popper-wrapper[x-placement^="top"] .popper__arrow {
border-width: 5px 5px 0 5px;
border-color: $fallback--bg transparent transparent transparent;
border-color: var(--bg, $fallback--bg) transparent transparent transparent;
bottom: -5px;
left: calc(50% - 5px);
margin-top: 0;
margin-bottom: 0;
}
.popper-wrapper[x-placement^="bottom"] {
margin-top: 5px;
}
.popper-wrapper[x-placement^="bottom"] .popper__arrow {
border-width: 0 5px 5px 5px;
border-color: transparent transparent $fallback--bg transparent;
border-color: transparent transparent var(--bg, $fallback--bg) transparent;
top: -5px;
left: calc(50% - 5px);
margin-top: 0;
margin-bottom: 0;
}
.popper-wrapper[x-placement^="right"] {
margin-left: 5px;
}
.popper-wrapper[x-placement^="right"] .popper__arrow {
border-width: 5px 5px 5px 0;
border-color: transparent $fallback--bg transparent transparent;
border-color: transparent var(--bg, $fallback--bg) transparent transparent;
left: -5px;
top: calc(50% - 5px);
margin-left: 0;
margin-right: 0;
}
.popper-wrapper[x-placement^="left"] {
margin-right: 5px;
}
.popper-wrapper[x-placement^="left"] .popper__arrow {
border-width: 5px 0 5px 5px;
border-color: transparent transparent transparent $fallback--bg;
border-color: transparent transparent transparent var(--bg, $fallback--bg);
right: -5px;
top: calc(50% - 5px);
margin-left: 0;
margin-right: 0;
}

View File

@ -1,5 +1,7 @@
import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji-input/emoji-input.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import Completion from '../../services/completion/completion.js'
import { take, filter, reject, map, uniqBy } from 'lodash'
@ -16,30 +18,52 @@ const buildMentionsString = ({user, attentions}, currentUser) => {
return `@${attention.screen_name}`
})
return mentions.join(' ') + ' '
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
}
const PostStatusForm = {
props: [
'replyTo',
'repliedUser',
'attentions'
'attentions',
'copyMessageScope',
'subject'
],
components: {
MediaUpload
MediaUpload,
ScopeSelector,
EmojiInput
},
mounted () {
this.resize(this.$refs.textarea)
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
if (this.replyTo) {
this.$refs.textarea.focus()
}
},
data () {
const preset = this.$route.query.message
let statusText = preset || ''
const scopeCopy = typeof this.$store.state.config.scopeCopy === 'undefined'
? this.$store.state.instance.scopeCopy
: this.$store.state.config.scopeCopy
if (this.replyTo) {
const currentUser = this.$store.state.users.currentUser
statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
}
const scope = (this.copyMessageScope && scopeCopy || this.copyMessageScope === 'direct')
? this.copyMessageScope
: this.$store.state.users.currentUser.default_scope
const contentType = typeof this.$store.state.config.postContentType === 'undefined'
? this.$store.state.instance.postContentType
: this.$store.state.config.postContentType
return {
dropFiles: [],
submitDisabled: false,
@ -47,27 +71,25 @@ const PostStatusForm = {
posting: false,
highlighted: 0,
newStatus: {
spoilerText: this.subject || '',
status: statusText,
nsfw: false,
files: [],
visibility: null
visibility: scope,
contentType
},
caret: 0
}
},
computed: {
vis () {
return {
public: { selected: !this.newStatus.visibility || this.newStatus.visibility === 'public' },
unlisted: { selected: this.newStatus.visibility === 'unlisted' },
private: { selected: this.newStatus.visibility === 'private' },
direct: { selected: this.newStatus.visibility === 'direct' }
}
},
candidates () {
const firstchar = this.textAtCaret.charAt(0)
if (firstchar === '@') {
const matchedUsers = filter(this.users, (user) => (String(user.name + user.screen_name)).toUpperCase()
.match(this.textAtCaret.slice(1).toUpperCase()))
const query = this.textAtCaret.slice(1).toUpperCase()
const matchedUsers = filter(this.users, (user) => {
return user.screen_name.toUpperCase().startsWith(query) ||
user.name && user.name.toUpperCase().startsWith(query)
})
if (matchedUsers.length <= 0) {
return false
}
@ -81,16 +103,16 @@ const PostStatusForm = {
}))
} else if (firstchar === ':') {
if (this.textAtCaret === ':') { return }
const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.match(this.textAtCaret.slice(1)))
const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1)))
if (matchedEmoji.length <= 0) {
return false
}
return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({
// eslint-disable-next-line camelcase
screen_name: `:${shortcode}:`,
name: '',
utf: utf || '',
img: image_url,
// eslint-disable-next-line camelcase
img: utf ? '' : this.$store.state.instance.server + image_url,
highlighted: index === this.highlighted
}))
} else {
@ -107,29 +129,59 @@ const PostStatusForm = {
users () {
return this.$store.state.users.users
},
userDefaultScope () {
return this.$store.state.users.currentUser.default_scope
},
showAllScopes () {
const minimalScopesMode = typeof this.$store.state.config.minimalScopesMode === 'undefined'
? this.$store.state.instance.minimalScopesMode
: this.$store.state.config.minimalScopesMode
return !minimalScopesMode
},
emoji () {
return this.$store.state.config.emoji || []
return this.$store.state.instance.emoji || []
},
customEmoji () {
return this.$store.state.config.customEmoji || []
return this.$store.state.instance.customEmoji || []
},
statusLength () {
return this.newStatus.status.length
},
spoilerTextLength () {
return this.newStatus.spoilerText.length
},
statusLengthLimit () {
return this.$store.state.config.textlimit
return this.$store.state.instance.textlimit
},
hasStatusLengthLimit () {
return this.statusLengthLimit > 0
},
charactersLeft () {
return this.statusLengthLimit - this.statusLength
return this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
},
isOverLengthLimit () {
return this.hasStatusLengthLimit && (this.statusLength > this.statusLengthLimit)
return this.hasStatusLengthLimit && (this.charactersLeft < 0)
},
scopeOptionsEnabled () {
return this.$store.state.config.scopeOptionsEnabled
minimalScopesMode () {
return this.$store.state.instance.minimalScopesMode
},
alwaysShowSubject () {
if (typeof this.$store.state.config.alwaysShowSubjectInput !== 'undefined') {
return this.$store.state.config.alwaysShowSubjectInput
} else if (typeof this.$store.state.instance.alwaysShowSubjectInput !== 'undefined') {
return this.$store.state.instance.alwaysShowSubjectInput
} else {
return true
}
},
formattingOptionsEnabled () {
return this.$store.state.instance.formattingOptionsEnabled
},
postFormats () {
return this.$store.state.instance.postFormats || []
},
safeDMEnabled () {
return this.$store.state.instance.safeDM
}
},
methods: {
@ -178,6 +230,9 @@ const PostStatusForm = {
this.highlighted = 0
}
},
onKeydown (e) {
e.stopPropagation()
},
setCaret ({target: {selectionStart}}) {
this.caret = selectionStart
},
@ -199,19 +254,25 @@ const PostStatusForm = {
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: newStatus.files,
store: this.$store,
inReplyToStatusId: this.replyTo
inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType
}).then((data) => {
if (!data.error) {
this.newStatus = {
status: '',
spoilerText: '',
files: [],
visibility: newStatus.visibility
visibility: newStatus.visibility,
contentType: newStatus.contentType
}
this.$refs.mediaUpload.clearFile()
this.$emit('posted')
let el = this.$el.querySelector('textarea')
el.style.height = '16px'
el.style.height = 'auto'
el.style.height = undefined
this.error = null
} else {
this.error = data.error
@ -227,6 +288,11 @@ const PostStatusForm = {
let index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
},
uploadFailed (errString, templateArgs) {
templateArgs = templateArgs || {}
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
this.enableSubmit()
},
disableSubmit () {
this.submitDisabled = true
},
@ -238,6 +304,8 @@ const PostStatusForm = {
},
paste (e) {
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
e.preventDefault()
// Strangely, files property gets emptied after event propagation
// Trying to wrap it in array doesn't work. Plus I doubt it's possible
// to hold more than one file in clipboard.
@ -254,13 +322,15 @@ const PostStatusForm = {
e.dataTransfer.dropEffect = 'copy'
},
resize (e) {
if (!e.target) { return }
const vertPadding = Number(window.getComputedStyle(e.target)['padding-top'].substr(0, 1)) +
Number(window.getComputedStyle(e.target)['padding-bottom'].substr(0, 1))
e.target.style.height = 'auto'
e.target.style.height = `${e.target.scrollHeight - vertPadding}px`
if (e.target.value === '') {
e.target.style.height = '16px'
const target = e.target || e
if (!(target instanceof window.Element)) { return }
const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) +
Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1))
// Auto is needed to make textbox shrink when removing lines
target.style.height = 'auto'
target.style.height = `${target.scrollHeight - vertPadding}px`
if (target.value === '') {
target.style.height = null
}
},
clearError () {

View File

@ -2,16 +2,29 @@
<div class="post-status-form">
<form @submit.prevent="postStatus(newStatus)">
<div class="form-group" >
<input
v-if="scopeOptionsEnabled"
<i18n
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'"
path="post_status.account_not_locked_warning"
tag="p"
class="visibility-notice">
<router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link>
</i18n>
<p v-if="newStatus.visibility === 'direct'" class="visibility-notice">
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p>
<EmojiInput
v-if="newStatus.spoilerText || alwaysShowSubject"
type="text"
:placeholder="$t('post_status.content_warning')"
v-model="newStatus.spoilerText"
class="form-cw">
classname="form-control"
/>
<textarea
ref="textarea"
@click="setCaret"
@keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control"
@keydown="onKeydown"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@ -22,28 +35,47 @@
@drop="fileDrop"
@dragover.prevent="fileDrag"
@input="resize"
@paste="paste">
@paste="paste"
:disabled="posting"
>
</textarea>
<div v-if="scopeOptionsEnabled" class="visibility-tray">
<i v-on:click="changeVis('direct')" class="icon-mail-alt" :class="vis.direct"></i>
<i v-on:click="changeVis('private')" class="icon-lock" :class="vis.private"></i>
<i v-on:click="changeVis('unlisted')" class="icon-lock-open-alt" :class="vis.unlisted"></i>
<i v-on:click="changeVis('public')" class="icon-globe" :class="vis.public"></i>
<div class="visibility-tray">
<span class="text-format" v-if="formattingOptionsEnabled">
<label for="post-content-type" class="select">
<select id="post-content-type" v-model="newStatus.contentType" class="form-control">
<option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat">
{{$t(`post_status.content_type["${postFormat}"]`)}}
</option>
</select>
<i class="icon-down-open"></i>
</label>
</span>
<scope-selector
:showAll="showAllScopes"
:userDefault="userDefaultScope"
:originalScope="copyMessageScope"
:initialScope="newStatus.visibility"
:onScopeChange="changeVis"/>
</div>
</div>
<div style="position:relative;" v-if="candidates">
<div class="autocomplete-panel">
<div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))">
<div class="autocomplete" :class="{ highlighted: candidate.highlighted }">
<span v-if="candidate.img"><img :src="candidate.img"></img></span>
<span v-else>{{candidate.utf}}</span>
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
</div>
<div class="autocomplete-panel" v-if="candidates">
<div class="autocomplete-panel-body">
<div
v-for="(candidate, index) in candidates"
:key="index"
@click="replace(candidate.utf || (candidate.screen_name + ' '))"
class="autocomplete-item"
:class="{ highlighted: candidate.highlighted }"
>
<span v-if="candidate.img"><img :src="candidate.img" /></span>
<span v-else>{{candidate.utf}}</span>
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
</div>
</div>
</div>
<div class='form-bottom'>
<media-upload @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="enableSubmit" :drop-files="dropFiles"></media-upload>
<media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload>
<p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p>
<p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p>
@ -54,17 +86,23 @@
</div>
<div class='alert error' v-if="error">
Error: {{ error }}
<i class="icon-cancel" @click="clearError"></i>
<i class="button-icon icon-cancel" @click="clearError"></i>
</div>
<div class="attachments">
<div class="media-upload-container attachment" v-for="file in newStatus.files">
<i class="fa icon-cancel" @click="removeMediaFile(file)"></i>
<img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img>
<video v-if="type(file) === 'video'" :src="file.image" controls></video>
<audio v-if="type(file) === 'audio'" :src="file.image" controls></audio>
<a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a>
<div class="media-upload-wrapper" v-for="file in newStatus.files">
<i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i>
<div class="media-upload-container attachment">
<img class="thumbnail media-upload" :src="file.url" v-if="type(file) === 'image'"></img>
<video v-if="type(file) === 'video'" :src="file.url" controls></video>
<audio v-if="type(file) === 'audio'" :src="file.url" controls></audio>
<a v-if="type(file) === 'unknown'" :href="file.url">{{file.url}}</a>
</div>
</div>
</div>
<div class="upload_settings" v-if="newStatus.files.length > 0">
<input type="checkbox" id="filesSensitive" v-model="newStatus.nsfw">
<label for="filesSensitive">{{$t('post_status.attachments_sensitive')}}</label>
</div>
</form>
</div>
</template>
@ -91,14 +129,11 @@
}
}
.post-status-form .visibility-tray {
font-size: 1.2em;
padding: 3px;
cursor: pointer;
.selected {
color: $fallback--lightFg;
color: var(--lightFg, $fallback--lightFg);
.post-status-form {
.visibility-tray {
display: flex;
justify-content: space-between;
flex-direction: row-reverse;
}
}
@ -123,14 +158,49 @@
text-align: center;
}
.media-upload-wrapper {
flex: 0 0 auto;
max-width: 100%;
min-width: 50px;
margin-right: .2em;
margin-bottom: .5em;
.icon-cancel {
display: inline-block;
position: static;
margin: 0;
padding-bottom: 0;
margin-left: $fallback--attachmentRadius;
margin-left: var(--attachmentRadius, $fallback--attachmentRadius);
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
.attachments {
padding: 0 0.5em;
.attachment {
margin: 0;
position: relative;
flex: 0 0 auto;
border: 1px solid $fallback--border;
border: 1px solid var(--border, $fallback--border);
margin: 0.5em 0.8em 0.2em 0;
text-align: center;
audio {
min-width: 300px;
flex: 1 0 auto;
}
a {
display: block;
text-align: left;
line-height: 1.2;
padding: .5em;
}
}
i {
@ -199,50 +269,5 @@
cursor: pointer;
z-index: 4;
}
.autocomplete-panel {
margin: 0 0.5em 0 0.5em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
position: absolute;
z-index: 1;
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
min-width: 75%;
background: $fallback--bg;
background: var(--bg, $fallback--bg);
color: $fallback--lightFg;
color: var(--lightFg, $fallback--lightFg);
}
.autocomplete {
cursor: pointer;
padding: 0.2em 0.4em 0.2em 0.4em;
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
display: flex;
img {
width: 24px;
height: 24px;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
object-fit: contain;
}
span {
line-height: 24px;
margin: 0 0.1em 0 0.2em;
}
small {
margin-left: .5em;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
&.highlighted {
background-color: $fallback--btn;
background-color: var(--btn, $fallback--btn);
}
}
}
</style>

View File

@ -0,0 +1,35 @@
<template>
<button :disabled="progress || disabled" @click="onClick">
<template v-if="progress">
<slot name="progress" />
</template>
<template v-else>
<slot />
</template>
</button>
</template>
<script>
export default {
props: {
disabled: {
type: Boolean
},
click: { // click event handler. Must return a promise
type: Function,
default: () => Promise.resolve()
}
},
data () {
return {
progress: false
}
},
methods: {
onClick () {
this.progress = true
this.click().then(() => { this.progress = false })
}
}
}
</script>

View File

@ -7,7 +7,7 @@ const PublicAndExternalTimeline = {
timeline () { return this.$store.state.statuses.timelines.publicAndExternal }
},
created () {
this.$store.dispatch('startFetching', 'publicAndExternal')
this.$store.dispatch('startFetchingTimeline', { timeline: 'publicAndExternal' })
},
destroyed () {
this.$store.dispatch('stopFetching', 'publicAndExternal')

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