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

* upstream/develop: (193 commits)
  fix user avatar fallback logic
  remove dead code
  make bio textarea resizable vertically only
  remove dead code
  remove dead code
  fix crazy watch logic in conversation
  show three dot button only if needed
  hide mute conversation button to guests
  update keyBy
  generate idObj at timeline level
  fix pin showing logic in conversation
  Show a message when JS is disabled
  Initialize chat only if user is logged in and it wasn't initialized before
  i18n/Update Japanese
  i18n/Update pedantic Japanese
  sync profile tab state with location query
  refactor TabSwitcher
  use better name of controlled prop
  fix potential bug to render active tab in controlled way
  remove unused param
  ...
This commit is contained in:
Henry Jameson 2019-08-31 22:38:02 +03:00
commit 18ec13d796
226 changed files with 10872 additions and 5070 deletions

View file

@ -21,26 +21,6 @@ module.exports = {
'generator-star-spacing': 0, 'generator-star-spacing': 0,
// allow debugger during development // allow debugger during development
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
// Webpack 4 update commit, most of these probably should be fixed and removed in a separate MR 'vue/require-prop-types': 0
// A lot of errors come from .vue files that are now properly linted
'vue/valid-v-if': 1,
'vue/use-v-on-exact': 1,
'vue/no-parsing-error': 1,
'vue/require-v-for-key': 1,
'vue/valid-v-for': 1,
'vue/require-prop-types': 1,
'vue/no-use-v-if-with-v-for': 1,
'indent': 1,
'import/first': 1,
'object-curly-spacing': 1,
'prefer-promise-reject-errors': 1,
'eol-last': 1,
'no-return-await': 1,
'no-multi-spaces': 1,
'no-trailing-spaces': 1,
'no-unused-expressions': 1,
'no-mixed-operators': 1,
'camelcase': 1,
'no-multiple-empty-lines': 1
} }
} }

View file

@ -1,5 +1,8 @@
# v1.0 # v1.0
## Removed features/radically changed behavior ## Removed features/radically changed behavior
### formattingOptionsEnabled
as of !833 `formattingOptionsEnabled` is no longer available and instead FE check for available post formatting options and enables formatting control if there's more than one option.
### minimalScopesMode ### minimalScopesMode
As of !633, `scopeOptions` is no longer available and instead is changed for `minimalScopesMode` (default: `false`) As of !633, `scopeOptions` is no longer available and instead is changed for `minimalScopesMode` (default: `false`)

View file

@ -31,8 +31,13 @@ var hotMiddleware = require('webpack-hot-middleware')(compiler)
// force page reload when html-webpack-plugin template changes // force page reload when html-webpack-plugin template changes
compiler.plugin('compilation', function (compilation) { compiler.plugin('compilation', function (compilation) {
compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) { compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
hotMiddleware.publish({ action: 'reload' }) // FIXME: This supposed to reload whole page when index.html is changed,
cb() // however now it reloads entire page on every breath, i suppose the order
// of plugins changed or something. It's a minor thing and douesn't hurt
// disabling it, constant reloads hurt much more
// hotMiddleware.publish({ action: 'reload' })
// cb()
}) })
}) })

View file

@ -27,16 +27,17 @@ exports.cssLoaders = function (options) {
return [ return [
{ {
test: /\.(post)?css$/, test: /\.(post)?css$/,
use: generateLoaders(['css-loader']), use: generateLoaders(['css-loader', 'postcss-loader']),
}, },
{ {
test: /\.less$/, test: /\.less$/,
use: generateLoaders(['css-loader', 'less-loader']), use: generateLoaders(['css-loader', 'postcss-loader', 'less-loader']),
}, },
{ {
test: /\.sass$/, test: /\.sass$/,
use: generateLoaders([ use: generateLoaders([
'css-loader', 'css-loader',
'postcss-loader',
{ {
loader: 'sass-loader', loader: 'sass-loader',
options: { options: {
@ -47,11 +48,11 @@ exports.cssLoaders = function (options) {
}, },
{ {
test: /\.scss$/, test: /\.scss$/,
use: generateLoaders(['css-loader', 'sass-loader']) use: generateLoaders(['css-loader', 'postcss-loader', 'sass-loader'])
}, },
{ {
test: /\.styl(us)?$/, test: /\.styl(us)?$/,
use: generateLoaders(['css-loader', 'stylus-loader']), use: generateLoaders(['css-loader', 'postcss-loader', 'stylus-loader']),
}, },
] ]
} }

View file

@ -48,6 +48,11 @@ module.exports = {
changeOrigin: true, changeOrigin: true,
cookieDomainRewrite: 'localhost', cookieDomainRewrite: 'localhost',
ws: true ws: true
},
'/oauth/revoke': {
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost'
} }
}, },
// CSS Sourcemaps off by default because relative paths are "buggy" // CSS Sourcemaps off by default because relative paths are "buggy"

View file

@ -9,7 +9,8 @@
<link rel="stylesheet" href="/static/font/css/fontello.css"> <link rel="stylesheet" href="/static/font/css/fontello.css">
<link rel="stylesheet" href="/static/font/css/animation.css"> <link rel="stylesheet" href="/static/font/css/animation.css">
</head> </head>
<body style="display: none"> <body>
<noscript>To use Pleroma, please enable JavaScript.</noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->
</body> </body>

View file

@ -25,17 +25,15 @@
"localforage": "^1.5.0", "localforage": "^1.5.0",
"object-path": "^0.11.3", "object-path": "^0.11.3",
"phoenix": "^1.3.0", "phoenix": "^1.3.0",
"popper.js": "^1.14.7",
"portal-vue": "^2.1.4", "portal-vue": "^2.1.4",
"sanitize-html": "^1.13.0", "sanitize-html": "^1.13.0",
"v-click-outside": "^2.1.1", "v-click-outside": "^2.1.1",
"v-tooltip": "^2.0.2",
"vue": "^2.5.13", "vue": "^2.5.13",
"vue-chat-scroll": "^1.2.1", "vue-chat-scroll": "^1.2.1",
"vue-i18n": "^7.3.2", "vue-i18n": "^7.3.2",
"vue-popperjs": "^2.0.3",
"vue-router": "^3.0.1", "vue-router": "^3.0.1",
"vue-template-compiler": "^2.3.4", "vue-template-compiler": "^2.3.4",
"vue-timeago": "^3.1.2",
"vuelidate": "^0.7.4", "vuelidate": "^0.7.4",
"vuex": "^3.0.1", "vuex": "^3.0.1",
"whatwg-fetch": "^2.0.3" "whatwg-fetch": "^2.0.3"
@ -82,8 +80,8 @@
"json-loader": "^0.5.4", "json-loader": "^0.5.4",
"karma": "^3.0.0", "karma": "^3.0.0",
"karma-coverage": "^1.1.1", "karma-coverage": "^1.1.1",
"karma-mocha": "^1.2.0",
"karma-firefox-launcher": "^1.1.0", "karma-firefox-launcher": "^1.1.0",
"karma-mocha": "^1.2.0",
"karma-sinon-chai": "^2.0.2", "karma-sinon-chai": "^2.0.2",
"karma-sourcemap-loader": "^0.3.7", "karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "0.0.26", "karma-spec-reporter": "0.0.26",
@ -95,6 +93,7 @@
"nightwatch": "^0.9.8", "nightwatch": "^0.9.8",
"opn": "^4.0.2", "opn": "^4.0.2",
"ora": "^0.3.0", "ora": "^0.3.0",
"postcss-loader": "^3.0.0",
"raw-loader": "^0.5.1", "raw-loader": "^0.5.1",
"sass": "^1.17.3", "sass": "^1.17.3",
"sass-loader": "git://github.com/webpack-contrib/sass-loader", "sass-loader": "git://github.com/webpack-contrib/sass-loader",

5
postcss.config.js Normal file
View file

@ -0,0 +1,5 @@
module.exports = {
plugins: [
require('autoprefixer')
]
}

View file

@ -1,7 +1,7 @@
import UserPanel from './components/user_panel/user_panel.vue' import UserPanel from './components/user_panel/user_panel.vue'
import NavPanel from './components/nav_panel/nav_panel.vue' import NavPanel from './components/nav_panel/nav_panel.vue'
import Notifications from './components/notifications/notifications.vue' import Notifications from './components/notifications/notifications.vue'
import UserFinder from './components/user_finder/user_finder.vue' import SearchBar from './components/search_bar/search_bar.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue' import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from './components/features_panel/features_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 WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
@ -19,7 +19,7 @@ export default {
UserPanel, UserPanel,
NavPanel, NavPanel,
Notifications, Notifications,
UserFinder, SearchBar,
InstanceSpecificPanel, InstanceSpecificPanel,
FeaturesPanel, FeaturesPanel,
WhoToFollowPanel, WhoToFollowPanel,
@ -32,7 +32,7 @@ export default {
}, },
data: () => ({ data: () => ({
mobileActivePanel: 'timeline', mobileActivePanel: 'timeline',
finderHidden: true, searchBarHidden: true,
supportsMask: window.CSS && window.CSS.supports && ( supportsMask: window.CSS && window.CSS.supports && (
window.CSS.supports('mask-size', 'contain') || window.CSS.supports('mask-size', 'contain') ||
window.CSS.supports('-webkit-mask-size', 'contain') || window.CSS.supports('-webkit-mask-size', 'contain') ||
@ -70,7 +70,7 @@ export default {
logoBgStyle () { logoBgStyle () {
return Object.assign({ return Object.assign({
'margin': `${this.$store.state.instance.logoMargin} 0`, 'margin': `${this.$store.state.instance.logoMargin} 0`,
opacity: this.finderHidden ? 1 : 0 opacity: this.searchBarHidden ? 1 : 0
}, this.enableMask ? {} : { }, this.enableMask ? {} : {
'background-color': this.enableMask ? '' : 'transparent' 'background-color': this.enableMask ? '' : 'transparent'
}) })
@ -89,7 +89,11 @@ export default {
sitename () { return this.$store.state.instance.name }, sitename () { return this.$store.state.instance.name },
chat () { return this.$store.state.chat.channel.state === 'joined' }, chat () { return this.$store.state.chat.channel.state === 'joined' },
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled }, suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () { return this.$store.state.instance.showInstanceSpecificPanel }, showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.state.config.hideISP &&
this.$store.state.instance.instanceSpecificPanelContent
},
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
isMobileLayout () { return this.$store.state.interface.mobileLayout } isMobileLayout () { return this.$store.state.interface.mobileLayout }
}, },
@ -101,8 +105,8 @@ export default {
this.$router.replace('/main/public') this.$router.replace('/main/public')
this.$store.dispatch('logout') this.$store.dispatch('logout')
}, },
onFinderToggled (hidden) { onSearchBarToggled (hidden) {
this.finderHidden = hidden this.searchBarHidden = hidden
}, },
updateMobileState () { updateMobileState () {
const mobileLayout = windowWidth() <= 800 const mobileLayout = windowWidth() <= 800

View file

@ -47,6 +47,8 @@ body {
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
max-width: 100vw; max-width: 100vw;
overflow-x: hidden; overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
a { a {
@ -129,6 +131,7 @@ input, textarea, .select {
font-family: sans-serif; font-family: sans-serif;
font-family: var(--inputFont, sans-serif); font-family: var(--inputFont, sans-serif);
font-size: 14px; font-size: 14px;
margin: 0;
padding: 8px .5em; padding: 8px .5em;
box-sizing: border-box; box-sizing: border-box;
display: inline-block; display: inline-block;
@ -182,7 +185,44 @@ input, textarea, .select {
flex: 1; flex: 1;
} }
&[type=radio], &[type=radio] {
display: none;
&:checked + label::before {
box-shadow: 0px 0px 2px black inset, 0px 0px 0px 4px $fallback--fg inset;
box-shadow: var(--inputShadow), 0px 0px 0px 4px var(--fg, $fallback--fg) inset;
background-color: var(--link, $fallback--link);
}
&:disabled {
&,
& + label,
& + label::before {
opacity: .5;
}
}
+ label::before {
flex-shrink: 0;
display: inline-block;
content: '';
transition: box-shadow 200ms;
width: 1.1em;
height: 1.1em;
border-radius: 100%; // Radio buttons should always be circle
box-shadow: 0px 0px 2px black inset;
box-shadow: var(--inputShadow);
margin-right: .5em;
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
vertical-align: top;
text-align: center;
line-height: 1.1em;
font-size: 1.1em;
box-sizing: border-box;
color: transparent;
overflow: hidden;
box-sizing: border-box;
}
}
&[type=checkbox] { &[type=checkbox] {
display: none; display: none;
&:checked + label::before { &:checked + label::before {
@ -197,6 +237,7 @@ input, textarea, .select {
} }
} }
+ label::before { + label::before {
flex-shrink: 0;
display: inline-block; display: inline-block;
content: ''; content: '';
transition: color 200ms; transition: color 200ms;
@ -228,11 +269,45 @@ option {
background-color: var(--bg, $fallback--bg); background-color: var(--bg, $fallback--bg);
} }
.hide-number-spinner {
-moz-appearance: textfield;
&[type=number]::-webkit-inner-spin-button,
&[type=number]::-webkit-outer-spin-button {
opacity: 0;
display: none;
}
}
i[class*=icon-] { i[class*=icon-] {
color: $fallback--icon; color: $fallback--icon;
color: var(--icon, $fallback--icon) color: var(--icon, $fallback--icon)
} }
.btn-block {
display: block;
width: 100%;
}
.btn-group {
position: relative;
display: inline-flex;
vertical-align: middle;
button {
position: relative;
flex: 1 1 auto;
&:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
}
.container { .container {
display: flex; display: flex;
@ -474,23 +549,6 @@ nav {
color: var(--faint, $fallback--faint); color: var(--faint, $fallback--faint);
box-shadow: 0px 0px 4px rgba(0,0,0,.6); box-shadow: 0px 0px 4px rgba(0,0,0,.6);
box-shadow: var(--topBarShadow); 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 { .fade-enter-active, .fade-leave-active {
@ -526,12 +584,6 @@ nav {
overflow-y: scroll; overflow-y: scroll;
} }
nav {
.back-button {
display: none;
}
}
.sidebar-bounds { .sidebar-bounds {
overflow: hidden; overflow: hidden;
max-height: 100vh; max-height: 100vh;
@ -806,54 +858,3 @@ nav {
.btn.btn-default { .btn.btn-default {
min-height: 28px; 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,53 +1,113 @@
<template> <template>
<div id="app" v-bind:style="bgAppStyle"> <div
<div class="app-bg-wrapper" v-bind:style="bgStyle"></div> id="app"
:style="bgAppStyle"
>
<div
class="app-bg-wrapper"
:style="bgStyle"
/>
<MobileNav v-if="isMobileLayout" /> <MobileNav v-if="isMobileLayout" />
<nav v-else class='nav-bar container' @click="scrollToTop()" id="nav"> <nav
<div class='logo' :style='logoBgStyle'> v-else
<div class='mask' :style='logoMaskStyle'></div> id="nav"
<img :src='logo' :style='logoStyle'> class="nav-bar container"
@click="scrollToTop()"
>
<div
class="logo"
:style="logoBgStyle"
>
<div
class="mask"
:style="logoMaskStyle"
/>
<img
:src="logo"
:style="logoStyle"
>
</div> </div>
<div class='inner-nav'> <div class="inner-nav">
<div class='item'> <div class="item">
<router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link> <router-link
class="site-name"
:to="{ name: 'root' }"
active-class="home"
>
{{ sitename }}
</router-link>
</div> </div>
<div class='item right'> <div class="item right">
<user-finder class="button-icon nav-icon mobile-hidden" @toggled="onFinderToggled"></user-finder> <search-bar
<router-link class="mobile-hidden" :to="{ name: 'settings'}"><i class="button-icon icon-cog nav-icon" :title="$t('nav.preferences')"></i></router-link> class="nav-icon mobile-hidden"
<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> @toggled="onSearchBarToggled"
@click.stop.native
/>
<router-link
class="mobile-hidden"
:to="{ name: 'settings'}"
>
<i
class="button-icon icon-cog nav-icon"
:title="$t('nav.preferences')"
/>
</router-link>
<a
v-if="currentUser"
href="#"
class="mobile-hidden"
@click.prevent="logout"
><i
class="button-icon icon-logout nav-icon"
:title="$t('login.logout')"
/></a>
</div> </div>
</div> </div>
</nav> </nav>
<div class="container" id="content"> <div
id="content"
class="container"
>
<div class="sidebar-flexer mobile-hidden"> <div class="sidebar-flexer mobile-hidden">
<div class="sidebar-bounds"> <div class="sidebar-bounds">
<div class="sidebar-scroller"> <div class="sidebar-scroller">
<div class="sidebar"> <div class="sidebar">
<user-panel></user-panel> <user-panel />
<div v-if="!isMobileLayout"> <div v-if="!isMobileLayout">
<nav-panel></nav-panel> <nav-panel />
<instance-specific-panel v-if="showInstanceSpecificPanel"></instance-specific-panel> <instance-specific-panel v-if="showInstanceSpecificPanel" />
<features-panel v-if="!currentUser && showFeaturesPanel"></features-panel> <features-panel v-if="!currentUser && showFeaturesPanel" />
<who-to-follow-panel v-if="currentUser && suggestionsEnabled"></who-to-follow-panel> <who-to-follow-panel v-if="currentUser && suggestionsEnabled" />
<notifications v-if="currentUser"></notifications> <notifications v-if="currentUser" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="main"> <div class="main">
<div v-if="!currentUser" class="login-hint panel panel-default"> <div
<router-link :to="{ name: 'login' }" class="panel-body"> v-if="!currentUser"
class="login-hint panel panel-default"
>
<router-link
:to="{ name: 'login' }"
class="panel-body"
>
{{ $t("login.hint") }} {{ $t("login.hint") }}
</router-link> </router-link>
</div> </div>
<transition name="fade"> <transition name="fade">
<router-view></router-view> <router-view />
</transition> </transition>
</div> </div>
<media-modal></media-modal> <media-modal />
</div> </div>
<chat-panel :floating="true" v-if="currentUser && chat" class="floating-chat mobile-hidden"></chat-panel> <chat-panel
v-if="currentUser && chat"
:floating="true"
class="floating-chat mobile-hidden"
/>
<MobilePostStatusModal />
<UserReportingModal /> <UserReportingModal />
<portal-target name="modal" /> <portal-target name="modal" />
</div> </div>

View file

@ -100,7 +100,6 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('redirectRootLogin') copyInstanceOption('redirectRootLogin')
copyInstanceOption('showInstanceSpecificPanel') copyInstanceOption('showInstanceSpecificPanel')
copyInstanceOption('minimalScopesMode') copyInstanceOption('minimalScopesMode')
copyInstanceOption('formattingOptionsEnabled')
copyInstanceOption('hideMutedPosts') copyInstanceOption('hideMutedPosts')
copyInstanceOption('collapseMessageWithSubject') copyInstanceOption('collapseMessageWithSubject')
copyInstanceOption('scopeCopy') copyInstanceOption('scopeCopy')
@ -110,12 +109,6 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('noAttachmentLinks') copyInstanceOption('noAttachmentLinks')
copyInstanceOption('showFeaturesPanel') copyInstanceOption('showFeaturesPanel')
if ((config.chatDisabled)) {
store.dispatch('disableChat')
} else {
store.dispatch('initializeSocket')
}
return store.dispatch('setTheme', config['theme']) return store.dispatch('setTheme', config['theme'])
} }
@ -149,13 +142,48 @@ const getInstancePanel = async ({ store }) => {
} }
} }
const getStickers = async ({ store }) => {
try {
const res = await window.fetch('/static/stickers.json')
if (res.ok) {
const values = await res.json()
const stickers = (await Promise.all(
Object.entries(values).map(async ([name, path]) => {
const resPack = await window.fetch(path + 'pack.json')
var meta = {}
if (resPack.ok) {
meta = await resPack.json()
}
return {
pack: name,
path,
meta
}
})
)).sort((a, b) => {
return a.meta.title.localeCompare(b.meta.title)
})
store.dispatch('setInstanceOption', { name: 'stickers', value: stickers })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load stickers")
console.warn(e)
}
}
const getStaticEmoji = async ({ store }) => { const getStaticEmoji = async ({ store }) => {
try { try {
const res = await window.fetch('/static/emoji.json') const res = await window.fetch('/static/emoji.json')
if (res.ok) { if (res.ok) {
const values = await res.json() const values = await res.json()
const emoji = Object.keys(values).map((key) => { const emoji = Object.keys(values).map((key) => {
return { shortcode: key, image_url: false, 'utf': values[key] } return {
displayText: key,
imageUrl: false,
replacement: values[key]
}
}) })
store.dispatch('setInstanceOption', { name: 'emoji', value: emoji }) store.dispatch('setInstanceOption', { name: 'emoji', value: emoji })
} else { } else {
@ -176,7 +204,12 @@ const getCustomEmoji = async ({ store }) => {
const result = await res.json() const result = await res.json()
const values = Array.isArray(result) ? Object.assign({}, ...result) : result const values = Array.isArray(result) ? Object.assign({}, ...result) : result
const emoji = Object.keys(values).map((key) => { const emoji = Object.keys(values).map((key) => {
return { shortcode: key, image_url: values[key].image_url || values[key] } const imageUrl = values[key].image_url
return {
displayText: key,
imageUrl: imageUrl ? store.state.instance.server + imageUrl : values[key],
replacement: `:${key}: `
}
}) })
store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji }) store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true }) store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true })
@ -207,11 +240,12 @@ const getNodeInfo = async ({ store }) => {
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
const metadata = data.metadata const metadata = data.metadata
const features = metadata.features const features = metadata.features
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') }) store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') }) store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames }) store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats }) store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
@ -277,6 +311,7 @@ const afterStoreSetup = async ({ store, i18n }) => {
setConfig({ store }), setConfig({ store }),
getTOS({ store }), getTOS({ store }),
getInstancePanel({ store }), getInstancePanel({ store }),
getStickers({ store }),
getStaticEmoji({ store }), getStaticEmoji({ store }),
getCustomEmoji({ store }), getCustomEmoji({ store }),
getNodeInfo({ store }) getNodeInfo({ store })

View file

@ -6,12 +6,12 @@ import ConversationPage from 'components/conversation-page/conversation-page.vue
import Interactions from 'components/interactions/interactions.vue' import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue' import DMs from 'components/dm_timeline/dm_timeline.vue'
import UserProfile from 'components/user_profile/user_profile.vue' import UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue'
import Settings from 'components/settings/settings.vue' import Settings from 'components/settings/settings.vue'
import Registration from 'components/registration/registration.vue' import Registration from 'components/registration/registration.vue'
import UserSettings from 'components/user_settings/user_settings.vue' import UserSettings from 'components/user_settings/user_settings.vue'
import FollowRequests from 'components/follow_requests/follow_requests.vue' import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.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 Notifications from 'components/notifications/notifications.vue'
import AuthForm from 'components/auth_form/auth_form.js' import AuthForm from 'components/auth_form/auth_form.js'
import ChatPanel from 'components/chat_panel/chat_panel.vue' import ChatPanel from 'components/chat_panel/chat_panel.vue'
@ -19,6 +19,14 @@ import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import About from 'components/about/about.vue' import About from 'components/about/about.vue'
export default (store) => { export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => {
if (store.state.users.currentUser) {
next()
} else {
next(store.state.instance.redirectRootNoLogin || '/main/all')
}
}
return [ return [
{ name: 'root', { name: 'root',
path: '/', path: '/',
@ -30,23 +38,23 @@ export default (store) => {
}, },
{ name: 'public-external-timeline', path: '/main/all', component: PublicAndExternalTimeline }, { name: 'public-external-timeline', path: '/main/all', component: PublicAndExternalTimeline },
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline }, { name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline }, { name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline }, { name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } }, { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile }, { name: 'external-user-profile', path: '/users/:id', component: UserProfile },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions }, { name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/users/:username/dms', component: DMs }, { name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
{ name: 'settings', path: '/settings', component: Settings }, { name: 'settings', path: '/settings', component: Settings },
{ name: 'registration', path: '/registration', component: Registration }, { name: 'registration', path: '/registration', component: Registration },
{ name: 'registration-token', path: '/registration/:token', component: Registration }, { name: 'registration-token', path: '/registration/:token', component: Registration },
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests }, { name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
{ name: 'user-settings', path: '/user-settings', component: UserSettings }, { name: 'user-settings', path: '/user-settings', component: UserSettings, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications }, { name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm }, { name: 'login', path: '/login', component: AuthForm },
{ name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) }, { name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) }, { 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: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow }, { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About }, { name: 'about', path: '/about', component: About },
{ name: 'user-profile', path: '/(users/)?:name', component: UserProfile } { name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
] ]

View file

@ -1,8 +1,8 @@
<template> <template>
<div class="sidebar"> <div class="sidebar">
<instance-specific-panel></instance-specific-panel> <instance-specific-panel />
<features-panel v-if="showFeaturesPanel"></features-panel> <features-panel v-if="showFeaturesPanel" />
<terms-of-service-panel></terms-of-service-panel> <terms-of-service-panel />
</div> </div>
</template> </template>

View file

@ -51,7 +51,7 @@ const Attachment = {
} }
}, },
methods: { methods: {
linkClicked ({target}) { linkClicked ({ target }) {
if (target.tagName === 'A') { if (target.tagName === 'A') {
window.open(target.href, '_blank') window.open(target.href, '_blank')
} }

View file

@ -1,54 +1,106 @@
<template> <template>
<div v-if="usePlaceHolder" @click="openModal"> <div
<a class="placeholder" v-if="usePlaceHolder"
v-if="type !== 'html'" @click="openModal"
target="_blank" :href="attachment.url"
> >
[{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}] <a
v-if="type !== 'html'"
class="placeholder"
target="_blank"
:href="attachment.url"
>
[{{ nsfw ? "NSFW/" : "" }}{{ type.toUpperCase() }}]
</a> </a>
</div> </div>
<div <div
v-else class="attachment" v-else
:class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
v-show="!isEmpty" v-show="!isEmpty"
class="attachment"
:class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
> >
<a class="image-attachment" v-if="hidden" :href="attachment.url" @click.prevent="toggleHidden"> <a
<img class="nsfw" :key="nsfwImage" :src="nsfwImage" :class="{'small': isSmall}"/> v-if="hidden"
<i v-if="type === 'video'" class="play-icon icon-play-circled"></i> class="image-attachment"
:href="attachment.url"
@click.prevent="toggleHidden"
>
<img
:key="nsfwImage"
class="nsfw"
:src="nsfwImage"
:class="{'small': isSmall}"
>
<i
v-if="type === 'video'"
class="play-icon icon-play-circled"
/>
</a> </a>
<div class="hider" v-if="nsfw && hideNsfwLocal && !hidden"> <div
<a href="#" @click.prevent="toggleHidden">Hide</a> v-if="nsfw && hideNsfwLocal && !hidden"
class="hider"
>
<a
href="#"
@click.prevent="toggleHidden"
>Hide</a>
</div> </div>
<a v-if="type === 'image' && (!hidden || preloadImage)" <a
@click="openModal" v-if="type === 'image' && (!hidden || preloadImage)"
class="image-attachment" class="image-attachment"
:class="{'hidden': hidden && preloadImage }" :class="{'hidden': hidden && preloadImage }"
:href="attachment.url" target="_blank" :href="attachment.url"
target="_blank"
:title="attachment.description" :title="attachment.description"
@click="openModal"
> >
<StillImage :referrerpolicy="referrerpolicy" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/> <StillImage
:referrerpolicy="referrerpolicy"
:mimetype="attachment.mimetype"
:src="attachment.large_thumb_url || attachment.url"
/>
</a> </a>
<a class="video-container" <a
@click="openModal"
v-if="type === 'video' && !hidden" v-if="type === 'video' && !hidden"
class="video-container"
:class="{'small': isSmall}" :class="{'small': isSmall}"
:href="allowPlay ? undefined : attachment.url" :href="allowPlay ? undefined : attachment.url"
@click="openModal"
> >
<VideoAttachment class="video" :attachment="attachment" :controls="allowPlay" /> <VideoAttachment
<i v-if="!allowPlay" class="play-icon icon-play-circled"></i> class="video"
:attachment="attachment"
:controls="allowPlay"
/>
<i
v-if="!allowPlay"
class="play-icon icon-play-circled"
/>
</a> </a>
<audio v-if="type === 'audio'" :src="attachment.url" controls></audio> <audio
v-if="type === 'audio'"
:src="attachment.url"
controls
/>
<div @click.prevent="linkClicked" v-if="type === 'html' && attachment.oembed" class="oembed"> <div
<div v-if="attachment.thumb_url" class="image"> v-if="type === 'html' && attachment.oembed"
<img :src="attachment.thumb_url"/> class="oembed"
@click.prevent="linkClicked"
>
<div
v-if="attachment.thumb_url"
class="image"
>
<img :src="attachment.thumb_url">
</div> </div>
<div class="text"> <div class="text">
<h1><a :href="attachment.url">{{attachment.oembed.title}}</a></h1> <!-- eslint-disable vue/no-v-html -->
<div v-html="attachment.oembed.oembedHTML"></div> <h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1>
<div v-html="attachment.oembed.oembedHTML" />
<!-- eslint-enable vue/no-v-html -->
</div> </div>
</div> </div>
</div> </div>
@ -68,6 +120,7 @@
max-height: 200px; max-height: 200px;
max-width: 100%; max-width: 100%;
display: flex; display: flex;
align-items: center;
video { video {
max-width: 100%; max-width: 100%;
} }

View file

@ -1,8 +1,22 @@
<template> <template>
<div class="autosuggest" v-click-outside="onClickOutside"> <div
<input v-model="term" :placeholder="placeholder" @click="onInputClick" class="autosuggest-input" /> v-click-outside="onClickOutside"
<div class="autosuggest-results" v-if="resultsVisible && filtered.length > 0"> class="autosuggest"
<slot v-for="item in filtered" :item="item" /> >
<input
v-model="term"
:placeholder="placeholder"
class="autosuggest-input"
@click="onInputClick"
>
<div
v-if="resultsVisible && filtered.length > 0"
class="autosuggest-results"
>
<slot
v-for="item in filtered"
:item="item"
/>
</div> </div>
</div> </div>
</template> </template>

View file

@ -1,7 +1,15 @@
<template> <template>
<div class="avatars"> <div class="avatars">
<router-link :to="userProfileLink(user)" class="avatars-item" v-for="user in slicedUsers"> <router-link
<UserAvatar :user="user" class="avatar-small" /> v-for="user in slicedUsers"
:key="user.id"
:to="userProfileLink(user)"
class="avatars-item"
>
<UserAvatar
:user="user"
class="avatar-small"
/>
</router-link> </router-link>
</div> </div>
</template> </template>

View file

@ -7,20 +7,45 @@
@click.prevent.native="toggleUserExpanded" @click.prevent.native="toggleUserExpanded"
/> />
</router-link> </router-link>
<div class="basic-user-card-expanded-content" v-if="userExpanded"> <div
<UserCard :user="user" :rounded="true" :bordered="true"/> v-if="userExpanded"
class="basic-user-card-expanded-content"
>
<UserCard
:user="user"
:rounded="true"
:bordered="true"
/>
</div> </div>
<div class="basic-user-card-collapsed-content" v-else> <div
<div :title="user.name" class="basic-user-card-user-name"> v-else
<span v-if="user.name_html" class="basic-user-card-user-name-value" v-html="user.name_html"></span> class="basic-user-card-collapsed-content"
<span v-else class="basic-user-card-user-name-value">{{ user.name }}</span> >
<div
:title="user.name"
class="basic-user-card-user-name"
>
<!-- eslint-disable vue/no-v-html -->
<span
v-if="user.name_html"
class="basic-user-card-user-name-value"
v-html="user.name_html"
/>
<!-- eslint-enable vue/no-v-html -->
<span
v-else
class="basic-user-card-user-name-value"
>{{ user.name }}</span>
</div> </div>
<div> <div>
<router-link class="basic-user-card-screen-name" :to="userProfileLink(user)"> <router-link
@{{user.screen_name}} class="basic-user-card-screen-name"
:to="userProfileLink(user)"
>
@{{ user.screen_name }}
</router-link> </router-link>
</div> </div>
<slot></slot> <slot />
</div> </div>
</div> </div>
</template> </template>
@ -62,6 +87,7 @@
&-expanded-content { &-expanded-content {
flex: 1; flex: 1;
margin-left: 0.7em; margin-left: 0.7em;
min-width: 0;
} }
} }
</style> </style>

View file

@ -1,7 +1,12 @@
<template> <template>
<basic-user-card :user="user"> <basic-user-card :user="user">
<div class="block-card-content-container"> <div class="block-card-content-container">
<button class="btn btn-default" @click="unblockUser" :disabled="progress" v-if="blocked"> <button
v-if="blocked"
class="btn btn-default"
:disabled="progress"
@click="unblockUser"
>
<template v-if="progress"> <template v-if="progress">
{{ $t('user_card.unblock_progress') }} {{ $t('user_card.unblock_progress') }}
</template> </template>
@ -9,7 +14,12 @@
{{ $t('user_card.unblock') }} {{ $t('user_card.unblock') }}
</template> </template>
</button> </button>
<button class="btn btn-default" @click="blockUser" :disabled="progress" v-else> <button
v-else
class="btn btn-default"
:disabled="progress"
@click="blockUser"
>
<template v-if="progress"> <template v-if="progress">
{{ $t('user_card.block_progress') }} {{ $t('user_card.block_progress') }}
</template> </template>

View file

@ -16,7 +16,7 @@ const chatPanel = {
}, },
methods: { methods: {
submit (message) { submit (message) {
this.$store.state.chat.channel.push('new_msg', {text: message}, 10000) this.$store.state.chat.channel.push('new_msg', { text: message }, 10000)
this.currentMessage = '' this.currentMessage = ''
}, },
togglePanel () { togglePanel () {

View file

@ -1,41 +1,70 @@
<template> <template>
<div class="chat-panel" v-if="!this.collapsed || !this.floating"> <div
v-if="!collapsed || !floating"
class="chat-panel"
>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading timeline-heading" :class="{ 'chat-heading': floating }" @click.stop.prevent="togglePanel"> <div
class="panel-heading timeline-heading"
:class="{ 'chat-heading': floating }"
@click.stop.prevent="togglePanel"
>
<div class="title"> <div class="title">
<span>{{$t('chat.title')}}</span> <span>{{ $t('chat.title') }}</span>
<i class="icon-cancel" v-if="floating"></i> <i
v-if="floating"
class="icon-cancel"
/>
</div> </div>
</div> </div>
<div class="chat-window" v-chat-scroll> <div
<div class="chat-message" v-for="message in messages" :key="message.id"> v-chat-scroll
class="chat-window"
>
<div
v-for="message in messages"
:key="message.id"
class="chat-message"
>
<span class="chat-avatar"> <span class="chat-avatar">
<img :src="message.author.avatar" /> <img :src="message.author.avatar">
</span> </span>
<div class="chat-content"> <div class="chat-content">
<router-link <router-link
class="chat-name" class="chat-name"
:to="userProfileLink(message.author)"> :to="userProfileLink(message.author)"
{{message.author.username}} >
{{ message.author.username }}
</router-link> </router-link>
<br> <br>
<span class="chat-text"> <span class="chat-text">
{{message.text}} {{ message.text }}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div class="chat-input"> <div class="chat-input">
<textarea @keyup.enter="submit(currentMessage)" v-model="currentMessage" class="chat-input-textarea" rows="1"></textarea> <textarea
v-model="currentMessage"
class="chat-input-textarea"
rows="1"
@keyup.enter="submit(currentMessage)"
/>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="chat-panel"> <div
v-else
class="chat-panel"
>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading stub timeline-heading chat-heading" @click.stop.prevent="togglePanel"> <div
class="panel-heading stub timeline-heading chat-heading"
@click.stop.prevent="togglePanel"
>
<div class="title"> <div class="title">
<i class="icon-comment-empty"></i> <i class="icon-comment-empty" />
{{$t('chat.title')}} {{ $t('chat.title') }}
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,8 +1,13 @@
<template> <template>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" :checked="checked" @change="$emit('change', $event.target.checked)" :indeterminate.prop="indeterminate"> <input
type="checkbox"
:checked="checked"
:indeterminate.prop="indeterminate"
@change="$emit('change', $event.target.checked)"
>
<i class="checkbox-indicator" /> <i class="checkbox-indicator" />
<span v-if="!!$slots.default"><slot></slot></span> <span v-if="!!$slots.default"><slot /></span>
</label> </label>
</template> </template>

View file

@ -1,16 +1,27 @@
<template> <template>
<div class="color-control style-control" :class="{ disabled: !present || disabled }"> <div
<label :for="name" class="label"> class="color-control style-control"
{{label}} :class="{ disabled: !present || disabled }"
>
<label
:for="name"
class="label"
>
{{ label }}
</label> </label>
<input <input
v-if="typeof fallback !== 'undefined'" v-if="typeof fallback !== 'undefined'"
class="opt exlcude-disabled"
:id="name + '-o'" :id="name + '-o'"
class="opt exlcude-disabled"
type="checkbox" type="checkbox"
:checked="present" :checked="present"
@input="$emit('input', typeof value === 'undefined' ? fallback : undefined)"> @input="$emit('input', typeof value === 'undefined' ? fallback : undefined)"
<label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label> >
<label
v-if="typeof fallback !== 'undefined'"
class="opt-l"
:for="name + '-o'"
/>
<input <input
:id="name" :id="name"
class="color-input" class="color-input"
@ -27,7 +38,7 @@
:disabled="!present || disabled" :disabled="!present || disabled"
@input="$emit('input', $event.target.value)" @input="$emit('input', $event.target.value)"
> >
</div> </div>
</template> </template>
<script> <script>

View file

@ -1,28 +1,38 @@
<template> <template>
<span v-if="contrast" class="contrast-ratio"> <span
<span :title="hint" class="rating"> v-if="contrast"
class="contrast-ratio"
>
<span
:title="hint"
class="rating"
>
<span v-if="contrast.aaa"> <span v-if="contrast.aaa">
<i class="icon-thumbs-up-alt"/> <i class="icon-thumbs-up-alt" />
</span> </span>
<span v-if="!contrast.aaa && contrast.aa"> <span v-if="!contrast.aaa && contrast.aa">
<i class="icon-adjust"/> <i class="icon-adjust" />
</span> </span>
<span v-if="!contrast.aaa && !contrast.aa"> <span v-if="!contrast.aaa && !contrast.aa">
<i class="icon-attention"/> <i class="icon-attention" />
</span> </span>
</span> </span>
<span class="rating" v-if="contrast && large" :title="hint_18pt"> <span
v-if="contrast && large"
class="rating"
:title="hint_18pt"
>
<span v-if="contrast.laaa"> <span v-if="contrast.laaa">
<i class="icon-thumbs-up-alt"/> <i class="icon-thumbs-up-alt" />
</span> </span>
<span v-if="!contrast.laaa && contrast.laa"> <span v-if="!contrast.laaa && contrast.laa">
<i class="icon-adjust"/> <i class="icon-adjust" />
</span> </span>
<span v-if="!contrast.laaa && !contrast.laa"> <span v-if="!contrast.laaa && !contrast.laa">
<i class="icon-attention"/> <i class="icon-attention" />
</span>
</span> </span>
</span> </span>
</span>
</template> </template>
<script> <script>

View file

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

View file

@ -42,7 +42,7 @@ const conversation = {
'statusoid', 'statusoid',
'collapsable', 'collapsable',
'isPage', 'isPage',
'showPinned' 'pinnedStatusIdsObject'
], ],
created () { created () {
if (this.isPage) { if (this.isPage) {
@ -86,7 +86,8 @@ const conversation = {
}, },
replies () { replies () {
let i = 1 let i = 1
return reduce(this.conversation, (result, {id, in_reply_to_status_id}) => { // eslint-disable-next-line camelcase
return reduce(this.conversation, (result, { id, in_reply_to_status_id }) => {
/* eslint-disable camelcase */ /* eslint-disable camelcase */
const irid = in_reply_to_status_id const irid = in_reply_to_status_id
/* eslint-enable camelcase */ /* eslint-enable camelcase */
@ -109,7 +110,7 @@ const conversation = {
Status Status
}, },
watch: { watch: {
'$route': 'fetchConversation', status: 'fetchConversation',
expanded (value) { expanded (value) {
if (value) { if (value) {
this.fetchConversation() this.fetchConversation()
@ -119,15 +120,15 @@ const conversation = {
methods: { methods: {
fetchConversation () { fetchConversation () {
if (this.status) { if (this.status) {
this.$store.state.api.backendInteractor.fetchConversation({id: this.status.id}) this.$store.state.api.backendInteractor.fetchConversation({ id: this.status.id })
.then(({ancestors, descendants}) => { .then(({ ancestors, descendants }) => {
this.$store.dispatch('addNewStatuses', { statuses: ancestors }) this.$store.dispatch('addNewStatuses', { statuses: ancestors })
this.$store.dispatch('addNewStatuses', { statuses: descendants }) this.$store.dispatch('addNewStatuses', { statuses: descendants })
}) })
.then(() => this.setHighlight(this.statusId)) .then(() => this.setHighlight(this.statusId))
} else { } else {
const id = this.$route.params.id const id = this.$route.params.id
this.$store.state.api.backendInteractor.fetchStatus({id}) this.$store.state.api.backendInteractor.fetchStatus({ id })
.then((status) => this.$store.dispatch('addNewStatuses', { statuses: [status] })) .then((status) => this.$store.dispatch('addNewStatuses', { statuses: [status] }))
.then(() => this.fetchConversation()) .then(() => this.fetchConversation())
} }
@ -139,6 +140,7 @@ const conversation = {
return (this.isExpanded) && id === this.status.id return (this.isExpanded) && id === this.status.id
}, },
setHighlight (id) { setHighlight (id) {
if (!id) return
this.highlight = id this.highlight = id
this.$store.dispatch('fetchFavsAndRepeats', id) this.$store.dispatch('fetchFavsAndRepeats', id)
}, },
@ -147,9 +149,6 @@ const conversation = {
}, },
toggleExpanded () { toggleExpanded () {
this.expanded = !this.expanded this.expanded = !this.expanded
if (!this.expanded) {
this.setHighlight(null)
}
} }
} }
} }

View file

@ -1,25 +1,34 @@
<template> <template>
<div class="timeline panel-default" :class="[isExpanded ? 'panel' : 'panel-disabled']"> <div
<div v-if="isExpanded" class="panel-heading conversation-heading"> 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 class="title"> {{ $t('timeline.conversation') }} </span>
<span v-if="collapsable"> <span v-if="collapsable">
<a href="#" @click.prevent="toggleExpanded">{{ $t('timeline.collapse') }}</a> <a
href="#"
@click.prevent="toggleExpanded"
>{{ $t('timeline.collapse') }}</a>
</span> </span>
</div> </div>
<status <status
v-for="status in conversation" v-for="status in conversation"
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
:key="status.id" :key="status.id"
:inlineExpanded="collapsable && isExpanded" :inline-expanded="collapsable && isExpanded"
:statusoid="status" :statusoid="status"
:expandable='!isExpanded' :expandable="!isExpanded"
:showPinned="showPinned" :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="focused(status.id)" :focused="focused(status.id)"
:inConversation="isExpanded" :in-conversation="isExpanded"
:highlight="getHighlight()" :highlight="getHighlight()"
:replies="getReplies(status.id)" :replies="getReplies(status.id)"
class="status-fadein panel-body" class="status-fadein panel-body"
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
/> />
</div> </div>
</template> </template>

View file

@ -1,16 +1,22 @@
<template> <template>
<span v-bind:class="{ 'dark-overlay': darkOverlay }" @click.self.stop='onCancel()'> <span
<div class="dialog-modal panel panel-default" @click.stop=''> :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="panel-heading dialog-modal-heading">
<div class="title"> <div class="title">
<slot name="header"></slot> <slot name="header" />
</div> </div>
</div> </div>
<div class="dialog-modal-content"> <div class="dialog-modal-content">
<slot name="default"></slot> <slot name="default" />
</div> </div>
<div class="dialog-modal-footer user-interactions panel-footer"> <div class="dialog-modal-footer user-interactions panel-footer">
<slot name="footer"></slot> <slot name="footer" />
</div> </div>
</div> </div>
</span> </span>

View file

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

View file

@ -1,51 +1,122 @@
import Completion from '../../services/completion/completion.js' import Completion from '../../services/completion/completion.js'
import { take, filter, map } from 'lodash' import { take } from 'lodash'
/**
* EmojiInput - augmented inputs for emoji and autocomplete support in inputs
* without having to give up the comfort of <input/> and <textarea/> elements
*
* Intended usage is:
* <EmojiInput v-model="something">
* <input v-model="something"/>
* </EmojiInput>
*
* Works only with <input> and <textarea>. Intended to use with only one nested
* input. It will find first input or textarea and work with that, multiple
* nested children not tested. You HAVE TO duplicate v-model for both
* <emoji-input> and <input>/<textarea> otherwise it will not work.
*
* Be prepared for CSS troubles though because it still wraps component in a div
* while TRYING to make it look like nothing happened, but it could break stuff.
*/
const EmojiInput = { const EmojiInput = {
props: [ props: {
'value', suggest: {
'placeholder', /**
'type', * suggest: function (input: String) => Suggestion[]
'classname' *
], * Function that takes input string which takes string (textAtCaret)
* and returns an array of Suggestions
*
* Suggestion is an object containing following properties:
* displayText: string. Main display text, what actual suggestion
* represents (user's screen name/emoji shortcode)
* replacement: string. Text that should replace the textAtCaret
* detailText: string, optional. Subtitle text, providing additional info
* if present (user's nickname)
* imageUrl: string, optional. Image to display alongside with suggestion,
* currently if no image is provided, replacement will be used (for
* unicode emojis)
*
* TODO: make it asynchronous when adding proper server-provided user
* suggestions
*
* For commonly used suggestors (emoji, users, both) use suggestor.js
*/
required: true,
type: Function
},
value: {
/**
* Used for v-model
*/
required: true,
type: String
}
},
data () { data () {
return { return {
input: undefined,
highlighted: 0, highlighted: 0,
caret: 0 caret: 0,
focused: false,
blurTimeout: null
} }
}, },
computed: { computed: {
suggestions () { suggestions () {
const firstchar = this.textAtCaret.charAt(0) const firstchar = this.textAtCaret.charAt(0)
if (firstchar === ':') { if (this.textAtCaret === firstchar) { return [] }
if (this.textAtCaret === ':') { return } const matchedSuggestions = this.suggest(this.textAtCaret)
const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1))) if (matchedSuggestions.length <= 0) {
if (matchedEmoji.length <= 0) { return []
return false
} }
return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({ return take(matchedSuggestions, 5)
shortcode: `:${shortcode}:`, .map(({ imageUrl, ...rest }, index) => ({
utf: utf || '', ...rest,
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
img: utf ? '' : this.$store.state.instance.server + image_url, img: imageUrl || '',
highlighted: index === this.highlighted highlighted: index === this.highlighted
})) }))
} else { },
return false showPopup () {
} return this.focused && this.suggestions && this.suggestions.length > 0
}, },
textAtCaret () { textAtCaret () {
return (this.wordAtCaret || {}).word || '' return (this.wordAtCaret || {}).word || ''
}, },
wordAtCaret () { wordAtCaret () {
if (this.value && this.caret) {
const word = Completion.wordAtPosition(this.value, this.caret - 1) || {} const word = Completion.wordAtPosition(this.value, this.caret - 1) || {}
return word return word
}
}
}, },
emoji () { mounted () {
return this.$store.state.instance.emoji || [] const slots = this.$slots.default
if (!slots || slots.length === 0) return
const input = slots.find(slot => ['input', 'textarea'].includes(slot.tag))
if (!input) return
this.input = input
this.resize()
input.elm.addEventListener('blur', this.onBlur)
input.elm.addEventListener('focus', this.onFocus)
input.elm.addEventListener('paste', this.onPaste)
input.elm.addEventListener('keyup', this.onKeyUp)
input.elm.addEventListener('keydown', this.onKeyDown)
input.elm.addEventListener('transitionend', this.onTransition)
input.elm.addEventListener('compositionupdate', this.onCompositionUpdate)
}, },
customEmoji () { unmounted () {
return this.$store.state.instance.customEmoji || [] const { input } = this
if (input) {
input.elm.removeEventListener('blur', this.onBlur)
input.elm.removeEventListener('focus', this.onFocus)
input.elm.removeEventListener('paste', this.onPaste)
input.elm.removeEventListener('keyup', this.onKeyUp)
input.elm.removeEventListener('keydown', this.onKeyDown)
input.elm.removeEventListener('transitionend', this.onTransition)
input.elm.removeEventListener('compositionupdate', this.onCompositionUpdate)
} }
}, },
methods: { methods: {
@ -54,27 +125,35 @@ const EmojiInput = {
this.$emit('input', newValue) this.$emit('input', newValue)
this.caret = 0 this.caret = 0
}, },
replaceEmoji (e) { replaceText (e, suggestion) {
const len = this.suggestions.length || 0 const len = this.suggestions.length || 0
if (this.textAtCaret === ':' || e.ctrlKey) { return } if (this.textAtCaret.length === 1) { return }
if (len > 0) { if (len > 0 || suggestion) {
e.preventDefault() const chosenSuggestion = suggestion || this.suggestions[this.highlighted]
const emoji = this.suggestions[this.highlighted] const replacement = chosenSuggestion.replacement
const replacement = emoji.utf || (emoji.shortcode + ' ')
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement) const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue) this.$emit('input', newValue)
this.caret = 0
this.highlighted = 0 this.highlighted = 0
const position = this.wordAtCaret.start + replacement.length
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
this.input.elm.focus()
// Set selection right after the replacement instead of the very end
this.input.elm.setSelectionRange(position, position)
this.caret = position
})
e.preventDefault()
} }
}, },
cycleBackward (e) { cycleBackward (e) {
const len = this.suggestions.length || 0 const len = this.suggestions.length || 0
if (len > 0) { if (len > 0) {
e.preventDefault()
this.highlighted -= 1 this.highlighted -= 1
if (this.highlighted < 0) { if (this.highlighted < 0) {
this.highlighted = this.suggestions.length - 1 this.highlighted = this.suggestions.length - 1
} }
e.preventDefault()
} else { } else {
this.highlighted = 0 this.highlighted = 0
} }
@ -82,24 +161,88 @@ const EmojiInput = {
cycleForward (e) { cycleForward (e) {
const len = this.suggestions.length || 0 const len = this.suggestions.length || 0
if (len > 0) { if (len > 0) {
if (e.shiftKey) { return }
e.preventDefault()
this.highlighted += 1 this.highlighted += 1
if (this.highlighted >= len) { if (this.highlighted >= len) {
this.highlighted = 0 this.highlighted = 0
} }
e.preventDefault()
} else { } else {
this.highlighted = 0 this.highlighted = 0
} }
}, },
onKeydown (e) { onTransition (e) {
e.stopPropagation() this.resize()
},
onBlur (e) {
// Clicking on any suggestion removes focus from autocomplete,
// preventing click handler ever executing.
this.blurTimeout = setTimeout(() => {
this.focused = false
this.setCaret(e)
this.resize()
}, 200)
},
onClick (e, suggestion) {
this.replaceText(e, suggestion)
},
onFocus (e) {
if (this.blurTimeout) {
clearTimeout(this.blurTimeout)
this.blurTimeout = null
}
this.focused = true
this.setCaret(e)
this.resize()
},
onKeyUp (e) {
this.setCaret(e)
this.resize()
},
onPaste (e) {
this.setCaret(e)
this.resize()
},
onKeyDown (e) {
this.setCaret(e)
this.resize()
const { ctrlKey, shiftKey, key } = e
if (key === 'Tab') {
if (shiftKey) {
this.cycleBackward(e)
} else {
this.cycleForward(e)
}
}
if (key === 'ArrowUp') {
this.cycleBackward(e)
} else if (key === 'ArrowDown') {
this.cycleForward(e)
}
if (key === 'Enter') {
if (!ctrlKey) {
this.replaceText(e)
}
}
}, },
onInput (e) { onInput (e) {
this.setCaret(e)
this.$emit('input', e.target.value) this.$emit('input', e.target.value)
}, },
setCaret ({target: {selectionStart}}) { onCompositionUpdate (e) {
this.setCaret(e)
this.resize()
this.$emit('input', e.target.value)
},
setCaret ({ target: { selectionStart } }) {
this.caret = selectionStart this.caret = selectionStart
},
resize () {
const { panel } = this.$refs
if (!panel) return
const { offsetHeight, offsetTop } = this.input.elm
this.$refs.panel.style.top = (offsetTop + offsetHeight) + 'px'
} }
} }
} }

View file

@ -1,50 +1,30 @@
<template> <template>
<div class="emoji-input"> <div class="emoji-input">
<input <slot />
v-if="type !== 'textarea'" <div
:class="classname" ref="panel"
:type="type" class="autocomplete-panel"
:value="value" :class="{ hide: !showPopup }"
: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 class="autocomplete-panel-body">
<div <div
v-for="(emoji, index) in suggestions" v-for="(suggestion, index) in suggestions"
:key="index" :key="index"
@click="replace(emoji.utf || (emoji.shortcode + ' '))"
class="autocomplete-item" class="autocomplete-item"
:class="{ highlighted: emoji.highlighted }" :class="{ highlighted: suggestion.highlighted }"
@click.stop.prevent="onClick($event, suggestion)"
> >
<span v-if="emoji.img"> <span class="image">
<img :src="emoji.img" /> <img
v-if="suggestion.img"
:src="suggestion.img"
>
<span v-else>{{ suggestion.replacement }}</span>
</span> </span>
<span v-else>{{emoji.utf}}</span> <div class="label">
<span>{{emoji.shortcode}}</span> <span class="displayText">{{ suggestion.displayText }}</span>
<span class="detailText">{{ suggestion.detailText }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -57,8 +37,81 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.emoji-input { .emoji-input {
.form-control { display: flex;
width: 100%; flex-direction: column;
.autocomplete {
&-panel {
position: absolute;
z-index: 9;
margin-top: 2px;
&.hide {
display: none
}
&-body {
margin: 0 0.5em 0 0.5em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
box-shadow: var(--popupShadow);
min-width: 75%;
background: $fallback--bg;
background: var(--bg, $fallback--bg);
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
}
&-item {
display: flex;
cursor: pointer;
padding: 0.2em 0.4em;
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
height: 32px;
.image {
width: 32px;
height: 32px;
line-height: 32px;
text-align: center;
font-size: 32px;
margin-right: 4px;
img {
width: 32px;
height: 32px;
object-fit: contain;
}
}
.label {
display: flex;
flex-direction: column;
justify-content: center;
margin: 0 0.1em 0 0.2em;
.displayText {
line-height: 1.5;
}
.detailText {
font-size: 9px;
line-height: 9px;
}
}
&.highlighted {
background-color: $fallback--fg;
background-color: var(--lightBg, $fallback--fg);
}
}
}
input, textarea {
flex: 1 0 auto;
} }
} }
</style> </style>

View file

@ -0,0 +1,94 @@
import { debounce } from 'lodash'
/**
* suggest - generates a suggestor function to be used by emoji-input
* data: object providing source information for specific types of suggestions:
* data.emoji - optional, an array of all emoji available i.e.
* (state.instance.emoji + state.instance.customEmoji)
* data.users - optional, an array of all known users
* updateUsersList - optional, a function to search and append to users
*
* Depending on data present one or both (or none) can be present, so if field
* doesn't support user linking you can just provide only emoji.
*/
const debounceUserSearch = debounce((data, input) => {
data.updateUsersList(input)
}, 500, { leading: true, trailing: false })
export default data => input => {
const firstChar = input[0]
if (firstChar === ':' && data.emoji) {
return suggestEmoji(data.emoji)(input)
}
if (firstChar === '@' && data.users) {
return suggestUsers(data)(input)
}
return []
}
export const suggestEmoji = emojis => input => {
const noPrefix = input.toLowerCase().substr(1)
return emojis
.filter(({ displayText }) => displayText.toLowerCase().startsWith(noPrefix))
.sort((a, b) => {
let aScore = 0
let bScore = 0
// Make custom emojis a priority
aScore += a.imageUrl ? 10 : 0
bScore += b.imageUrl ? 10 : 0
// Sort alphabetically
const alphabetically = a.displayText > b.displayText ? 1 : -1
return bScore - aScore + alphabetically
})
}
export const suggestUsers = data => input => {
const noPrefix = input.toLowerCase().substr(1)
const users = data.users
const newUsers = users.filter(
user =>
user.screen_name.toLowerCase().startsWith(noPrefix) ||
user.name.toLowerCase().startsWith(noPrefix)
/* taking only 20 results so that sorting is a bit cheaper, we display
* only 5 anyway. could be inaccurate, but we ideally we should query
* backend anyway
*/
).slice(0, 20).sort((a, b) => {
let aScore = 0
let bScore = 0
// Matches on screen name (i.e. user@instance) makes a priority
aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
// Matches on name takes second priority
aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
const diff = (bScore - aScore) * 10
// Then sort alphabetically
const nameAlphabetically = a.name > b.name ? 1 : -1
const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1
return diff + nameAlphabetically + screenNameAlphabetically
/* eslint-disable camelcase */
}).map(({ screen_name, name, profile_image_url_original }) => ({
displayText: screen_name,
detailText: name,
imageUrl: profile_image_url_original,
replacement: '@' + screen_name + ' '
}))
// BE search users if there are no matches
if (newUsers.length === 0 && data.updateUsersList) {
debounceUserSearch(data, noPrefix)
}
return newUsers
/* eslint-enable camelcase */
}

View file

@ -1,12 +1,27 @@
<template> <template>
<div class="import-export-container"> <div class="import-export-container">
<slot name="before"/> <slot name="before" />
<button class="btn" @click="exportData">{{ exportLabel }}</button> <button
<button class="btn" @click="importData">{{ importLabel }}</button> class="btn"
<slot name="afterButtons"/> @click="exportData"
<p v-if="importFailed" class="alert error">{{ importFailedText }}</p> >
<slot name="afterError"/> {{ exportLabel }}
</div> </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> </template>
<script> <script>
@ -49,7 +64,7 @@ export default {
if (event.target.files[0]) { if (event.target.files[0]) {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const reader = new FileReader() const reader = new FileReader()
reader.onload = ({target}) => { reader.onload = ({ target }) => {
try { try {
const parsed = JSON.parse(target.result) const parsed = JSON.parse(target.result)
const valid = this.validator(parsed) const valid = this.validator(parsed)

View file

@ -1,10 +1,16 @@
<template> <template>
<div class="exporter"> <div class="exporter">
<div v-if="processing"> <div v-if="processing">
<i class="icon-spin4 animate-spin exporter-processing"></i> <i class="icon-spin4 animate-spin exporter-processing" />
<span>{{processingMessage}}</span> <span>{{ processingMessage }}</span>
</div> </div>
<button class="btn btn-default" @click="process" v-else>{{exportButtonLabel}}</button> <button
v-else
class="btn btn-default"
@click="process"
>
{{ exportButtonLabel }}
</button>
</div> </div>
</template> </template>

View file

@ -1,45 +1,31 @@
import Popper from 'vue-popperjs/src/component/popper.js.vue'
const ExtraButtons = { const ExtraButtons = {
props: [ 'status' ], props: [ 'status' ],
components: {
Popper
},
data () {
return {
showDropDown: false,
showPopper: true
}
},
methods: { methods: {
deleteStatus () { deleteStatus () {
this.refreshPopper()
const confirmed = window.confirm(this.$t('status.delete_confirm')) const confirmed = window.confirm(this.$t('status.delete_confirm'))
if (confirmed) { if (confirmed) {
this.$store.dispatch('deleteStatus', { id: this.status.id }) this.$store.dispatch('deleteStatus', { id: this.status.id })
} }
}, },
toggleMenu () {
this.showDropDown = !this.showDropDown
},
pinStatus () { pinStatus () {
this.refreshPopper()
this.$store.dispatch('pinStatus', this.status.id) this.$store.dispatch('pinStatus', this.status.id)
.then(() => this.$emit('onSuccess')) .then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error)) .catch(err => this.$emit('onError', err.error.error))
}, },
unpinStatus () { unpinStatus () {
this.refreshPopper()
this.$store.dispatch('unpinStatus', this.status.id) this.$store.dispatch('unpinStatus', this.status.id)
.then(() => this.$emit('onSuccess')) .then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error)) .catch(err => this.$emit('onError', err.error.error))
}, },
refreshPopper () { muteConversation () {
this.showPopper = false this.$store.dispatch('muteConversation', this.status.id)
this.showDropDown = false .then(() => this.$emit('onSuccess'))
setTimeout(() => { .catch(err => this.$emit('onError', err.error.error))
this.showPopper = true },
}) unmuteConversation () {
this.$store.dispatch('unmuteConversation', this.status.id)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
} }
}, },
computed: { computed: {
@ -55,8 +41,8 @@ const ExtraButtons = {
canPin () { canPin () {
return this.ownStatus && (this.status.visibility === 'public' || this.status.visibility === 'unlisted') return this.ownStatus && (this.status.visibility === 'public' || this.status.visibility === 'unlisted')
}, },
enabled () { canMute () {
return this.canPin || this.canDelete return !!this.currentUser
} }
} }
} }

View file

@ -1,34 +1,58 @@
<template> <template>
<Popper <v-popover
v-if="canDelete || canMute || canPin"
trigger="click" trigger="click"
@hide='showDropDown = false' placement="top"
append-to-body class="extra-button-popover"
v-if="enabled && showPopper" :offset="5"
:options="{ :container="false"
placement: 'top',
modifiers: {
arrow: { enabled: true },
offset: { offset: '0, 5px' },
}
}"
> >
<div class="popper-wrapper"> <div slot="popover">
<div class="dropdown-menu"> <div class="dropdown-menu">
<button class="dropdown-item dropdown-item-icon" @click.prevent="pinStatus" v-if="!status.pinned && canPin"> <button
<i class="icon-pin"></i><span>{{$t("status.pin")}}</span> v-if="canMute && !status.muted"
class="dropdown-item dropdown-item-icon"
@click.prevent="muteConversation"
>
<i class="icon-eye-off" /><span>{{ $t("status.mute_conversation") }}</span>
</button> </button>
<button class="dropdown-item dropdown-item-icon" @click.prevent="unpinStatus" v-if="status.pinned && canPin"> <button
<i class="icon-pin"></i><span>{{$t("status.unpin")}}</span> v-if="canMute && status.muted"
class="dropdown-item dropdown-item-icon"
@click.prevent="unmuteConversation"
>
<i class="icon-eye-off" /><span>{{ $t("status.unmute_conversation") }}</span>
</button> </button>
<button class="dropdown-item dropdown-item-icon" @click.prevent="deleteStatus" v-if="canDelete"> <button
<i class="icon-cancel"></i><span>{{$t("status.delete")}}</span> v-if="!status.pinned && canPin"
v-close-popover
class="dropdown-item dropdown-item-icon"
@click.prevent="pinStatus"
>
<i class="icon-pin" /><span>{{ $t("status.pin") }}</span>
</button>
<button
v-if="status.pinned && canPin"
v-close-popover
class="dropdown-item dropdown-item-icon"
@click.prevent="unpinStatus"
>
<i class="icon-pin" /><span>{{ $t("status.unpin") }}</span>
</button>
<button
v-if="canDelete"
v-close-popover
class="dropdown-item dropdown-item-icon"
@click.prevent="deleteStatus"
>
<i class="icon-cancel" /><span>{{ $t("status.delete") }}</span>
</button> </button>
</div> </div>
</div> </div>
<div class="button-icon" slot="reference" @click="toggleMenu"> <div class="button-icon">
<i class='icon-ellipsis' :class="{'icon-clicked': showDropDown}"></i> <i class="icon-ellipsis" />
</div> </div>
</Popper> </v-popover>
</template> </template>
<script src="./extra_buttons.js" ></script> <script src="./extra_buttons.js" ></script>
@ -40,7 +64,8 @@
.icon-ellipsis { .icon-ellipsis {
cursor: pointer; cursor: pointer;
&:hover, &.icon-clicked { &:hover,
.extra-button-popover.open & {
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }

View file

@ -11,9 +11,9 @@ const FavoriteButton = {
methods: { methods: {
favorite () { favorite () {
if (!this.status.favorited) { if (!this.status.favorited) {
this.$store.dispatch('favorite', {id: this.status.id}) this.$store.dispatch('favorite', { id: this.status.id })
} else { } else {
this.$store.dispatch('unfavorite', {id: this.status.id}) this.$store.dispatch('unfavorite', { id: this.status.id })
} }
this.animated = true this.animated = true
setTimeout(() => { setTimeout(() => {

View file

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

View file

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

View file

@ -3,17 +3,25 @@
<div class="panel panel-default base01-background"> <div class="panel panel-default base01-background">
<div class="panel-heading timeline-heading base02-background base04"> <div class="panel-heading timeline-heading base02-background base04">
<div class="title"> <div class="title">
{{$t('features_panel.title')}} {{ $t('features_panel.title') }}
</div> </div>
</div> </div>
<div class="panel-body features-panel"> <div class="panel-body features-panel">
<ul> <ul>
<li v-if="chat">{{$t('features_panel.chat')}}</li> <li v-if="chat">
<li v-if="gopher">{{$t('features_panel.gopher')}}</li> {{ $t('features_panel.chat') }}
<li v-if="whoToFollow">{{$t('features_panel.who_to_follow')}}</li> </li>
<li v-if="mediaProxy">{{$t('features_panel.media_proxy')}}</li> <li v-if="gopher">
<li>{{$t('features_panel.scope_options')}}</li> {{ $t('features_panel.gopher') }}
<li>{{$t('features_panel.text_limit')}} = {{textlimit}}</li> </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> </ul>
</div> </div>
</div> </div>

View file

@ -1,11 +1,17 @@
<template> <template>
<basic-user-card :user="user"> <basic-user-card :user="user">
<div class="follow-card-content-container"> <div class="follow-card-content-container">
<span class="faint" v-if="!noFollowsYou && user.follows_you"> <span
v-if="!noFollowsYou && user.follows_you"
class="faint"
>
{{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }} {{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }}
</span> </span>
<template v-if="!loggedIn"> <template v-if="!loggedIn">
<div class="follow-card-follow-button" v-if="!user.following"> <div
v-if="!user.following"
class="follow-card-follow-button"
>
<RemoteFollow :user="user" /> <RemoteFollow :user="user" />
</div> </div>
</template> </template>
@ -13,9 +19,9 @@
<button <button
v-if="!user.following" v-if="!user.following"
class="btn btn-default follow-card-follow-button" class="btn btn-default follow-card-follow-button"
@click="followUser"
:disabled="inProgress" :disabled="inProgress"
:title="requestSent ? $t('user_card.follow_again') : ''" :title="requestSent ? $t('user_card.follow_again') : ''"
@click="followUser"
> >
<template v-if="inProgress"> <template v-if="inProgress">
{{ $t('user_card.follow_progress') }} {{ $t('user_card.follow_progress') }}
@ -27,7 +33,12 @@
{{ $t('user_card.follow') }} {{ $t('user_card.follow') }}
</template> </template>
</button> </button>
<button v-else class="btn btn-default follow-card-follow-button pressed" @click="unfollowUser" :disabled="inProgress"> <button
v-else
class="btn btn-default follow-card-follow-button pressed"
:disabled="inProgress"
@click="unfollowUser"
>
<template v-if="inProgress"> <template v-if="inProgress">
{{ $t('user_card.follow_progress') }} {{ $t('user_card.follow_progress') }}
</template> </template>

View file

@ -1,8 +1,18 @@
<template> <template>
<basic-user-card :user="user"> <basic-user-card :user="user">
<div class="follow-request-card-content-container"> <div class="follow-request-card-content-container">
<button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button> <button
<button class="btn btn-default" @click="denyUser">{{ $t('user_card.deny') }}</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> </div>
</basic-user-card> </basic-user-card>
</template> </template>

View file

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

View file

@ -1,35 +1,56 @@
<template> <template>
<div class="font-control style-control" :class="{ custom: isCustom }"> <div
<label :for="preset === 'custom' ? name : name + '-font-switcher'" class="label"> class="font-control style-control"
{{label}} :class="{ custom: isCustom }"
>
<label
:for="preset === 'custom' ? name : name + '-font-switcher'"
class="label"
>
{{ label }}
</label> </label>
<input <input
v-if="typeof fallback !== 'undefined'" v-if="typeof fallback !== 'undefined'"
:id="name + '-o'"
class="opt exlcude-disabled" class="opt exlcude-disabled"
type="checkbox" type="checkbox"
:id="name + '-o'"
:checked="present" :checked="present"
@input="$emit('input', typeof value === 'undefined' ? fallback : undefined)"> @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"> <label
<select v-if="typeof fallback !== 'undefined'"
class="opt-l"
:for="name + '-o'"
/>
<label
:for="name + '-font-switcher'"
class="select"
:disabled="!present" :disabled="!present"
>
<select
:id="name + '-font-switcher'"
v-model="preset" v-model="preset"
:disabled="!present"
class="font-switcher" class="font-switcher"
:id="name + '-font-switcher'"> >
<option v-for="option in availableOptions" :value="option"> <option
v-for="option in availableOptions"
:key="option"
:value="option"
>
{{ option === 'custom' ? $t('settings.style.fonts.custom') : option }} {{ option === 'custom' ? $t('settings.style.fonts.custom') : option }}
</option> </option>
</select> </select>
<i class="icon-down-open"/> <i class="icon-down-open" />
</label> </label>
<input <input
v-if="isCustom" v-if="isCustom"
:id="name"
v-model="family"
class="custom-font" class="custom-font"
type="text" type="text"
:id="name" >
v-model="family"> </div>
</div>
</template> </template>
<script src="./font_control.js" ></script> <script src="./font_control.js" ></script>

View file

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

View file

@ -1,13 +1,22 @@
<template> <template>
<div ref="galleryContainer" style="width: 100%;"> <div
<div class="gallery-row" v-for="row in rows" :style="rowHeight(row.length)" :class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }"> ref="galleryContainer"
style="width: 100%;"
>
<div
v-for="(row, index) in rows"
:key="index"
class="gallery-row"
:style="rowHeight(row.length)"
:class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }"
>
<attachment <attachment
v-for="attachment in row" v-for="attachment in row"
:setMedia="setMedia" :key="attachment.id"
:set-media="setMedia"
:nsfw="nsfw" :nsfw="nsfw"
:attachment="attachment" :attachment="attachment"
:allowPlay="false" :allow-play="false"
:key="attachment.id"
/> />
</div> </div>
</div> </div>
@ -28,7 +37,9 @@
flex-grow: 1; flex-grow: 1;
margin-top: 0.5em; margin-top: 0.5em;
.attachments, .attachment { // FIXME: specificity problem with this and .attachments.attachment
// we shouldn't have the need for .image here
.attachment.image {
margin: 0 0.5em 0 0; margin: 0 0.5em 0 0;
flex-grow: 1; flex-grow: 1;
height: 100%; height: 100%;
@ -50,13 +61,17 @@
} }
&.contain-fit { &.contain-fit {
img, video { img,
video,
canvas {
object-fit: contain; object-fit: contain;
} }
} }
&.cover-fit { &.cover-fit {
img, video { img,
video,
canvas {
object-fit: cover; object-fit: cover;
} }
} }

View file

@ -2,20 +2,57 @@
<div class="image-cropper"> <div class="image-cropper">
<div v-if="dataUrl"> <div v-if="dataUrl">
<div class="image-cropper-image-container"> <div class="image-cropper-image-container">
<img ref="img" :src="dataUrl" alt="" @load.stop="createCropper" /> <img
ref="img"
:src="dataUrl"
alt=""
@load.stop="createCropper"
>
</div> </div>
<div class="image-cropper-buttons-wrapper"> <div class="image-cropper-buttons-wrapper">
<button class="btn" type="button" :disabled="submitting" @click="submit()" v-text="saveText"></button> <button
<button class="btn" type="button" :disabled="submitting" @click="destroy" v-text="cancelText"></button> class="btn"
<button class="btn" type="button" :disabled="submitting" @click="submit(false)" v-text="saveWithoutCroppingText"></button> type="button"
<i class="icon-spin4 animate-spin" v-if="submitting"></i> :disabled="submitting"
@click="submit()"
v-text="saveText"
/>
<button
class="btn"
type="button"
:disabled="submitting"
@click="destroy"
v-text="cancelText"
/>
<button
class="btn"
type="button"
:disabled="submitting"
@click="submit(false)"
v-text="saveWithoutCroppingText"
/>
<i
v-if="submitting"
class="icon-spin4 animate-spin"
/>
</div> </div>
<div class="alert error" v-if="submitError"> <div
{{submitErrorMsg}} v-if="submitError"
<i class="button-icon icon-cancel" @click="clearError"></i> class="alert error"
>
{{ submitErrorMsg }}
<i
class="button-icon icon-cancel"
@click="clearError"
/>
</div> </div>
</div> </div>
<input ref="input" type="file" class="image-cropper-img-input" :accept="mimes"> <input
ref="input"
type="file"
class="image-cropper-img-input"
:accept="mimes"
>
</div> </div>
</template> </template>

View file

@ -1,17 +1,36 @@
<template> <template>
<div class="importer"> <div class="importer">
<form> <form>
<input type="file" ref="input" v-on:change="change" /> <input
ref="input"
type="file"
@change="change"
>
</form> </form>
<i class="icon-spin4 animate-spin importer-uploading" v-if="submitting"></i> <i
<button class="btn btn-default" v-else @click="submit">{{submitButtonLabel}}</button> v-if="submitting"
class="icon-spin4 animate-spin importer-uploading"
/>
<button
v-else
class="btn btn-default"
@click="submit"
>
{{ submitButtonLabel }}
</button>
<div v-if="success"> <div v-if="success">
<i class="icon-cross" @click="dismiss"></i> <i
<p>{{successMessage}}</p> class="icon-cross"
@click="dismiss"
/>
<p>{{ successMessage }}</p>
</div> </div>
<div v-else-if="error"> <div v-else-if="error">
<i class="icon-cross" @click="dismiss"></i> <i
<p>{{errorMessage}}</p> class="icon-cross"
@click="dismiss"
/>
<p>{{ errorMessage }}</p>
</div> </div>
</div> </div>
</template> </template>

View file

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

View file

@ -1,15 +1,13 @@
<template> <template>
<div v-if="show" class="instance-specific-panel"> <div class="instance-specific-panel">
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-body"> <div class="panel-body">
<div v-html="instanceSpecificPanelContent"> <!-- eslint-disable vue/no-v-html -->
</div> <div v-html="instanceSpecificPanelContent" />
<!-- eslint-enable vue/no-v-html -->
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script src="./instance_specific_panel.js" ></script> <script src="./instance_specific_panel.js" ></script>
<style lang="scss">
</style>

View file

@ -13,8 +13,8 @@ const Interactions = {
} }
}, },
methods: { methods: {
onModeSwitch (index, dataset) { onModeSwitch (key) {
this.filterMode = tabModeDict[dataset.filter] this.filterMode = tabModeDict[key]
} }
}, },
components: { components: {

View file

@ -7,17 +7,26 @@
</div> </div>
<tab-switcher <tab-switcher
ref="tabSwitcher" ref="tabSwitcher"
:onSwitch="onModeSwitch" :on-switch="onModeSwitch"
> >
<span data-tab-dummy data-filter="mentions" :label="$t('nav.mentions')"/> <span
<span data-tab-dummy data-filter="likes+repeats" :label="$t('interactions.favs_repeats')"/> key="mentions"
<span data-tab-dummy data-filter="follows" :label="$t('interactions.follows')"/> :label="$t('nav.mentions')"
/>
<span
key="likes+repeats"
:label="$t('interactions.favs_repeats')"
/>
<span
key="follows"
:label="$t('interactions.follows')"
/>
</tab-switcher> </tab-switcher>
<Notifications <Notifications
ref="notifications" ref="notifications"
:noHeading="true" :no-heading="true"
:minimalMode="true" :minimal-mode="true"
:filterMode="filterMode" :filter-mode="filterMode"
/> />
</div> </div>
</template> </template>

View file

@ -3,23 +3,33 @@
<label for="interface-language-switcher"> <label for="interface-language-switcher">
{{ $t('settings.interfaceLanguage') }} {{ $t('settings.interfaceLanguage') }}
</label> </label>
<label for="interface-language-switcher" class='select'> <label
<select id="interface-language-switcher" v-model="language"> for="interface-language-switcher"
<option v-for="(langCode, i) in languageCodes" :value="langCode"> class="select"
>
<select
id="interface-language-switcher"
v-model="language"
>
<option
v-for="(langCode, i) in languageCodes"
:key="langCode"
:value="langCode"
>
{{ languageNames[i] }} {{ languageNames[i] }}
</option> </option>
</select> </select>
<i class="icon-down-open"/> <i class="icon-down-open" />
</label> </label>
</div> </div>
</template> </template>
<script> <script>
import languagesObject from '../../i18n/messages' import languagesObject from '../../i18n/messages'
import ISO6391 from 'iso-639-1' import ISO6391 from 'iso-639-1'
import _ from 'lodash' import _ from 'lodash'
export default { export default {
computed: { computed: {
languageCodes () { languageCodes () {
return Object.keys(languagesObject) return Object.keys(languagesObject)
@ -48,5 +58,5 @@
return specialLanguageNames[code] || ISO6391.getName(code) return specialLanguageNames[code] || ISO6391.getName(code)
} }
} }
} }
</script> </script>

View file

@ -5,6 +5,11 @@ const LinkPreview = {
'size', 'size',
'nsfw' 'nsfw'
], ],
data () {
return {
imageLoaded: false
}
},
computed: { computed: {
useImage () { useImage () {
// Currently BE shoudn't give cards if tagged NSFW, this is a bit paranoid // Currently BE shoudn't give cards if tagged NSFW, this is a bit paranoid
@ -15,6 +20,15 @@ const LinkPreview = {
useDescription () { useDescription () {
return this.card.description && /\S/.test(this.card.description) return this.card.description && /\S/.test(this.card.description)
} }
},
created () {
if (this.useImage) {
const newImg = new Image()
newImg.onload = () => {
this.imageLoaded = true
}
newImg.src = this.card.image
}
} }
} }

View file

@ -1,13 +1,25 @@
<template> <template>
<div> <div>
<a class="link-preview-card" :href="card.url" target="_blank" rel="noopener"> <a
<div class="card-image" :class="{ 'small-image': size === 'small' }" v-if="useImage"> class="link-preview-card"
<img :src="card.image"></img> :href="card.url"
target="_blank"
rel="noopener"
>
<div
v-if="useImage && imageLoaded"
class="card-image"
:class="{ 'small-image': size === 'small' }"
>
<img :src="card.image">
</div> </div>
<div class="card-content"> <div class="card-content">
<span class="card-host faint">{{ card.provider_name }}</span> <span class="card-host faint">{{ card.provider_name }}</span>
<h4 class="card-title">{{ card.title }}</h4> <h4 class="card-title">{{ card.title }}</h4>
<p class="card-description" v-if="useDescription">{{ card.description }}</p> <p
v-if="useDescription"
class="card-description"
>{{ card.description }}</p>
</div> </div>
</a> </a>
</div> </div>

View file

@ -1,9 +1,19 @@
<template> <template>
<div class="list"> <div class="list">
<div v-for="item in items" class="list-item" :key="getKey(item)"> <div
<slot name="item" :item="item" /> v-for="item in items"
:key="getKey(item)"
class="list-item"
>
<slot
name="item"
:item="item"
/>
</div> </div>
<div class="list-empty-content faint" v-if="items.length === 0 && !!$slots.empty"> <div
v-if="items.length === 0 && !!$slots.empty"
class="list-empty-content faint"
>
<slot name="empty" /> <slot name="empty" />
</div> </div>
</div> </div>

View file

@ -26,9 +26,10 @@ const LoginForm = {
this.isTokenAuth ? this.submitToken() : this.submitPassword() this.isTokenAuth ? this.submitToken() : this.submitPassword()
}, },
submitToken () { submitToken () {
const { clientId } = this.oauth const { clientId, clientSecret } = this.oauth
const data = { const data = {
clientId, clientId,
clientSecret,
instance: this.instance.server, instance: this.instance.server,
commit: this.$store.commit commit: this.$store.commit
} }
@ -57,7 +58,7 @@ const LoginForm = {
).then((result) => { ).then((result) => {
if (result.error) { if (result.error) {
if (result.error === 'mfa_required') { if (result.error === 'mfa_required') {
this.requireMFA({app: app, settings: result}) this.requireMFA({ app: app, settings: result })
} else { } else {
this.error = result.error this.error = result.error
this.focusOnPasswordInput() this.focusOnPasswordInput()
@ -65,7 +66,7 @@ const LoginForm = {
return return
} }
this.login(result).then(() => { this.login(result).then(() => {
this.$router.push({name: 'friends'}) this.$router.push({ name: 'friends' })
}) })
}) })
}) })

View file

@ -1,53 +1,83 @@
<template> <template>
<div class="login panel panel-default"> <div class="login panel panel-default">
<!-- Default panel contents --> <!-- Default panel contents -->
<div class="panel-heading">{{$t('login.login')}}</div> <div class="panel-heading">
{{ $t('login.login') }}
</div>
<div class="panel-body"> <div class="panel-body">
<form class='login-form' @submit.prevent='submit'> <form
class="login-form"
@submit.prevent="submit"
>
<template v-if="isPasswordAuth"> <template v-if="isPasswordAuth">
<div class='form-group'> <div class="form-group">
<label for='username'>{{$t('login.username')}}</label> <label for="username">{{ $t('login.username') }}</label>
<input :disabled="loggingIn" v-model='user.username' <input
class='form-control' id='username' id="username"
:placeholder="$t('login.placeholder')"> v-model="user.username"
:disabled="loggingIn"
class="form-control"
:placeholder="$t('login.placeholder')"
>
</div> </div>
<div class='form-group'> <div class="form-group">
<label for='password'>{{$t('login.password')}}</label> <label for="password">{{ $t('login.password') }}</label>
<input :disabled="loggingIn" v-model='user.password' <input
ref='passwordInput' class='form-control' id='password' type='password'> id="password"
ref="passwordInput"
v-model="user.password"
:disabled="loggingIn"
class="form-control"
type="password"
>
</div> </div>
</template> </template>
<div class="form-group" v-if="isTokenAuth"> <div
<p>{{$t('login.description')}}</p> v-if="isTokenAuth"
class="form-group"
>
<p>{{ $t('login.description') }}</p>
</div> </div>
<div class='form-group'> <div class="form-group">
<div class='login-bottom'> <div class="login-bottom">
<div> <div>
<router-link :to="{name: 'registration'}" <router-link
v-if='registrationOpen' v-if="registrationOpen"
class='register'> :to="{name: 'registration'}"
{{$t('login.register')}} class="register"
>
{{ $t('login.register') }}
</router-link> </router-link>
</div> </div>
<button :disabled="loggingIn" type='submit' class='btn btn-default'> <button
{{$t('login.login')}} :disabled="loggingIn"
type="submit"
class="btn btn-default"
>
{{ $t('login.login') }}
</button> </button>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
<div v-if="error" class='form-group'> <div
<div class='alert error'> v-if="error"
{{error}} class="form-group"
<i class="button-icon icon-cancel" @click="clearError"></i> >
<div class="alert error">
{{ error }}
<i
class="button-icon icon-cancel"
@click="clearError"
/>
</div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script src="./login_form.js" ></script> <script src="./login_form.js" ></script>

View file

@ -1,25 +1,33 @@
<template> <template>
<div class="modal-view media-modal-view" v-if="showing" @click.prevent="hide"> <div
<img class="modal-image" v-if="type === 'image'" :src="currentMedia.url"></img> v-if="showing"
<VideoAttachment class="modal-view media-modal-view"
@click.prevent="hide"
>
<img
v-if="type === 'image'"
class="modal-image" class="modal-image"
:src="currentMedia.url"
>
<VideoAttachment
v-if="type === 'video'" v-if="type === 'video'"
class="modal-image"
:attachment="currentMedia" :attachment="currentMedia"
:controls="true" :controls="true"
@click.stop.native=""> @click.stop.native=""
</VideoAttachment> />
<button <button
v-if="canNavigate"
:title="$t('media_modal.previous')" :title="$t('media_modal.previous')"
class="modal-view-button-arrow modal-view-button-arrow--prev" class="modal-view-button-arrow modal-view-button-arrow--prev"
v-if="canNavigate"
@click.stop.prevent="goPrev" @click.stop.prevent="goPrev"
> >
<i class="icon-left-open arrow-icon" /> <i class="icon-left-open arrow-icon" />
</button> </button>
<button <button
v-if="canNavigate"
:title="$t('media_modal.next')" :title="$t('media_modal.next')"
class="modal-view-button-arrow modal-view-button-arrow--next" class="modal-view-button-arrow modal-view-button-arrow--next"
v-if="canNavigate"
@click.stop.prevent="goNext" @click.stop.prevent="goNext"
> >
<i class="icon-right-open arrow-icon" /> <i class="icon-right-open arrow-icon" />

View file

@ -16,7 +16,7 @@ const mediaUpload = {
if (file.size > store.state.instance.uploadlimit) { if (file.size > store.state.instance.uploadlimit) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size) const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(store.state.instance.uploadlimit) 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}) self.$emit('upload-failed', 'file_too_big', { filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit })
return return
} }
const formData = new FormData() const formData = new FormData()
@ -54,7 +54,7 @@ const mediaUpload = {
this.uploadReady = true this.uploadReady = true
}) })
}, },
change ({target}) { change ({ target }) {
for (var i = 0; i < target.files.length; i++) { for (var i = 0; i < target.files.length; i++) {
let file = target.files[i] let file = target.files[i]
this.uploadFile(file) this.uploadFile(file)

View file

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

View file

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

View file

@ -33,7 +33,7 @@ export default {
} }
this.login(result).then(() => { this.login(result).then(() => {
this.$router.push({name: 'friends'}) this.$router.push({ name: 'friends' })
}) })
}) })
} }

View file

@ -1,42 +1,65 @@
<template> <template>
<div class="login panel panel-default"> <div class="login panel panel-default">
<!-- Default panel contents --> <!-- Default panel contents -->
<div class="panel-heading">{{$t('login.heading.recovery')}}</div> <div class="panel-heading">
{{ $t('login.heading.recovery') }}
</div>
<div class="panel-body"> <div class="panel-body">
<form class='login-form' @submit.prevent='submit'> <form
<div class='form-group'> class="login-form"
<label for='code'>{{$t('login.recovery_code')}}</label> @submit.prevent="submit"
<input v-model='code' class='form-control' id='code'> >
<div class="form-group">
<label for="code">{{ $t('login.recovery_code') }}</label>
<input
id="code"
v-model="code"
class="form-control"
>
</div> </div>
<div class='form-group'> <div class="form-group">
<div class='login-bottom'> <div class="login-bottom">
<div> <div>
<a href="#" @click.prevent="requireTOTP"> <a
{{$t('login.enter_two_factor_code')}} href="#"
@click.prevent="requireTOTP"
>
{{ $t('login.enter_two_factor_code') }}
</a> </a>
<br /> <br>
<a href="#" @click.prevent="abortMFA"> <a
{{$t('general.cancel')}} href="#"
@click.prevent="abortMFA"
>
{{ $t('general.cancel') }}
</a> </a>
</div> </div>
<button type='submit' class='btn btn-default'> <button
{{$t('general.verify')}} type="submit"
class="btn btn-default"
>
{{ $t('general.verify') }}
</button> </button>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
<div v-if="error" class='form-group'> <div
<div class='alert error'> v-if="error"
{{error}} class="form-group"
<i class="button-icon icon-cancel" @click="clearError"></i> >
<div class="alert error">
{{ error }}
<i
class="button-icon icon-cancel"
@click="clearError"
/>
</div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script src="./recovery_form.js" ></script> <script src="./recovery_form.js" ></script>

View file

@ -32,7 +32,7 @@ export default {
} }
this.login(result).then(() => { this.login(result).then(() => {
this.$router.push({name: 'friends'}) this.$router.push({ name: 'friends' })
}) })
}) })
} }

View file

@ -1,45 +1,67 @@
<template> <template>
<div class="login panel panel-default"> <div class="login panel panel-default">
<!-- Default panel contents --> <!-- Default panel contents -->
<div class="panel-heading"> <div class="panel-heading">
{{$t('login.heading.totp')}} {{ $t('login.heading.totp') }}
</div> </div>
<div class="panel-body"> <div class="panel-body">
<form class='login-form' @submit.prevent='submit'> <form
<div class='form-group'> class="login-form"
<label for='code'> @submit.prevent="submit"
{{$t('login.authentication_code')}} >
<div class="form-group">
<label for="code">
{{ $t('login.authentication_code') }}
</label> </label>
<input v-model='code' class='form-control' id='code'> <input
id="code"
v-model="code"
class="form-control"
>
</div> </div>
<div class='form-group'> <div class="form-group">
<div class='login-bottom'> <div class="login-bottom">
<div> <div>
<a href="#" @click.prevent="requireRecovery"> <a
{{$t('login.enter_recovery_code')}} href="#"
@click.prevent="requireRecovery"
>
{{ $t('login.enter_recovery_code') }}
</a> </a>
<br /> <br>
<a href="#" @click.prevent="abortMFA"> <a
{{$t('general.cancel')}} href="#"
@click.prevent="abortMFA"
>
{{ $t('general.cancel') }}
</a> </a>
</div> </div>
<button type='submit' class='btn btn-default'> <button
{{$t('general.verify')}} type="submit"
class="btn btn-default"
>
{{ $t('general.verify') }}
</button> </button>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
<div v-if="error" class='form-group'> <div
<div class='alert error'> v-if="error"
{{error}} class="form-group"
<i class="button-icon icon-cancel" @click="clearError"></i> >
<div class="alert error">
{{ error }}
<i
class="button-icon icon-cancel"
@click="clearError"
/>
</div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script src="./totp_form.js"></script> <script src="./totp_form.js"></script>

View file

@ -1,14 +1,12 @@
import SideDrawer from '../side_drawer/side_drawer.vue' import SideDrawer from '../side_drawer/side_drawer.vue'
import Notifications from '../notifications/notifications.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 { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service' import GestureService from '../../services/gesture_service/gesture_service'
const MobileNav = { const MobileNav = {
components: { components: {
SideDrawer, SideDrawer,
Notifications, Notifications
MobilePostStatusModal
}, },
data: () => ({ data: () => ({
notificationsCloseGesture: undefined, notificationsCloseGesture: undefined,

View file

@ -1,39 +1,75 @@
<template> <template>
<div> <div>
<nav class='nav-bar container' id="nav"> <nav
<div class='mobile-inner-nav' @click="scrollToTop()"> id="nav"
<div class='item'> class="nav-bar container"
<a href="#" class="mobile-nav-button" @click.stop.prevent="toggleMobileSidebar()"> >
<i class="button-icon icon-menu"></i> <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" />
</a> </a>
<router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link> <router-link
class="site-name"
:to="{ name: 'root' }"
active-class="home"
>
{{ sitename }}
</router-link>
</div> </div>
<div class='item right'> <div class="item right">
<a class="mobile-nav-button" v-if="currentUser" href="#" @click.stop.prevent="openMobileNotifications()"> <a
<i class="button-icon icon-bell-alt"></i> v-if="currentUser"
<div class="alert-dot" v-if="unseenNotificationsCount"></div> class="mobile-nav-button"
href="#"
@click.stop.prevent="openMobileNotifications()"
>
<i class="button-icon icon-bell-alt" />
<div
v-if="unseenNotificationsCount"
class="alert-dot"
/>
</a> </a>
</div> </div>
</div> </div>
</nav> </nav>
<div v-if="currentUser" <div
v-if="currentUser"
class="mobile-notifications-drawer" class="mobile-notifications-drawer"
:class="{ 'closed': !notificationsOpen }" :class="{ 'closed': !notificationsOpen }"
@touchstart.stop="notificationsTouchStart" @touchstart.stop="notificationsTouchStart"
@touchmove.stop="notificationsTouchMove" @touchmove.stop="notificationsTouchMove"
> >
<div class="mobile-notifications-header"> <div class="mobile-notifications-header">
<span class="title">{{$t('notifications.notifications')}}</span> <span class="title">{{ $t('notifications.notifications') }}</span>
<a class="mobile-nav-button" @click.stop.prevent="closeMobileNotifications()"> <a
<i class="button-icon icon-cancel"/> class="mobile-nav-button"
@click.stop.prevent="closeMobileNotifications()"
>
<i class="button-icon icon-cancel" />
</a> </a>
</div> </div>
<div class="mobile-notifications" @scroll="onScroll"> <div
<Notifications ref="notifications" :noHeading="true"/> class="mobile-notifications"
@scroll="onScroll"
>
<Notifications
ref="notifications"
:no-heading="true"
/>
</div> </div>
</div> </div>
<SideDrawer ref="sideDrawer" :logout="logout"/> <SideDrawer
<MobilePostStatusModal /> ref="sideDrawer"
:logout="logout"
/>
</div> </div>
</template> </template>

View file

@ -96,12 +96,12 @@ const MobilePostStatusModal = {
this.hidden = false this.hidden = false
} }
this.oldScrollPos = window.scrollY this.oldScrollPos = window.scrollY
}, 100, {leading: true, trailing: false}), }, 100, { leading: true, trailing: false }),
handleScrollEnd: debounce(function () { handleScrollEnd: debounce(function () {
this.hidden = false this.hidden = false
this.oldScrollPos = window.scrollY this.oldScrollPos = window.scrollY
}, 100, {leading: false, trailing: true}) }, 100, { leading: false, trailing: true })
} }
} }

View file

@ -1,13 +1,21 @@
<template> <template>
<div v-if="currentUser"> <div v-if="currentUser">
<div <div
class="post-form-modal-view modal-view"
v-show="postFormOpen" v-show="postFormOpen"
class="post-form-modal-view modal-view"
@click="closePostForm" @click="closePostForm"
> >
<div class="post-form-modal-panel panel" @click.stop=""> <div
<div class="panel-heading">{{$t('post_status.new_status')}}</div> class="post-form-modal-panel panel"
<PostStatusForm class="panel-body" @posted="closePostForm" /> @click.stop=""
>
<div class="panel-heading">
{{ $t('post_status.new_status') }}
</div>
<PostStatusForm
class="panel-body"
@posted="closePostForm"
/>
</div> </div>
</div> </div>
<button <button
@ -17,7 +25,7 @@
> >
<i class="icon-edit" /> <i class="icon-edit" />
</button> </button>
</div> </div>
</template> </template>
<script src="./mobile_post_status_modal.js"></script> <script src="./mobile_post_status_modal.js"></script>
@ -26,14 +34,19 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.post-form-modal-view { .post-form-modal-view {
max-height: 100%; align-items: flex-start;
display: block;
} }
.post-form-modal-panel { .post-form-modal-panel {
flex-shrink: 0; flex-shrink: 0;
margin: 25% 0 4em 0; margin-top: 25%;
margin-bottom: 2em;
width: 100%; width: 100%;
max-width: 700px;
@media (orientation: landscape) {
margin-top: 8%;
}
} }
.new-status-button { .new-status-button {

View file

@ -1,5 +1,4 @@
import DialogModal from '../dialog_modal/dialog_modal.vue' 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 FORCE_NSFW = 'mrf_tag:media-force-nsfw'
const STRIP_MEDIA = 'mrf_tag:media-strip' const STRIP_MEDIA = 'mrf_tag:media-strip'
@ -29,8 +28,7 @@ const ModerationTools = {
} }
}, },
components: { components: {
DialogModal, DialogModal
Popper
}, },
computed: { computed: {
tagsSet () { tagsSet () {
@ -41,9 +39,6 @@ const ModerationTools = {
} }
}, },
methods: { methods: {
toggleMenu () {
this.showDropDown = !this.showDropDown
},
hasTag (tagName) { hasTag (tagName) {
return this.tagsSet.has(tagName) return this.tagsSet.has(tagName)
}, },
@ -52,12 +47,12 @@ const ModerationTools = {
if (this.tagsSet.has(tag)) { if (this.tagsSet.has(tag)) {
store.state.api.backendInteractor.untagUser(this.user, tag).then(response => { store.state.api.backendInteractor.untagUser(this.user, tag).then(response => {
if (!response.ok) { return } if (!response.ok) { return }
store.commit('untagUser', {user: this.user, tag}) store.commit('untagUser', { user: this.user, tag })
}) })
} else { } else {
store.state.api.backendInteractor.tagUser(this.user, tag).then(response => { store.state.api.backendInteractor.tagUser(this.user, tag).then(response => {
if (!response.ok) { return } if (!response.ok) { return }
store.commit('tagUser', {user: this.user, tag}) store.commit('tagUser', { user: this.user, tag })
}) })
} }
}, },
@ -66,12 +61,12 @@ const ModerationTools = {
if (this.user.rights[right]) { if (this.user.rights[right]) {
store.state.api.backendInteractor.deleteRight(this.user, right).then(response => { store.state.api.backendInteractor.deleteRight(this.user, right).then(response => {
if (!response.ok) { return } if (!response.ok) { return }
store.commit('updateRight', {user: this.user, right: right, value: false}) store.commit('updateRight', { user: this.user, right: right, value: false })
}) })
} else { } else {
store.state.api.backendInteractor.addRight(this.user, right).then(response => { store.state.api.backendInteractor.addRight(this.user, right).then(response => {
if (!response.ok) { return } if (!response.ok) { return }
store.commit('updateRight', {user: this.user, right: right, value: true}) store.commit('updateRight', { user: this.user, right: right, value: true })
}) })
} }
}, },
@ -80,7 +75,7 @@ const ModerationTools = {
const status = !!this.user.deactivated const status = !!this.user.deactivated
store.state.api.backendInteractor.setActivationStatus(this.user, status).then(response => { store.state.api.backendInteractor.setActivationStatus(this.user, status).then(response => {
if (!response.ok) { return } if (!response.ok) { return }
store.commit('updateActivationStatus', {user: this.user, status: status}) store.commit('updateActivationStatus', { user: this.user, status: status })
}) })
}, },
deleteUserDialog (show) { deleteUserDialog (show) {
@ -89,7 +84,7 @@ const ModerationTools = {
deleteUser () { deleteUser () {
const store = this.$store const store = this.$store
const user = this.user const user = this.user
const {id, name} = user const { id, name } = user
store.state.api.backendInteractor.deleteUser(user) store.state.api.backendInteractor.deleteUser(user)
.then(e => { .then(e => {
this.$store.dispatch('markStatusesAsDeleted', status => user.id === status.user.id) this.$store.dispatch('markStatusesAsDeleted', status => user.id === status.user.id)

View file

@ -1,85 +1,161 @@
<template> <template>
<div class='block' style='position: relative'> <div>
<Popper <v-popover
trigger="click" trigger="click"
@hide='showDropDown = false' class="moderation-tools-popover"
append-to-body :container="false"
:options="{ placement="bottom-end"
placement: 'bottom-end', :offset="5"
modifiers: { @show="showDropDown = true"
arrow: { enabled: true }, @hide="showDropDown = false"
offset: { offset: '0, 5px' }, >
} <div slot="popover">
}">
<div class="popper-wrapper">
<div class="dropdown-menu"> <div class="dropdown-menu">
<span v-if='user.is_local'> <span v-if="user.is_local">
<button class="dropdown-item" @click='toggleRight("admin")'> <button
class="dropdown-item"
@click="toggleRight(&quot;admin&quot;)"
>
{{ $t(!!user.rights.admin ? 'user_card.admin_menu.revoke_admin' : 'user_card.admin_menu.grant_admin') }} {{ $t(!!user.rights.admin ? 'user_card.admin_menu.revoke_admin' : 'user_card.admin_menu.grant_admin') }}
</button> </button>
<button class="dropdown-item" @click='toggleRight("moderator")'> <button
class="dropdown-item"
@click="toggleRight(&quot;moderator&quot;)"
>
{{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }} {{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }}
</button> </button>
<div role="separator" class="dropdown-divider"></div> <div
role="separator"
class="dropdown-divider"
/>
</span> </span>
<button class="dropdown-item" @click='toggleActivationStatus()'> <button
class="dropdown-item"
@click="toggleActivationStatus()"
>
{{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }} {{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }}
</button> </button>
<button class="dropdown-item" @click='deleteUserDialog(true)'> <button
class="dropdown-item"
@click="deleteUserDialog(true)"
>
{{ $t('user_card.admin_menu.delete_account') }} {{ $t('user_card.admin_menu.delete_account') }}
</button> </button>
<div role="separator" class="dropdown-divider" v-if='hasTagPolicy'></div> <div
<span v-if='hasTagPolicy'> v-if="hasTagPolicy"
<button class="dropdown-item" @click='toggleTag(tags.FORCE_NSFW)'> role="separator"
class="dropdown-divider"
/>
<span v-if="hasTagPolicy">
<button
class="dropdown-item"
@click="toggleTag(tags.FORCE_NSFW)"
>
{{ $t('user_card.admin_menu.force_nsfw') }} {{ $t('user_card.admin_menu.force_nsfw') }}
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }"></span> <span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }"
/>
</button> </button>
<button class="dropdown-item" @click='toggleTag(tags.STRIP_MEDIA)'> <button
class="dropdown-item"
@click="toggleTag(tags.STRIP_MEDIA)"
>
{{ $t('user_card.admin_menu.strip_media') }} {{ $t('user_card.admin_menu.strip_media') }}
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }"></span> <span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }"
/>
</button> </button>
<button class="dropdown-item" @click='toggleTag(tags.FORCE_UNLISTED)'> <button
class="dropdown-item"
@click="toggleTag(tags.FORCE_UNLISTED)"
>
{{ $t('user_card.admin_menu.force_unlisted') }} {{ $t('user_card.admin_menu.force_unlisted') }}
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }"></span> <span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }"
/>
</button> </button>
<button class="dropdown-item" @click='toggleTag(tags.SANDBOX)'> <button
class="dropdown-item"
@click="toggleTag(tags.SANDBOX)"
>
{{ $t('user_card.admin_menu.sandbox') }} {{ $t('user_card.admin_menu.sandbox') }}
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }"></span> <span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }"
/>
</button> </button>
<button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)'> <button
v-if="user.is_local"
class="dropdown-item"
@click="toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)"
>
{{ $t('user_card.admin_menu.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> <span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }"
/>
</button> </button>
<button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)'> <button
v-if="user.is_local"
class="dropdown-item"
@click="toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)"
>
{{ $t('user_card.admin_menu.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> <span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }"
/>
</button> </button>
<button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.QUARANTINE)'> <button
v-if="user.is_local"
class="dropdown-item"
@click="toggleTag(tags.QUARANTINE)"
>
{{ $t('user_card.admin_menu.quarantine') }} {{ $t('user_card.admin_menu.quarantine') }}
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }"></span> <span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }"
/>
</button> </button>
</span> </span>
</div> </div>
</div> </div>
<button slot="reference" v-bind:class="{ pressed: showDropDown }" @click='toggleMenu'> <button
class="btn btn-default btn-block"
:class="{ pressed: showDropDown }"
>
{{ $t('user_card.admin_menu.moderation') }} {{ $t('user_card.admin_menu.moderation') }}
</button> </button>
</Popper> </v-popover>
<portal to="modal"> <portal to="modal">
<DialogModal v-if="showDeleteUserDialog" :onCancel='deleteUserDialog.bind(this, false)'> <DialogModal
<template slot="header">{{ $t('user_card.admin_menu.delete_user') }}</template> v-if="showDeleteUserDialog"
:on-cancel="deleteUserDialog.bind(this, false)"
>
<template slot="header">
{{ $t('user_card.admin_menu.delete_user') }}
</template>
<p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p> <p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p>
<template slot="footer"> <template slot="footer">
<button class="btn btn-default" @click='deleteUserDialog(false)'> <button
class="btn btn-default"
@click="deleteUserDialog(false)"
>
{{ $t('general.cancel') }} {{ $t('general.cancel') }}
</button> </button>
<button class="btn btn-default danger" @click='deleteUser()'> <button
class="btn btn-default danger"
@click="deleteUser()"
>
{{ $t('user_card.admin_menu.delete_user') }} {{ $t('user_card.admin_menu.delete_user') }}
</button> </button>
</template> </template>
</DialogModal> </DialogModal>
</portal> </portal>
</div> </div>
</template> </template>
<script src="./moderation_tools.js"></script> <script src="./moderation_tools.js"></script>
@ -107,4 +183,11 @@
} }
} }
.moderation-tools-popover {
height: 100%;
.trigger {
display: flex !important;
height: 100%;
}
}
</style> </style>

View file

@ -1,7 +1,12 @@
<template> <template>
<basic-user-card :user="user"> <basic-user-card :user="user">
<div class="mute-card-content-container"> <div class="mute-card-content-container">
<button class="btn btn-default" @click="unmuteUser" :disabled="progress" v-if="muted"> <button
v-if="muted"
class="btn btn-default"
:disabled="progress"
@click="unmuteUser"
>
<template v-if="progress"> <template v-if="progress">
{{ $t('user_card.unmute_progress') }} {{ $t('user_card.unmute_progress') }}
</template> </template>
@ -9,7 +14,12 @@
{{ $t('user_card.unmute') }} {{ $t('user_card.unmute') }}
</template> </template>
</button> </button>
<button class="btn btn-default" @click="muteUser" :disabled="progress" v-else> <button
v-else
class="btn btn-default"
:disabled="progress"
@click="muteUser"
>
<template v-if="progress"> <template v-if="progress">
{{ $t('user_card.mute_progress') }} {{ $t('user_card.mute_progress') }}
</template> </template>

View file

@ -2,26 +2,29 @@
<div class="nav-panel"> <div class="nav-panel">
<div class="panel panel-default"> <div class="panel panel-default">
<ul> <ul>
<li v-if='currentUser'> <li v-if="currentUser">
<router-link :to="{ name: 'friends' }"> <router-link :to="{ name: 'friends' }">
{{ $t("nav.timeline") }} {{ $t("nav.timeline") }}
</router-link> </router-link>
</li> </li>
<li v-if='currentUser'> <li v-if="currentUser">
<router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }"> <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }">
{{ $t("nav.interactions") }} {{ $t("nav.interactions") }}
</router-link> </router-link>
</li> </li>
<li v-if='currentUser'> <li v-if="currentUser">
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
{{ $t("nav.dms") }} {{ $t("nav.dms") }}
</router-link> </router-link>
</li> </li>
<li v-if='currentUser && currentUser.locked'> <li v-if="currentUser && currentUser.locked">
<router-link :to="{ name: 'friend-requests' }"> <router-link :to="{ name: 'friend-requests' }">
{{ $t("nav.friend_requests")}} {{ $t("nav.friend_requests") }}
<span v-if='followRequestCount > 0' class="badge follow-request-count"> <span
{{followRequestCount}} v-if="followRequestCount > 0"
class="badge follow-request-count"
>
{{ followRequestCount }}
</span> </span>
</router-link> </router-link>
</li> </li>

View file

@ -1,6 +1,7 @@
import Status from '../status/status.vue' import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue' import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -13,7 +14,10 @@ const Notification = {
}, },
props: [ 'notification' ], props: [ 'notification' ],
components: { components: {
Status, UserAvatar, UserCard Status,
UserAvatar,
UserCard,
Timeago
}, },
methods: { methods: {
toggleUserExpanded () { toggleUserExpanded () {

View file

@ -3,49 +3,104 @@
v-if="notification.type === 'mention'" v-if="notification.type === 'mention'"
:compact="true" :compact="true"
:statusoid="notification.status" :statusoid="notification.status"
/>
<div
v-else
class="non-mention"
:class="[userClass, { highlighted: userStyle }]"
:style="[ userStyle ]"
> >
</status> <a
<div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]" v-else> class="avatar-container"
<a class='avatar-container' :href="notification.from_profile.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded"> :href="notification.from_profile.statusnet_profile_url"
<UserAvatar :compact="true" :betterShadow="betterShadow" :user="notification.from_profile"/> @click.stop.prevent.capture="toggleUserExpanded"
>
<UserAvatar
:compact="true"
:better-shadow="betterShadow"
:user="notification.from_profile"
/>
</a> </a>
<div class='notification-right'> <div class="notification-right">
<UserCard :user="getUser(notification)" :rounded="true" :bordered="true" v-if="userExpanded" /> <UserCard
v-if="userExpanded"
:user="getUser(notification)"
:rounded="true"
:bordered="true"
/>
<span class="notification-details"> <span class="notification-details">
<div class="name-and-action"> <div class="name-and-action">
<span class="username" v-if="!!notification.from_profile.name_html" :title="'@'+notification.from_profile.screen_name" v-html="notification.from_profile.name_html"></span> <!-- eslint-disable vue/no-v-html -->
<span class="username" v-else :title="'@'+notification.from_profile.screen_name">{{ notification.from_profile.name }}</span> <span
v-if="!!notification.from_profile.name_html"
class="username"
:title="'@'+notification.from_profile.screen_name"
v-html="notification.from_profile.name_html"
/>
<!-- eslint-enable vue/no-v-html -->
<span
v-else
class="username"
:title="'@'+notification.from_profile.screen_name"
>{{ notification.from_profile.name }}</span>
<span v-if="notification.type === 'like'"> <span v-if="notification.type === 'like'">
<i class="fa icon-star lit"></i> <i class="fa icon-star lit" />
<small>{{$t('notifications.favorited_you')}}</small> <small>{{ $t('notifications.favorited_you') }}</small>
</span> </span>
<span v-if="notification.type === 'repeat'"> <span v-if="notification.type === 'repeat'">
<i class="fa icon-retweet lit" :title="$t('tool_tip.repeat')"></i> <i
<small>{{$t('notifications.repeated_you')}}</small> class="fa icon-retweet lit"
:title="$t('tool_tip.repeat')"
/>
<small>{{ $t('notifications.repeated_you') }}</small>
</span> </span>
<span v-if="notification.type === 'follow'"> <span v-if="notification.type === 'follow'">
<i class="fa icon-user-plus lit"></i> <i class="fa icon-user-plus lit" />
<small>{{$t('notifications.followed_you')}}</small> <small>{{ $t('notifications.followed_you') }}</small>
</span> </span>
</div> </div>
<div class="timeago" v-if="notification.type === 'follow'"> <div
v-if="notification.type === 'follow'"
class="timeago"
>
<span class="faint"> <span class="faint">
<timeago :since="notification.created_at" :auto-update="240"></timeago> <Timeago
:time="notification.created_at"
:auto-update="240"
/>
</span> </span>
</div> </div>
<div class="timeago" v-else> <div
<router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }" class="faint-link"> v-else
<timeago :since="notification.created_at" :auto-update="240"></timeago> class="timeago"
>
<router-link
v-if="notification.status"
:to="{ name: 'conversation', params: { id: notification.status.id } }"
class="faint-link"
>
<Timeago
:time="notification.created_at"
:auto-update="240"
/>
</router-link> </router-link>
</div> </div>
</span> </span>
<div class="follow-text" v-if="notification.type === 'follow'"> <div
v-if="notification.type === 'follow'"
class="follow-text"
>
<router-link :to="userProfileLink(notification.from_profile)"> <router-link :to="userProfileLink(notification.from_profile)">
@{{notification.from_profile.screen_name}} @{{ notification.from_profile.screen_name }}
</router-link> </router-link>
</div> </div>
<template v-else> <template v-else>
<status class="faint" :compact="true" :statusoid="notification.action" :noHeading="true"></status> <status
class="faint"
:compact="true"
:statusoid="notification.action"
:no-heading="true"
/>
</template> </template>
</div> </div>
</div> </div>

View file

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

View file

@ -4,10 +4,11 @@ const oac = {
props: ['code'], props: ['code'],
mounted () { mounted () {
if (this.code) { if (this.code) {
const { clientId } = this.$store.state.oauth const { clientId, clientSecret } = this.$store.state.oauth
oauth.getToken({ oauth.getToken({
clientId, clientId,
clientSecret,
instance: this.$store.state.instance.server, instance: this.$store.state.instance.server,
code: this.code code: this.code
}).then((result) => { }).then((result) => {

View file

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

112
src/components/poll/poll.js Normal file
View file

@ -0,0 +1,112 @@
import Timeago from '../timeago/timeago.vue'
import { forEach, map } from 'lodash'
export default {
name: 'Poll',
props: ['basePoll'],
components: { Timeago },
data () {
return {
loading: false,
choices: []
}
},
created () {
if (!this.$store.state.polls.pollsObject[this.pollId]) {
this.$store.dispatch('mergeOrAddPoll', this.basePoll)
}
this.$store.dispatch('trackPoll', this.pollId)
},
destroyed () {
this.$store.dispatch('untrackPoll', this.pollId)
},
computed: {
pollId () {
return this.basePoll.id
},
poll () {
const storePoll = this.$store.state.polls.pollsObject[this.pollId]
return storePoll || {}
},
options () {
return (this.poll && this.poll.options) || []
},
expiresAt () {
return (this.poll && this.poll.expires_at) || 0
},
expired () {
return (this.poll && this.poll.expired) || false
},
loggedIn () {
return this.$store.state.users.currentUser
},
showResults () {
return this.poll.voted || this.expired || !this.loggedIn
},
totalVotesCount () {
return this.poll.votes_count
},
containerClass () {
return {
loading: this.loading
}
},
choiceIndices () {
// Convert array of booleans into an array of indices of the
// items that were 'true', so [true, false, false, true] becomes
// [0, 3].
return this.choices
.map((entry, index) => entry && index)
.filter(value => typeof value === 'number')
},
isDisabled () {
const noChoice = this.choiceIndices.length === 0
return this.loading || noChoice
}
},
methods: {
percentageForOption (count) {
return this.totalVotesCount === 0 ? 0 : Math.round(count / this.totalVotesCount * 100)
},
resultTitle (option) {
return `${option.votes_count}/${this.totalVotesCount} ${this.$t('polls.votes')}`
},
fetchPoll () {
this.$store.dispatch('refreshPoll', { id: this.statusId, pollId: this.poll.id })
},
activateOption (index) {
// forgive me father: doing checking the radio/checkboxes
// in code because of customized input elements need either
// a) an extra element for the actual graphic, or b) use a
// pseudo element for the label. We use b) which mandates
// using "for" and "id" matching which isn't nice when the
// same poll appears multiple times on the site (notifs and
// timeline for example). With code we can make sure it just
// works without altering the pseudo element implementation.
const allElements = this.$el.querySelectorAll('input')
const clickedElement = this.$el.querySelector(`input[value="${index}"]`)
if (this.poll.multiple) {
// Checkboxes, toggle only the clicked one
clickedElement.checked = !clickedElement.checked
} else {
// Radio button, uncheck everything and check the clicked one
forEach(allElements, element => { element.checked = false })
clickedElement.checked = true
}
this.choices = map(allElements, e => e.checked)
},
optionId (index) {
return `poll${this.poll.id}-${index}`
},
vote () {
if (this.choiceIndices.length === 0) return
this.loading = true
this.$store.dispatch(
'votePoll',
{ id: this.statusId, pollId: this.poll.id, choices: this.choiceIndices }
).then(poll => {
this.loading = false
})
}
}
}

View file

@ -0,0 +1,134 @@
<template>
<div
class="poll"
:class="containerClass"
>
<div
v-for="(option, index) in options"
:key="index"
class="poll-option"
>
<div
v-if="showResults"
:title="resultTitle(option)"
class="option-result"
>
<div class="option-result-label">
<span class="result-percentage">
{{ percentageForOption(option.votes_count) }}%
</span>
<span>{{ option.title }}</span>
</div>
<div
class="result-fill"
:style="{ 'width': `${percentageForOption(option.votes_count)}%` }"
/>
</div>
<div
v-else
@click="activateOption(index)"
>
<input
v-if="poll.multiple"
type="checkbox"
:disabled="loading"
:value="index"
>
<input
v-else
type="radio"
:disabled="loading"
:value="index"
>
<label class="option-vote">
<div>{{ option.title }}</div>
</label>
</div>
</div>
<div class="footer faint">
<button
v-if="!showResults"
class="btn btn-default poll-vote-button"
type="button"
:disabled="isDisabled"
@click="vote"
>
{{ $t('polls.vote') }}
</button>
<div class="total">
{{ totalVotesCount }} {{ $t("polls.votes") }}&nbsp;·&nbsp;
</div>
<i18n :path="expired ? 'polls.expired' : 'polls.expires_in'">
<Timeago
:time="expiresAt"
:auto-update="60"
:now-threshold="0"
/>
</i18n>
</div>
</div>
</template>
<script src="./poll.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.poll {
.votes {
display: flex;
flex-direction: column;
margin: 0 0 0.5em;
}
.poll-option {
margin: 0.75em 0.5em;
}
.option-result {
height: 100%;
display: flex;
flex-direction: row;
position: relative;
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
.option-result-label {
display: flex;
align-items: center;
padding: 0.1em 0.25em;
z-index: 1;
}
.result-percentage {
width: 3.5em;
flex-shrink: 0;
}
.result-fill {
height: 100%;
position: absolute;
background-color: $fallback--lightBg;
background-color: var(--linkBg, $fallback--lightBg);
border-radius: $fallback--panelRadius;
border-radius: var(--panelRadius, $fallback--panelRadius);
top: 0;
left: 0;
transition: width 0.5s;
}
.option-vote {
display: flex;
align-items: center;
}
input {
width: 3.5em;
}
.footer {
display: flex;
align-items: center;
}
&.loading * {
cursor: progress;
}
.poll-vote-button {
padding: 0 0.5em;
margin-right: 0.5em;
}
}
</style>

View file

@ -0,0 +1,121 @@
import * as DateUtils from 'src/services/date_utils/date_utils.js'
import { uniq } from 'lodash'
export default {
name: 'PollForm',
props: ['visible'],
data: () => ({
pollType: 'single',
options: ['', ''],
expiryAmount: 10,
expiryUnit: 'minutes'
}),
computed: {
pollLimits () {
return this.$store.state.instance.pollLimits
},
maxOptions () {
return this.pollLimits.max_options
},
maxLength () {
return this.pollLimits.max_option_chars
},
expiryUnits () {
const allUnits = ['minutes', 'hours', 'days']
const expiry = this.convertExpiryFromUnit
return allUnits.filter(
unit => this.pollLimits.max_expiration >= expiry(unit, 1)
)
},
minExpirationInCurrentUnit () {
return Math.ceil(
this.convertExpiryToUnit(
this.expiryUnit,
this.pollLimits.min_expiration
)
)
},
maxExpirationInCurrentUnit () {
return Math.floor(
this.convertExpiryToUnit(
this.expiryUnit,
this.pollLimits.max_expiration
)
)
}
},
methods: {
clear () {
this.pollType = 'single'
this.options = ['', '']
this.expiryAmount = 10
this.expiryUnit = 'minutes'
},
nextOption (index) {
const element = this.$el.querySelector(`#poll-${index + 1}`)
if (element) {
element.focus()
} else {
// Try adding an option and try focusing on it
const addedOption = this.addOption()
if (addedOption) {
this.$nextTick(function () {
this.nextOption(index)
})
}
}
},
addOption () {
if (this.options.length < this.maxOptions) {
this.options.push('')
return true
}
return false
},
deleteOption (index, event) {
if (this.options.length > 2) {
this.options.splice(index, 1)
}
},
convertExpiryToUnit (unit, amount) {
// Note: we want seconds and not milliseconds
switch (unit) {
case 'minutes': return (1000 * amount) / DateUtils.MINUTE
case 'hours': return (1000 * amount) / DateUtils.HOUR
case 'days': return (1000 * amount) / DateUtils.DAY
}
},
convertExpiryFromUnit (unit, amount) {
// Note: we want seconds and not milliseconds
switch (unit) {
case 'minutes': return 0.001 * amount * DateUtils.MINUTE
case 'hours': return 0.001 * amount * DateUtils.HOUR
case 'days': return 0.001 * amount * DateUtils.DAY
}
},
expiryAmountChange () {
this.expiryAmount =
Math.max(this.minExpirationInCurrentUnit, this.expiryAmount)
this.expiryAmount =
Math.min(this.maxExpirationInCurrentUnit, this.expiryAmount)
this.updatePollToParent()
},
updatePollToParent () {
const expiresIn = this.convertExpiryFromUnit(
this.expiryUnit,
this.expiryAmount
)
const options = uniq(this.options.filter(option => option !== ''))
if (options.length < 2) {
this.$emit('update-poll', { error: this.$t('polls.not_enough_options') })
return
}
this.$emit('update-poll', {
options,
multiple: this.pollType === 'multiple',
expiresIn
})
}
}
}

View file

@ -0,0 +1,163 @@
<template>
<div
v-if="visible"
class="poll-form"
>
<div
v-for="(option, index) in options"
:key="index"
class="poll-option"
>
<div class="input-container">
<input
:id="`poll-${index}`"
v-model="options[index]"
class="poll-option-input"
type="text"
:placeholder="$t('polls.option')"
:maxlength="maxLength"
@change="updatePollToParent"
@keydown.enter.stop.prevent="nextOption(index)"
>
</div>
<div
v-if="options.length > 2"
class="icon-container"
>
<i
class="icon-cancel"
@click="deleteOption(index)"
/>
</div>
</div>
<a
v-if="options.length < maxOptions"
class="add-option faint"
@click="addOption"
>
<i class="icon-plus" />
{{ $t("polls.add_option") }}
</a>
<div class="poll-type-expiry">
<div
class="poll-type"
:title="$t('polls.type')"
>
<label
for="poll-type-selector"
class="select"
>
<select
v-model="pollType"
class="select"
@change="updatePollToParent"
>
<option value="single">{{ $t('polls.single_choice') }}</option>
<option value="multiple">{{ $t('polls.multiple_choices') }}</option>
</select>
<i class="icon-down-open" />
</label>
</div>
<div
class="poll-expiry"
:title="$t('polls.expiry')"
>
<input
v-model="expiryAmount"
type="number"
class="expiry-amount hide-number-spinner"
:min="minExpirationInCurrentUnit"
:max="maxExpirationInCurrentUnit"
@change="expiryAmountChange"
>
<label class="expiry-unit select">
<select
v-model="expiryUnit"
@change="expiryAmountChange"
>
<option
v-for="unit in expiryUnits"
:key="unit"
:value="unit"
>
{{ $t(`time.${unit}_short`, ['']) }}
</option>
</select>
<i class="icon-down-open" />
</label>
</div>
</div>
</div>
</template>
<script src="./poll_form.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.poll-form {
display: flex;
flex-direction: column;
padding: 0 0.5em 0.5em;
.add-option {
align-self: flex-start;
padding-top: 0.25em;
cursor: pointer;
}
.poll-option {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.25em;
}
.input-container {
width: 100%;
input {
// Hack: dodge the floating X icon
padding-right: 2.5em;
width: 100%;
}
}
.icon-container {
// Hack: Move the icon over the input box
width: 2em;
margin-left: -2em;
z-index: 1;
}
.poll-type-expiry {
margin-top: 0.5em;
display: flex;
width: 100%;
}
.poll-type {
margin-right: 0.75em;
flex: 1 1 60%;
.select {
border: none;
box-shadow: none;
background-color: transparent;
}
}
.poll-expiry {
display: flex;
.expiry-amount {
width: 3em;
text-align: right;
}
.expiry-unit {
border: none;
box-shadow: none;
background-color: transparent;
}
}
}
</style>

View file

@ -1,71 +1,99 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.popper-wrapper { .tooltip.popover {
z-index: 8; z-index: 8;
}
.popper-wrapper .popper__arrow { .popover-inner {
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
box-shadow: var(--panelShadow);
border-radius: $fallback--btnRadius;
border-radius: var(--btnRadius, $fallback--btnRadius);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
}
.popover-arrow {
width: 0; width: 0;
height: 0; height: 0;
border-style: solid; border-style: solid;
position: absolute; position: absolute;
margin: 5px; margin: 5px;
} border-color: $fallback--bg;
border-color: var(--bg, $fallback--bg);
z-index: 1;
}
.popper-wrapper[x-placement^="top"] { &[x-placement^="top"] {
margin-bottom: 5px; margin-bottom: 5px;
}
.popper-wrapper[x-placement^="top"] .popper__arrow { .popover-arrow {
border-width: 5px 5px 0 5px; border-width: 5px 5px 0 5px;
border-color: $fallback--bg transparent transparent transparent; border-left-color: transparent !important;
border-color: var(--bg, $fallback--bg) transparent transparent transparent; border-right-color: transparent !important;
border-bottom-color: transparent !important;
bottom: -5px; bottom: -5px;
left: calc(50% - 5px); left: calc(50% - 5px);
margin-top: 0; margin-top: 0;
margin-bottom: 0; margin-bottom: 0;
} }
}
.popper-wrapper[x-placement^="bottom"] { &[x-placement^="bottom"] {
margin-top: 5px; margin-top: 5px;
}
.popper-wrapper[x-placement^="bottom"] .popper__arrow { .popover-arrow {
border-width: 0 5px 5px 5px; border-width: 0 5px 5px 5px;
border-color: transparent transparent $fallback--bg transparent; border-left-color: transparent !important;
border-color: transparent transparent var(--bg, $fallback--bg) transparent; border-right-color: transparent !important;
border-top-color: transparent !important;
top: -5px; top: -5px;
left: calc(50% - 5px); left: calc(50% - 5px);
margin-top: 0; margin-top: 0;
margin-bottom: 0; margin-bottom: 0;
} }
}
.popper-wrapper[x-placement^="right"] { &[x-placement^="right"] {
margin-left: 5px; margin-left: 5px;
}
.popper-wrapper[x-placement^="right"] .popper__arrow { .popover-arrow {
border-width: 5px 5px 5px 0; border-width: 5px 5px 5px 0;
border-color: transparent $fallback--bg transparent transparent; border-left-color: transparent !important;
border-color: transparent var(--bg, $fallback--bg) transparent transparent; border-top-color: transparent !important;
border-bottom-color: transparent !important;
left: -5px; left: -5px;
top: calc(50% - 5px); top: calc(50% - 5px);
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
} }
}
.popper-wrapper[x-placement^="left"] { &[x-placement^="left"] {
margin-right: 5px; margin-right: 5px;
}
.popper-wrapper[x-placement^="left"] .popper__arrow { .popover-arrow {
border-width: 5px 0 5px 5px; border-width: 5px 0 5px 5px;
border-color: transparent transparent transparent $fallback--bg; border-top-color: transparent !important;
border-color: transparent transparent transparent var(--bg, $fallback--bg); border-right-color: transparent !important;
border-bottom-color: transparent !important;
right: -5px; right: -5px;
top: calc(50% - 5px); top: calc(50% - 5px);
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
}
}
&[aria-hidden='true'] {
visibility: hidden;
opacity: 0;
transition: opacity .15s, visibility .15s;
}
&[aria-hidden='false'] {
visibility: visible;
opacity: 1;
transition: opacity .15s;
}
} }
.dropdown-menu { .dropdown-menu {
@ -76,13 +104,6 @@
list-style: none; list-style: none;
max-width: 100vw; max-width: 100vw;
z-index: 10; 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 { .dropdown-divider {
height: 0; height: 0;

View file

@ -2,17 +2,19 @@ import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue' import MediaUpload from '../media_upload/media_upload.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue' import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji-input/emoji-input.vue' import EmojiInput from '../emoji-input/emoji-input.vue'
import PollForm from '../poll/poll_form.vue'
import StickerPicker from '../sticker_picker/sticker_picker.vue'
import fileTypeService from '../../services/file_type/file_type.service.js' import fileTypeService from '../../services/file_type/file_type.service.js'
import Completion from '../../services/completion/completion.js' import { reject, map, uniqBy } from 'lodash'
import { take, filter, reject, map, uniqBy } from 'lodash' import suggestor from '../emoji-input/suggestor.js'
const buildMentionsString = ({user, attentions}, currentUser) => { const buildMentionsString = ({ user, attentions }, currentUser) => {
let allAttentions = [...attentions] let allAttentions = [...attentions]
allAttentions.unshift(user) allAttentions.unshift(user)
allAttentions = uniqBy(allAttentions, 'id') allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, {id: currentUser.id}) allAttentions = reject(allAttentions, { id: currentUser.id })
let mentions = map(allAttentions, (attention) => { let mentions = map(allAttentions, (attention) => {
return `@${attention.screen_name}` return `@${attention.screen_name}`
@ -31,8 +33,10 @@ const PostStatusForm = {
], ],
components: { components: {
MediaUpload, MediaUpload,
ScopeSelector, EmojiInput,
EmojiInput PollForm,
StickerPicker,
ScopeSelector
}, },
mounted () { mounted () {
this.resize(this.$refs.textarea) this.resize(this.$refs.textarea)
@ -56,7 +60,7 @@ const PostStatusForm = {
statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser) statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
} }
const scope = (this.copyMessageScope && scopeCopy || this.copyMessageScope === 'direct') const scope = ((this.copyMessageScope && scopeCopy) || this.copyMessageScope === 'direct')
? this.copyMessageScope ? this.copyMessageScope
: this.$store.state.users.currentUser.default_scope : this.$store.state.users.currentUser.default_scope
@ -75,57 +79,16 @@ const PostStatusForm = {
status: statusText, status: statusText,
nsfw: false, nsfw: false,
files: [], files: [],
poll: {},
visibility: scope, visibility: scope,
contentType contentType
}, },
caret: 0 caret: 0,
pollFormVisible: false,
stickerPickerVisible: false
} }
}, },
computed: { computed: {
candidates () {
const firstchar = this.textAtCaret.charAt(0)
if (firstchar === '@') {
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
}
// eslint-disable-next-line camelcase
return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({
// eslint-disable-next-line camelcase
screen_name: `@${screen_name}`,
name: name,
img: profile_image_url_original,
highlighted: index === this.highlighted
}))
} else 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) => ({
screen_name: `:${shortcode}:`,
name: '',
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.newStatus.status, this.caret - 1) || {}
return word
},
users () { users () {
return this.$store.state.users.users return this.$store.state.users.users
}, },
@ -138,6 +101,24 @@ const PostStatusForm = {
: this.$store.state.config.minimalScopesMode : this.$store.state.config.minimalScopesMode
return !minimalScopesMode return !minimalScopesMode
}, },
emojiUserSuggestor () {
return suggestor({
emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
],
users: this.$store.state.users.users,
updateUsersList: (input) => this.$store.dispatch('searchUsers', input)
})
},
emojiSuggestor () {
return suggestor({
emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
]
})
},
emoji () { emoji () {
return this.$store.state.instance.emoji || [] return this.$store.state.instance.emoji || []
}, },
@ -174,71 +155,32 @@ const PostStatusForm = {
return true return true
} }
}, },
formattingOptionsEnabled () {
return this.$store.state.instance.formattingOptionsEnabled
},
postFormats () { postFormats () {
return this.$store.state.instance.postFormats || [] return this.$store.state.instance.postFormats || []
}, },
safeDMEnabled () { safeDMEnabled () {
return this.$store.state.instance.safeDM return this.$store.state.instance.safeDM
}, },
stickersAvailable () {
if (this.$store.state.instance.stickers) {
return this.$store.state.instance.stickers.length > 0
}
return 0
},
pollsAvailable () {
return this.$store.state.instance.pollsAvailable &&
this.$store.state.instance.pollLimits.max_options >= 2
},
hideScopeNotice () { hideScopeNotice () {
return this.$store.state.config.hideScopeNotice return this.$store.state.config.hideScopeNotice
},
pollContentError () {
return this.pollFormVisible &&
this.newStatus.poll &&
this.newStatus.poll.error
} }
}, },
methods: { methods: {
replace (replacement) {
this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement)
const el = this.$el.querySelector('textarea')
el.focus()
this.caret = 0
},
replaceCandidate (e) {
const len = this.candidates.length || 0
if (this.textAtCaret === ':' || e.ctrlKey) { return }
if (len > 0) {
e.preventDefault()
const candidate = this.candidates[this.highlighted]
const replacement = candidate.utf || (candidate.screen_name + ' ')
this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement)
const el = this.$el.querySelector('textarea')
el.focus()
this.caret = 0
this.highlighted = 0
}
},
cycleBackward (e) {
const len = this.candidates.length || 0
if (len > 0) {
e.preventDefault()
this.highlighted -= 1
if (this.highlighted < 0) {
this.highlighted = this.candidates.length - 1
}
} else {
this.highlighted = 0
}
},
cycleForward (e) {
const len = this.candidates.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()
},
setCaret ({target: {selectionStart}}) {
this.caret = selectionStart
},
postStatus (newStatus) { postStatus (newStatus) {
if (this.posting) { return } if (this.posting) { return }
if (this.submitDisabled) { return } if (this.submitDisabled) { return }
@ -252,6 +194,12 @@ const PostStatusForm = {
} }
} }
const poll = this.pollFormVisible ? this.newStatus.poll : {}
if (this.pollContentError) {
this.error = this.pollContentError
return
}
this.posting = true this.posting = true
statusPoster.postStatus({ statusPoster.postStatus({
status: newStatus.status, status: newStatus.status,
@ -261,7 +209,8 @@ const PostStatusForm = {
media: newStatus.files, media: newStatus.files,
store: this.$store, store: this.$store,
inReplyToStatusId: this.replyTo, inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType contentType: newStatus.contentType,
poll
}).then((data) => { }).then((data) => {
if (!data.error) { if (!data.error) {
this.newStatus = { this.newStatus = {
@ -269,9 +218,13 @@ const PostStatusForm = {
spoilerText: '', spoilerText: '',
files: [], files: [],
visibility: newStatus.visibility, visibility: newStatus.visibility,
contentType: newStatus.contentType contentType: newStatus.contentType,
poll: {}
} }
this.pollFormVisible = false
this.stickerPickerVisible = false
this.$refs.mediaUpload.clearFile() this.$refs.mediaUpload.clearFile()
this.clearPollForm()
this.$emit('posted') this.$emit('posted')
let el = this.$el.querySelector('textarea') let el = this.$el.querySelector('textarea')
el.style.height = 'auto' el.style.height = 'auto'
@ -286,6 +239,7 @@ const PostStatusForm = {
addMediaFile (fileInfo) { addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo) this.newStatus.files.push(fileInfo)
this.enableSubmit() this.enableSubmit()
this.stickerPickerVisible = false
}, },
removeMediaFile (fileInfo) { removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo) let index = this.newStatus.files.indexOf(fileInfo)
@ -327,8 +281,11 @@ const PostStatusForm = {
resize (e) { resize (e) {
const target = e.target || e const target = e.target || e
if (!(target instanceof window.Element)) { return } if (!(target instanceof window.Element)) { return }
const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) + const topPaddingStr = window.getComputedStyle(target)['padding-top']
Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1)) const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
// Remove "px" at the end of the values
const vertPadding = Number(topPaddingStr.substr(0, topPaddingStr.length - 2)) +
Number(bottomPaddingStr.substr(0, bottomPaddingStr.length - 2))
// Auto is needed to make textbox shrink when removing lines // Auto is needed to make textbox shrink when removing lines
target.style.height = 'auto' target.style.height = 'auto'
target.style.height = `${target.scrollHeight - vertPadding}px` target.style.height = `${target.scrollHeight - vertPadding}px`
@ -342,6 +299,25 @@ const PostStatusForm = {
changeVis (visibility) { changeVis (visibility) {
this.newStatus.visibility = visibility this.newStatus.visibility = visibility
}, },
toggleStickerPicker () {
this.stickerPickerVisible = !this.stickerPickerVisible
},
clearStickerPicker () {
if (this.$refs.stickerPicker) {
this.$refs.stickerPicker.clear()
}
},
togglePollForm () {
this.pollFormVisible = !this.pollFormVisible
},
setPoll (poll) {
this.newStatus.poll = poll
},
clearPollForm () {
if (this.$refs.pollForm) {
this.$refs.pollForm.clear()
}
},
dismissScopeNotice () { dismissScopeNotice () {
this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true }) this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true })
} }

View file

@ -1,127 +1,268 @@
<template> <template>
<div class="post-status-form"> <div class="post-status-form">
<form @submit.prevent="postStatus(newStatus)"> <form
<div class="form-group" > autocomplete="off"
@submit.prevent="postStatus(newStatus)"
>
<div class="form-group">
<i18n <i18n
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'" v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'"
path="post_status.account_not_locked_warning" path="post_status.account_not_locked_warning"
tag="p" tag="p"
class="visibility-notice"> class="visibility-notice"
<router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link> >
<router-link :to="{ name: 'user-settings' }">
{{ $t('post_status.account_not_locked_warning_link') }}
</router-link>
</i18n> </i18n>
<p v-if="!hideScopeNotice && newStatus.visibility === 'public'" class="visibility-notice notice-dismissible"> <p
v-if="!hideScopeNotice && newStatus.visibility === 'public'"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.public') }}</span> <span>{{ $t('post_status.scope_notice.public') }}</span>
<a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss"> <a
<i class='icon-cancel'></i> class="button-icon dismiss"
@click.prevent="dismissScopeNotice()"
>
<i class="icon-cancel" />
</a> </a>
</p> </p>
<p v-else-if="!hideScopeNotice && newStatus.visibility === 'unlisted'" class="visibility-notice notice-dismissible"> <p
v-else-if="!hideScopeNotice && newStatus.visibility === 'unlisted'"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.unlisted') }}</span> <span>{{ $t('post_status.scope_notice.unlisted') }}</span>
<a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss"> <a
<i class='icon-cancel'></i> class="button-icon dismiss"
@click.prevent="dismissScopeNotice()"
>
<i class="icon-cancel" />
</a> </a>
</p> </p>
<p v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && $store.state.users.currentUser.locked" class="visibility-notice notice-dismissible"> <p
v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && $store.state.users.currentUser.locked"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.private') }}</span> <span>{{ $t('post_status.scope_notice.private') }}</span>
<a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss"> <a
<i class='icon-cancel'></i> class="button-icon dismiss"
@click.prevent="dismissScopeNotice()"
>
<i class="icon-cancel" />
</a> </a>
</p> </p>
<p v-else-if="newStatus.visibility === 'direct'" class="visibility-notice"> <p
v-else-if="newStatus.visibility === 'direct'"
class="visibility-notice"
>
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span> <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p> </p>
<EmojiInput <EmojiInput
v-if="newStatus.spoilerText || alwaysShowSubject" v-if="newStatus.spoilerText || alwaysShowSubject"
v-model="newStatus.spoilerText"
:suggest="emojiSuggestor"
class="form-control"
>
<input
v-model="newStatus.spoilerText"
type="text" type="text"
:placeholder="$t('post_status.content_warning')" :placeholder="$t('post_status.content_warning')"
v-model="newStatus.spoilerText" class="form-post-subject"
classname="form-control" >
/> </EmojiInput>
<EmojiInput
v-model="newStatus.status"
:suggest="emojiUserSuggestor"
class="form-control main-input"
>
<textarea <textarea
ref="textarea" ref="textarea"
@click="setCaret" v-model="newStatus.status"
@keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control" :placeholder="$t('post_status.default')"
@keydown="onKeydown" rows="1"
@keydown.down="cycleForward" :disabled="posting"
@keydown.up="cycleBackward" class="form-post-body"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceCandidate"
@keydown.meta.enter="postStatus(newStatus)" @keydown.meta.enter="postStatus(newStatus)"
@keyup.ctrl.enter="postStatus(newStatus)" @keyup.ctrl.enter="postStatus(newStatus)"
@drop="fileDrop" @drop="fileDrop"
@dragover.prevent="fileDrag" @dragover.prevent="fileDrag"
@input="resize" @input="resize"
@paste="paste" @paste="paste"
:disabled="posting" />
<p
v-if="hasStatusLengthLimit"
class="character-counter faint"
:class="{ error: isOverLengthLimit }"
> >
</textarea> {{ charactersLeft }}
</p>
</EmojiInput>
<div class="visibility-tray"> <div class="visibility-tray">
<div class="text-format" v-if="formattingOptionsEnabled"> <scope-selector
<label for="post-content-type" class="select"> :show-all="showAllScopes"
<select id="post-content-type" v-model="newStatus.contentType" class="form-control"> :user-default="userDefaultScope"
<option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat"> :original-scope="copyMessageScope"
{{$t(`post_status.content_type["${postFormat}"]`)}} :initial-scope="newStatus.visibility"
:on-scope-change="changeVis"
/>
<div
v-if="postFormats.length > 1"
class="text-format"
>
<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> </option>
</select> </select>
<i class="icon-down-open"></i> <i class="icon-down-open" />
</label> </label>
</div> </div>
<scope-selector
:showAll="showAllScopes"
:userDefault="userDefaultScope"
:originalScope="copyMessageScope"
:initialScope="newStatus.visibility"
:onScopeChange="changeVis"/>
</div>
</div>
<div class="autocomplete-panel" v-if="candidates">
<div class="autocomplete-panel-body">
<div <div
v-for="(candidate, index) in candidates" v-if="postFormats.length === 1 && postFormats[0] !== 'text/plain'"
:key="index" class="text-format"
@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 class="only-format">
<span v-else>{{candidate.utf}}</span> {{ $t(`post_status.content_type["${postFormats[0]}"]`) }}
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span> </span>
</div> </div>
</div> </div>
</div> </div>
<div class='form-bottom'> <poll-form
<media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload> v-if="pollsAvailable"
ref="pollForm"
<p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p> :visible="pollFormVisible"
<p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p> @update-poll="setPoll"
/>
<button v-if="posting" disabled class="btn btn-default">{{$t('post_status.posting')}}</button> <div class="form-bottom">
<button v-else-if="isOverLengthLimit" disabled class="btn btn-default">{{$t('general.submit')}}</button> <div class="form-bottom-left">
<button v-else :disabled="submitDisabled" type="submit" class="btn btn-default">{{$t('general.submit')}}</button> <media-upload
ref="mediaUpload"
:drop-files="dropFiles"
@uploading="disableSubmit"
@uploaded="addMediaFile"
@upload-failed="uploadFailed"
/>
<div
v-if="stickersAvailable"
class="sticker-icon"
>
<i
:title="$t('stickers.add_sticker')"
class="icon-picture btn btn-default"
:class="{ selected: stickerPickerVisible }"
@click="toggleStickerPicker"
/>
</div> </div>
<div class='alert error' v-if="error"> <div
v-if="pollsAvailable"
class="poll-icon"
>
<i
:title="$t('polls.add_poll')"
class="icon-chart-bar btn btn-default"
:class="pollFormVisible && 'selected'"
@click="togglePollForm"
/>
</div>
</div>
<button
v-if="posting"
disabled
class="btn btn-default"
>
{{ $t('post_status.posting') }}
</button>
<button
v-else-if="isOverLengthLimit"
disabled
class="btn btn-default"
>
{{ $t('general.submit') }}
</button>
<button
v-else
:disabled="submitDisabled"
type="submit"
class="btn btn-default"
>
{{ $t('general.submit') }}
</button>
</div>
<div
v-if="error"
class="alert error"
>
Error: {{ error }} Error: {{ error }}
<i class="button-icon icon-cancel" @click="clearError"></i> <i
class="button-icon icon-cancel"
@click="clearError"
/>
</div> </div>
<div class="attachments"> <div class="attachments">
<div class="media-upload-wrapper" v-for="file in newStatus.files"> <div
<i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i> v-for="file in newStatus.files"
:key="file.url"
class="media-upload-wrapper"
>
<i
class="fa button-icon icon-cancel"
@click="removeMediaFile(file)"
/>
<div class="media-upload-container attachment"> <div class="media-upload-container attachment">
<img class="thumbnail media-upload" :src="file.url" v-if="type(file) === 'image'"></img> <img
<video v-if="type(file) === 'video'" :src="file.url" controls></video> v-if="type(file) === 'image'"
<audio v-if="type(file) === 'audio'" :src="file.url" controls></audio> class="thumbnail media-upload"
<a v-if="type(file) === 'unknown'" :href="file.url">{{file.url}}</a> :src="file.url"
>
<video
v-if="type(file) === 'video'"
:src="file.url"
controls
/>
<audio
v-if="type(file) === 'audio'"
:src="file.url"
controls
/>
<a
v-if="type(file) === 'unknown'"
:href="file.url"
>{{ file.url }}</a>
</div> </div>
</div> </div>
</div> </div>
<div class="upload_settings" v-if="newStatus.files.length > 0"> <div
<input type="checkbox" id="filesSensitive" v-model="newStatus.nsfw"> v-if="newStatus.files.length > 0"
<label for="filesSensitive">{{$t('post_status.attachments_sensitive')}}</label> class="upload_settings"
>
<input
id="filesSensitive"
v-model="newStatus.nsfw"
type="checkbox"
>
<label for="filesSensitive">{{ $t('post_status.attachments_sensitive') }}</label>
</div> </div>
</form> </form>
<sticker-picker
v-if="stickerPickerVisible"
ref="stickerPicker"
@uploaded="addMediaFile"
/>
</div> </div>
</template> </template>
@ -151,7 +292,6 @@
.visibility-tray { .visibility-tray {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
flex-direction: row-reverse;
padding-top: 5px; padding-top: 5px;
} }
} }
@ -173,6 +313,37 @@
} }
} }
.form-bottom-left {
display: flex;
flex: 1;
}
.text-format {
.only-format {
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
}
.poll-icon, .sticker-icon {
font-size: 26px;
flex: 1;
.selected {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
}
.sticker-icon {
flex: 0;
min-width: 50px;
}
.icon-chart-bar {
cursor: pointer;
}
.error { .error {
text-align: center; text-align: center;
} }
@ -233,7 +404,6 @@
} }
} }
.btn { .btn {
cursor: pointer; cursor: pointer;
} }
@ -263,19 +433,38 @@
min-height: 1px; min-height: 1px;
} }
form textarea.form-control { .form-post-body {
line-height:16px; height: 16px; // Only affects the empty-height
line-height: 16px;
resize: none; resize: none;
overflow: hidden; overflow: hidden;
transition: min-height 200ms 100ms; transition: min-height 200ms 100ms;
padding-bottom: 1.75em;
min-height: 1px; min-height: 1px;
box-sizing: content-box; box-sizing: content-box;
} }
form textarea.form-control:focus { .form-post-body:focus {
min-height: 48px; min-height: 48px;
} }
.main-input {
position: relative;
}
.character-counter {
position: absolute;
bottom: 0;
right: 0;
padding: 0;
margin: 0 0.5em;
&.error {
color: $fallback--cRed;
color: var(--cRed, $fallback--cRed);
}
}
.btn { .btn {
cursor: pointer; cursor: pointer;
} }

View file

@ -1,6 +1,9 @@
<template> <template>
<button :disabled="progress || disabled" @click="onClick"> <button
<template v-if="progress"> :disabled="progress || disabled"
@click="onClick"
>
<template v-if="progress && $slots.progress">
<slot name="progress" /> <slot name="progress" />
</template> </template>
<template v-else> <template v-else>

View file

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

View file

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

View file

@ -1,37 +1,50 @@
<template> <template>
<div class="range-control style-control" :class="{ disabled: !present || disabled }"> <div
<label :for="name" class="label"> class="range-control style-control"
{{label}} :class="{ disabled: !present || disabled }"
>
<label
:for="name"
class="label"
>
{{ label }}
</label> </label>
<input <input
v-if="typeof fallback !== 'undefined'" v-if="typeof fallback !== 'undefined'"
class="opt exclude-disabled"
:id="name + '-o'" :id="name + '-o'"
class="opt exclude-disabled"
type="checkbox" type="checkbox"
:checked="present" :checked="present"
@input="$emit('input', !present ? fallback : undefined)"> @input="$emit('input', !present ? fallback : undefined)"
<label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label> >
<label
v-if="typeof fallback !== 'undefined'"
class="opt-l"
:for="name + '-o'"
/>
<input <input
:id="name" :id="name"
class="input-number" class="input-number"
type="range" type="range"
:value="value || fallback" :value="value || fallback"
:disabled="!present || disabled" :disabled="!present || disabled"
@input="$emit('input', $event.target.value)"
:max="max || hardMax || 100" :max="max || hardMax || 100"
:min="min || hardMin || 0" :min="min || hardMin || 0"
:step="step || 1"> :step="step || 1"
@input="$emit('input', $event.target.value)"
>
<input <input
:id="name" :id="name"
class="input-number" class="input-number"
type="number" type="number"
:value="value || fallback" :value="value || fallback"
:disabled="!present || disabled" :disabled="!present || disabled"
@input="$emit('input', $event.target.value)"
:max="hardMax" :max="hardMax"
:min="hardMin" :min="hardMin"
:step="step || 1"> :step="step || 1"
</div> @input="$emit('input', $event.target.value)"
>
</div>
</template> </template>
<script> <script>

View file

@ -28,7 +28,7 @@ const registration = {
}, },
created () { created () {
if ((!this.registrationOpen && !this.token) || this.signedIn) { if ((!this.registrationOpen && !this.token) || this.signedIn) {
this.$router.push({name: 'root'}) this.$router.push({ name: 'root' })
} }
this.setCaptcha() this.setCaptcha()
@ -61,7 +61,7 @@ const registration = {
if (!this.$v.$invalid) { if (!this.$v.$invalid) {
try { try {
await this.signUp(this.user) await this.signUp(this.user)
this.$router.push({name: 'friends'}) this.$router.push({ name: 'friends' })
} catch (error) { } catch (error) {
console.warn('Registration failed: ' + error) console.warn('Registration failed: ' + error)
} }

View file

@ -1,109 +1,236 @@
<template> <template>
<div class="settings panel panel-default"> <div class="settings panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
{{$t('registration.registration')}} {{ $t('registration.registration') }}
</div> </div>
<div class="panel-body"> <div class="panel-body">
<form v-on:submit.prevent='submit(user)' class='registration-form'> <form
<div class='container'> class="registration-form"
<div class='text-fields'> @submit.prevent="submit(user)"
<div class='form-group' :class="{ 'form-group--error': $v.user.username.$error }"> >
<label class='form--label' for='sign-up-username'>{{$t('login.username')}}</label> <div class="container">
<input :disabled="isPending" v-model.trim='$v.user.username.$model' class='form-control' id='sign-up-username' :placeholder="$t('registration.username_placeholder')"> <div class="text-fields">
<div
class="form-group"
:class="{ 'form-group--error': $v.user.username.$error }"
>
<label
class="form--label"
for="sign-up-username"
>{{ $t('login.username') }}</label>
<input
id="sign-up-username"
v-model.trim="$v.user.username.$model"
:disabled="isPending"
class="form-control"
:placeholder="$t('registration.username_placeholder')"
>
</div> </div>
<div class="form-error" v-if="$v.user.username.$dirty"> <div
v-if="$v.user.username.$dirty"
class="form-error"
>
<ul> <ul>
<li v-if="!$v.user.username.required"> <li v-if="!$v.user.username.required">
<span>{{$t('registration.validations.username_required')}}</span> <span>{{ $t('registration.validations.username_required') }}</span>
</li> </li>
</ul> </ul>
</div> </div>
<div class='form-group' :class="{ 'form-group--error': $v.user.fullname.$error }"> <div
<label class='form--label' for='sign-up-fullname'>{{$t('registration.fullname')}}</label> class="form-group"
<input :disabled="isPending" v-model.trim='$v.user.fullname.$model' class='form-control' id='sign-up-fullname' :placeholder="$t('registration.fullname_placeholder')"> :class="{ 'form-group--error': $v.user.fullname.$error }"
>
<label
class="form--label"
for="sign-up-fullname"
>{{ $t('registration.fullname') }}</label>
<input
id="sign-up-fullname"
v-model.trim="$v.user.fullname.$model"
:disabled="isPending"
class="form-control"
:placeholder="$t('registration.fullname_placeholder')"
>
</div> </div>
<div class="form-error" v-if="$v.user.fullname.$dirty"> <div
v-if="$v.user.fullname.$dirty"
class="form-error"
>
<ul> <ul>
<li v-if="!$v.user.fullname.required"> <li v-if="!$v.user.fullname.required">
<span>{{$t('registration.validations.fullname_required')}}</span> <span>{{ $t('registration.validations.fullname_required') }}</span>
</li> </li>
</ul> </ul>
</div> </div>
<div class='form-group' :class="{ 'form-group--error': $v.user.email.$error }"> <div
<label class='form--label' for='email'>{{$t('registration.email')}}</label> class="form-group"
<input :disabled="isPending" v-model='$v.user.email.$model' class='form-control' id='email' type="email"> :class="{ 'form-group--error': $v.user.email.$error }"
>
<label
class="form--label"
for="email"
>{{ $t('registration.email') }}</label>
<input
id="email"
v-model="$v.user.email.$model"
:disabled="isPending"
class="form-control"
type="email"
>
</div> </div>
<div class="form-error" v-if="$v.user.email.$dirty"> <div
v-if="$v.user.email.$dirty"
class="form-error"
>
<ul> <ul>
<li v-if="!$v.user.email.required"> <li v-if="!$v.user.email.required">
<span>{{$t('registration.validations.email_required')}}</span> <span>{{ $t('registration.validations.email_required') }}</span>
</li> </li>
</ul> </ul>
</div> </div>
<div class='form-group'> <div class="form-group">
<label class='form--label' for='bio'>{{$t('registration.bio')}} ({{$t('general.optional')}})</label> <label
<textarea :disabled="isPending" v-model='user.bio' class='form-control' id='bio' :placeholder="bioPlaceholder"></textarea> class="form--label"
for="bio"
>{{ $t('registration.bio') }} ({{ $t('general.optional') }})</label>
<textarea
id="bio"
v-model="user.bio"
:disabled="isPending"
class="form-control"
:placeholder="bioPlaceholder"
/>
</div> </div>
<div class='form-group' :class="{ 'form-group--error': $v.user.password.$error }"> <div
<label class='form--label' for='sign-up-password'>{{$t('login.password')}}</label> class="form-group"
<input :disabled="isPending" v-model='user.password' class='form-control' id='sign-up-password' type='password'> :class="{ 'form-group--error': $v.user.password.$error }"
>
<label
class="form--label"
for="sign-up-password"
>{{ $t('login.password') }}</label>
<input
id="sign-up-password"
v-model="user.password"
:disabled="isPending"
class="form-control"
type="password"
>
</div> </div>
<div class="form-error" v-if="$v.user.password.$dirty"> <div
v-if="$v.user.password.$dirty"
class="form-error"
>
<ul> <ul>
<li v-if="!$v.user.password.required"> <li v-if="!$v.user.password.required">
<span>{{$t('registration.validations.password_required')}}</span> <span>{{ $t('registration.validations.password_required') }}</span>
</li> </li>
</ul> </ul>
</div> </div>
<div class='form-group' :class="{ 'form-group--error': $v.user.confirm.$error }"> <div
<label class='form--label' for='sign-up-password-confirmation'>{{$t('registration.password_confirm')}}</label> class="form-group"
<input :disabled="isPending" v-model='user.confirm' class='form-control' id='sign-up-password-confirmation' type='password'> :class="{ 'form-group--error': $v.user.confirm.$error }"
>
<label
class="form--label"
for="sign-up-password-confirmation"
>{{ $t('registration.password_confirm') }}</label>
<input
id="sign-up-password-confirmation"
v-model="user.confirm"
:disabled="isPending"
class="form-control"
type="password"
>
</div> </div>
<div class="form-error" v-if="$v.user.confirm.$dirty"> <div
v-if="$v.user.confirm.$dirty"
class="form-error"
>
<ul> <ul>
<li v-if="!$v.user.confirm.required"> <li v-if="!$v.user.confirm.required">
<span>{{$t('registration.validations.password_confirmation_required')}}</span> <span>{{ $t('registration.validations.password_confirmation_required') }}</span>
</li> </li>
<li v-if="!$v.user.confirm.sameAsPassword"> <li v-if="!$v.user.confirm.sameAsPassword">
<span>{{$t('registration.validations.password_confirmation_match')}}</span> <span>{{ $t('registration.validations.password_confirmation_match') }}</span>
</li> </li>
</ul> </ul>
</div> </div>
<div class="form-group" id="captcha-group" v-if="captcha.type != 'none'"> <div
<label class='form--label' for='captcha-label'>{{$t('captcha')}}</label> v-if="captcha.type != 'none'"
id="captcha-group"
class="form-group"
>
<label
class="form--label"
for="captcha-label"
>{{ $t('captcha') }}</label>
<template v-if="captcha.type == 'kocaptcha'"> <template v-if="captcha.type == 'kocaptcha'">
<img v-bind:src="captcha.url" v-on:click="setCaptcha"> <img
:src="captcha.url"
@click="setCaptcha"
>
<sub>{{$t('registration.new_captcha')}}</sub> <sub>{{ $t('registration.new_captcha') }}</sub>
<input :disabled="isPending" <input
v-model='captcha.solution' id="captcha-answer"
class='form-control' id='captcha-answer' type='text' autocomplete="off"> v-model="captcha.solution"
:disabled="isPending"
class="form-control"
type="text"
autocomplete="off"
>
</template> </template>
</div> </div>
<div class='form-group' v-if='token' > <div
<label for='token'>{{$t('registration.token')}}</label> v-if="token"
<input disabled='true' v-model='token' class='form-control' id='token' type='text'> class="form-group"
>
<label for="token">{{ $t('registration.token') }}</label>
<input
id="token"
v-model="token"
disabled="true"
class="form-control"
type="text"
>
</div> </div>
<div class='form-group'> <div class="form-group">
<button :disabled="isPending" type='submit' class='btn btn-default'>{{$t('general.submit')}}</button> <button
:disabled="isPending"
type="submit"
class="btn btn-default"
>
{{ $t('general.submit') }}
</button>
</div> </div>
</div> </div>
<div class='terms-of-service' v-html="termsOfService"> <!-- eslint-disable vue/no-v-html -->
<div
class="terms-of-service"
v-html="termsOfService"
/>
<!-- eslint-enable vue/no-v-html -->
</div> </div>
</div> <div
<div v-if="serverValidationErrors.length" class='form-group'> v-if="serverValidationErrors.length"
<div class='alert error'> class="form-group"
<span v-for="error in serverValidationErrors">{{error}}</span> >
<div class="alert error">
<span
v-for="error in serverValidationErrors"
:key="error"
>{{ error }}</span>
</div> </div>
</div> </div>
</form> </form>
@ -141,6 +268,7 @@ $validations-cRed: #f04124;
textarea { textarea {
min-height: 100px; min-height: 100px;
resize: vertical;
} }
.form-group { .form-group {

View file

@ -1,9 +1,23 @@
<template> <template>
<div class="remote-follow"> <div class="remote-follow">
<form method="POST" :action='subscribeUrl'> <form
<input type="hidden" name="nickname" :value="user.screen_name"> method="POST"
<input type="hidden" name="profile" value=""> :action="subscribeUrl"
<button click="submit" class="remote-button"> >
<input
type="hidden"
name="nickname"
:value="user.screen_name"
>
<input
type="hidden"
name="profile"
value=""
>
<button
click="submit"
class="remote-button"
>
{{ $t('user_card.remote_follow') }} {{ $t('user_card.remote_follow') }}
</button> </button>
</form> </form>

View file

@ -11,9 +11,9 @@ const RetweetButton = {
methods: { methods: {
retweet () { retweet () {
if (!this.status.repeated) { if (!this.status.repeated) {
this.$store.dispatch('retweet', {id: this.status.id}) this.$store.dispatch('retweet', { id: this.status.id })
} else { } else {
this.$store.dispatch('unretweet', {id: this.status.id}) this.$store.dispatch('unretweet', { id: this.status.id })
} }
this.animated = true this.animated = true
setTimeout(() => { setTimeout(() => {

View file

@ -1,16 +1,29 @@
<template> <template>
<div v-if="loggedIn"> <div v-if="loggedIn">
<template v-if="visibility !== 'private' && visibility !== 'direct'"> <template v-if="visibility !== 'private' && visibility !== 'direct'">
<i :class='classes' class='button-icon retweet-button icon-retweet rt-active' v-on:click.prevent='retweet()' :title="$t('tool_tip.repeat')"></i> <i
<span v-if='!hidePostStatsLocal && status.repeat_num > 0'>{{status.repeat_num}}</span> :class="classes"
class="button-icon retweet-button icon-retweet rt-active"
:title="$t('tool_tip.repeat')"
@click.prevent="retweet()"
/>
<span v-if="!hidePostStatsLocal && status.repeat_num > 0">{{ status.repeat_num }}</span>
</template> </template>
<template v-else> <template v-else>
<i :class='classes' class='button-icon icon-lock' :title="$t('timeline.no_retweet_hint')"></i> <i
:class="classes"
class="button-icon icon-lock"
:title="$t('timeline.no_retweet_hint')"
/>
</template> </template>
</div> </div>
<div v-else-if="!loggedIn"> <div v-else-if="!loggedIn">
<i :class='classes' class='button-icon icon-retweet' :title="$t('tool_tip.repeat')"></i> <i
<span v-if='!hidePostStatsLocal && status.repeat_num > 0'>{{status.repeat_num}}</span> :class="classes"
class="button-icon icon-retweet"
:title="$t('tool_tip.repeat')"
/>
<span v-if="!hidePostStatsLocal && status.repeat_num > 0">{{ status.repeat_num }}</span>
</div> </div>
</template> </template>

View file

@ -29,10 +29,10 @@ const ScopeSelector = {
}, },
css () { css () {
return { return {
public: {selected: this.currentScope === 'public'}, public: { selected: this.currentScope === 'public' },
unlisted: {selected: this.currentScope === 'unlisted'}, unlisted: { selected: this.currentScope === 'unlisted' },
private: {selected: this.currentScope === 'private'}, private: { selected: this.currentScope === 'private' },
direct: {selected: this.currentScope === 'direct'} direct: { selected: this.currentScope === 'direct' }
} }
} }
}, },

View file

@ -1,30 +1,37 @@
<template> <template>
<div v-if="!showNothing" class="scope-selector"> <div
<i class="icon-mail-alt" v-if="!showNothing"
class="scope-selector"
>
<i
v-if="showDirect"
class="icon-mail-alt"
:class="css.direct" :class="css.direct"
:title="$t('post_status.scope.direct')" :title="$t('post_status.scope.direct')"
v-if="showDirect" @click="changeVis('direct')"
@click="changeVis('direct')"> />
</i> <i
<i class="icon-lock" v-if="showPrivate"
class="icon-lock"
:class="css.private" :class="css.private"
:title="$t('post_status.scope.private')" :title="$t('post_status.scope.private')"
v-if="showPrivate" @click="changeVis('private')"
v-on:click="changeVis('private')"> />
</i> <i
<i class="icon-lock-open-alt" v-if="showUnlisted"
class="icon-lock-open-alt"
:class="css.unlisted" :class="css.unlisted"
:title="$t('post_status.scope.unlisted')" :title="$t('post_status.scope.unlisted')"
v-if="showUnlisted" @click="changeVis('unlisted')"
@click="changeVis('unlisted')"> />
</i> <i
<i class="icon-globe" v-if="showPublic"
class="icon-globe"
:class="css.public" :class="css.public"
:title="$t('post_status.scope.public')" :title="$t('post_status.scope.public')"
v-if="showPublic" @click="changeVis('public')"
@click="changeVis('public')"> />
</i> </div>
</div>
</template> </template>
<script src="./scope_selector.js"></script> <script src="./scope_selector.js"></script>

View file

@ -0,0 +1,98 @@
import FollowCard from '../follow_card/follow_card.vue'
import Conversation from '../conversation/conversation.vue'
import Status from '../status/status.vue'
import map from 'lodash/map'
const Search = {
components: {
FollowCard,
Conversation,
Status
},
props: [
'query'
],
data () {
return {
loaded: false,
loading: false,
searchTerm: this.query || '',
userIds: [],
statuses: [],
hashtags: [],
currenResultTab: 'statuses'
}
},
computed: {
users () {
return this.userIds.map(userId => this.$store.getters.findUser(userId))
},
visibleStatuses () {
const allStatusesObject = this.$store.state.statuses.allStatusesObject
return this.statuses.filter(status =>
allStatusesObject[status.id] && !allStatusesObject[status.id].deleted
)
}
},
mounted () {
this.search(this.query)
},
watch: {
query (newValue) {
this.searchTerm = newValue
this.search(newValue)
}
},
methods: {
newQuery (query) {
this.$router.push({ name: 'search', query: { query } })
this.$refs.searchInput.focus()
},
search (query) {
if (!query) {
this.loading = false
return
}
this.loading = true
this.userIds = []
this.statuses = []
this.hashtags = []
this.$refs.searchInput.blur()
this.$store.dispatch('search', { q: query, resolve: true })
.then(data => {
this.loading = false
this.userIds = map(data.accounts, 'id')
this.statuses = data.statuses
this.hashtags = data.hashtags
this.currenResultTab = this.getActiveTab()
this.loaded = true
})
},
resultCount (tabName) {
const length = this[tabName].length
return length === 0 ? '' : ` (${length})`
},
onResultTabSwitch (key) {
this.currenResultTab = key
},
getActiveTab () {
if (this.visibleStatuses.length > 0) {
return 'statuses'
} else if (this.users.length > 0) {
return 'people'
} else if (this.hashtags.length > 0) {
return 'hashtags'
}
return 'statuses'
},
lastHistoryRecord (hashtag) {
return hashtag.history && hashtag.history[0]
}
}
}
export default Search

View file

@ -0,0 +1,208 @@
<template>
<div class="panel panel-default">
<div class="panel-heading">
<div class="title">
{{ $t('nav.search') }}
</div>
</div>
<div class="search-input-container">
<input
ref="searchInput"
v-model="searchTerm"
class="search-input"
:placeholder="$t('nav.search')"
@keyup.enter="newQuery(searchTerm)"
>
<button
class="btn search-button"
@click="newQuery(searchTerm)"
>
<i class="icon-search" />
</button>
</div>
<div
v-if="loading"
class="text-center loading-icon"
>
<i class="icon-spin3 animate-spin" />
</div>
<div v-else-if="loaded">
<div class="search-nav-heading">
<tab-switcher
ref="tabSwitcher"
:on-switch="onResultTabSwitch"
:active-tab="currenResultTab"
>
<span
key="statuses"
:label="$t('user_card.statuses') + resultCount('visibleStatuses')"
/>
<span
key="people"
:label="$t('search.people') + resultCount('users')"
/>
<span
key="hashtags"
:label="$t('search.hashtags') + resultCount('hashtags')"
/>
</tab-switcher>
</div>
</div>
<div class="panel-body">
<div v-if="currenResultTab === 'statuses'">
<div
v-if="visibleStatuses.length === 0 && !loading && loaded"
class="search-result-heading"
>
<h4>{{ $t('search.no_results') }}</h4>
</div>
<Status
v-for="status in visibleStatuses"
:key="status.id"
:collapsable="false"
:expandable="false"
:compact="false"
class="search-result"
:statusoid="status"
:no-heading="false"
/>
</div>
<div v-else-if="currenResultTab === 'people'">
<div
v-if="users.length === 0 && !loading && loaded"
class="search-result-heading"
>
<h4>{{ $t('search.no_results') }}</h4>
</div>
<FollowCard
v-for="user in users"
:key="user.id"
:user="user"
class="list-item search-result"
/>
</div>
<div v-else-if="currenResultTab === 'hashtags'">
<div
v-if="hashtags.length === 0 && !loading && loaded"
class="search-result-heading"
>
<h4>{{ $t('search.no_results') }}</h4>
</div>
<div
v-for="hashtag in hashtags"
:key="hashtag.url"
class="status trend search-result"
>
<div class="hashtag">
<router-link :to="{ name: 'tag-timeline', params: { tag: hashtag.name } }">
#{{ hashtag.name }}
</router-link>
<div v-if="lastHistoryRecord(hashtag)">
<span v-if="lastHistoryRecord(hashtag).accounts == 1">
{{ $t('search.person_talking', { count: lastHistoryRecord(hashtag).accounts }) }}
</span>
<span v-else>
{{ $t('search.people_talking', { count: lastHistoryRecord(hashtag).accounts }) }}
</span>
</div>
</div>
<div
v-if="lastHistoryRecord(hashtag)"
class="count"
>
{{ lastHistoryRecord(hashtag).uses }}
</div>
</div>
</div>
</div>
<div class="search-result-footer text-center panel-footer faint" />
</div>
</template>
<script src="./search.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.search-result-heading {
color: $fallback--faint;
color: var(--faint, $fallback--faint);
padding: 0.75rem;
text-align: center;
}
@media all and (max-width: 800px) {
.search-nav-heading {
.tab-switcher .tabs .tab-wrapper {
display: block;
justify-content: center;
flex: 1 1 auto;
text-align: center;
}
}
}
.search-result {
box-sizing: border-box;
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
}
.search-result-footer {
border-width: 1px 0 0 0;
border-style: solid;
border-color: var(--border, $fallback--border);
padding: 10px;
background-color: $fallback--fg;
background-color: var(--panel, $fallback--fg);
}
.search-input-container {
padding: 0.8rem;
display: flex;
justify-content: center;
.search-input {
width: 100%;
line-height: 1.125rem;
font-size: 1rem;
padding: 0.5rem;
box-sizing: border-box;
}
.search-button {
margin-left: 0.5em;
}
}
.loading-icon {
padding: 1em;
}
.trend {
display: flex;
align-items: center;
.hashtag {
flex: 1 1 auto;
color: $fallback--text;
color: var(--text, $fallback--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.count {
flex: 0 0 auto;
width: 2rem;
font-size: 1.5rem;
line-height: 2.25rem;
font-weight: 500;
text-align: center;
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
</style>

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