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,
// allow debugger during development
'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
// 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
'vue/require-prop-types': 0
}
}

View File

@ -1,5 +1,8 @@
# v1.0
## 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
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
compiler.plugin('compilation', function (compilation) {
compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
hotMiddleware.publish({ action: 'reload' })
cb()
// FIXME: This supposed to reload whole page when index.html is changed,
// however now it reloads entire page on every breath, i suppose the order
// of plugins changed or something. It's a minor thing and douesn't hurt
// disabling it, constant reloads hurt much more
// hotMiddleware.publish({ action: 'reload' })
// cb()
})
})

View File

@ -27,16 +27,17 @@ exports.cssLoaders = function (options) {
return [
{
test: /\.(post)?css$/,
use: generateLoaders(['css-loader']),
use: generateLoaders(['css-loader', 'postcss-loader']),
},
{
test: /\.less$/,
use: generateLoaders(['css-loader', 'less-loader']),
use: generateLoaders(['css-loader', 'postcss-loader', 'less-loader']),
},
{
test: /\.sass$/,
use: generateLoaders([
'css-loader',
'postcss-loader',
{
loader: 'sass-loader',
options: {
@ -47,11 +48,11 @@ exports.cssLoaders = function (options) {
},
{
test: /\.scss$/,
use: generateLoaders(['css-loader', 'sass-loader'])
use: generateLoaders(['css-loader', 'postcss-loader', 'sass-loader'])
},
{
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,
cookieDomainRewrite: 'localhost',
ws: true
},
'/oauth/revoke': {
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost'
}
},
// 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/animation.css">
</head>
<body style="display: none">
<body>
<noscript>To use Pleroma, please enable JavaScript.</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>

View File

@ -25,17 +25,15 @@
"localforage": "^1.5.0",
"object-path": "^0.11.3",
"phoenix": "^1.3.0",
"popper.js": "^1.14.7",
"portal-vue": "^2.1.4",
"sanitize-html": "^1.13.0",
"v-click-outside": "^2.1.1",
"v-tooltip": "^2.0.2",
"vue": "^2.5.13",
"vue-chat-scroll": "^1.2.1",
"vue-i18n": "^7.3.2",
"vue-popperjs": "^2.0.3",
"vue-router": "^3.0.1",
"vue-template-compiler": "^2.3.4",
"vue-timeago": "^3.1.2",
"vuelidate": "^0.7.4",
"vuex": "^3.0.1",
"whatwg-fetch": "^2.0.3"
@ -82,8 +80,8 @@
"json-loader": "^0.5.4",
"karma": "^3.0.0",
"karma-coverage": "^1.1.1",
"karma-mocha": "^1.2.0",
"karma-firefox-launcher": "^1.1.0",
"karma-mocha": "^1.2.0",
"karma-sinon-chai": "^2.0.2",
"karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "0.0.26",
@ -95,6 +93,7 @@
"nightwatch": "^0.9.8",
"opn": "^4.0.2",
"ora": "^0.3.0",
"postcss-loader": "^3.0.0",
"raw-loader": "^0.5.1",
"sass": "^1.17.3",
"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 NavPanel from './components/nav_panel/nav_panel.vue'
import Notifications from './components/notifications/notifications.vue'
import UserFinder from './components/user_finder/user_finder.vue'
import SearchBar from './components/search_bar/search_bar.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
@ -19,7 +19,7 @@ export default {
UserPanel,
NavPanel,
Notifications,
UserFinder,
SearchBar,
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel,
@ -32,7 +32,7 @@ export default {
},
data: () => ({
mobileActivePanel: 'timeline',
finderHidden: true,
searchBarHidden: true,
supportsMask: window.CSS && window.CSS.supports && (
window.CSS.supports('mask-size', 'contain') ||
window.CSS.supports('-webkit-mask-size', 'contain') ||
@ -70,7 +70,7 @@ export default {
logoBgStyle () {
return Object.assign({
'margin': `${this.$store.state.instance.logoMargin} 0`,
opacity: this.finderHidden ? 1 : 0
opacity: this.searchBarHidden ? 1 : 0
}, this.enableMask ? {} : {
'background-color': this.enableMask ? '' : 'transparent'
})
@ -89,7 +89,11 @@ export default {
sitename () { return this.$store.state.instance.name },
chat () { return this.$store.state.chat.channel.state === 'joined' },
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 },
isMobileLayout () { return this.$store.state.interface.mobileLayout }
},
@ -101,8 +105,8 @@ export default {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
},
onFinderToggled (hidden) {
this.finderHidden = hidden
onSearchBarToggled (hidden) {
this.searchBarHidden = hidden
},
updateMobileState () {
const mobileLayout = windowWidth() <= 800

View File

@ -47,6 +47,8 @@ body {
color: var(--text, $fallback--text);
max-width: 100vw;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
@ -129,6 +131,7 @@ input, textarea, .select {
font-family: sans-serif;
font-family: var(--inputFont, sans-serif);
font-size: 14px;
margin: 0;
padding: 8px .5em;
box-sizing: border-box;
display: inline-block;
@ -182,7 +185,44 @@ input, textarea, .select {
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] {
display: none;
&:checked + label::before {
@ -197,6 +237,7 @@ input, textarea, .select {
}
}
+ label::before {
flex-shrink: 0;
display: inline-block;
content: '';
transition: color 200ms;
@ -228,11 +269,45 @@ option {
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-] {
color: $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 {
display: flex;
@ -474,23 +549,6 @@ nav {
color: var(--faint, $fallback--faint);
box-shadow: 0px 0px 4px rgba(0,0,0,.6);
box-shadow: var(--topBarShadow);
.back-button {
display: block;
max-width: 99px;
transition-property: opacity, max-width;
transition-duration: 300ms;
transition-timing-function: ease-out;
i {
margin: 0 1em;
}
&.hidden {
opacity: 0;
max-width: 5px;
}
}
}
.fade-enter-active, .fade-leave-active {
@ -526,12 +584,6 @@ nav {
overflow-y: scroll;
}
nav {
.back-button {
display: none;
}
}
.sidebar-bounds {
overflow: hidden;
max-height: 100vh;
@ -806,54 +858,3 @@ nav {
.btn.btn-default {
min-height: 28px;
}
.autocomplete {
&-panel {
position: relative;
&-body {
margin: 0 0.5em 0 0.5em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
position: absolute;
z-index: 1;
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
// this doesn't match original but i don't care, making it uniform.
box-shadow: var(--popupShadow);
min-width: 75%;
background: $fallback--bg;
background: var(--bg, $fallback--bg);
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
}
&-item {
cursor: pointer;
padding: 0.2em 0.4em 0.2em 0.4em;
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
display: flex;
img {
width: 24px;
height: 24px;
object-fit: contain;
}
span {
line-height: 24px;
margin: 0 0.1em 0 0.2em;
}
small {
margin-left: .5em;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
&.highlighted {
background-color: $fallback--fg;
background-color: var(--lightBg, $fallback--fg);
}
}
}

View File

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

View File

@ -100,7 +100,6 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('redirectRootLogin')
copyInstanceOption('showInstanceSpecificPanel')
copyInstanceOption('minimalScopesMode')
copyInstanceOption('formattingOptionsEnabled')
copyInstanceOption('hideMutedPosts')
copyInstanceOption('collapseMessageWithSubject')
copyInstanceOption('scopeCopy')
@ -110,12 +109,6 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('noAttachmentLinks')
copyInstanceOption('showFeaturesPanel')
if ((config.chatDisabled)) {
store.dispatch('disableChat')
} else {
store.dispatch('initializeSocket')
}
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 }) => {
try {
const res = await window.fetch('/static/emoji.json')
if (res.ok) {
const values = await res.json()
const emoji = Object.keys(values).map((key) => {
return { shortcode: key, image_url: false, 'utf': values[key] }
return {
displayText: key,
imageUrl: false,
replacement: values[key]
}
})
store.dispatch('setInstanceOption', { name: 'emoji', value: emoji })
} else {
@ -176,7 +204,12 @@ const getCustomEmoji = async ({ store }) => {
const result = await res.json()
const values = Array.isArray(result) ? Object.assign({}, ...result) : result
const emoji = Object.keys(values).map((key) => {
return { shortcode: key, image_url: values[key].image_url || values[key] }
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: 'pleromaBackend', value: true })
@ -207,11 +240,12 @@ const getNodeInfo = async ({ store }) => {
if (res.ok) {
const data = await res.json()
const metadata = data.metadata
const features = metadata.features
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: '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: 'postFormats', value: metadata.postFormats })
@ -277,6 +311,7 @@ const afterStoreSetup = async ({ store, i18n }) => {
setConfig({ store }),
getTOS({ store }),
getInstancePanel({ store }),
getStickers({ store }),
getStaticEmoji({ store }),
getCustomEmoji({ 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 DMs from 'components/dm_timeline/dm_timeline.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 Registration from 'components/registration/registration.vue'
import UserSettings from 'components/user_settings/user_settings.vue'
import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import UserSearch from 'components/user_search/user_search.vue'
import Notifications from 'components/notifications/notifications.vue'
import AuthForm from 'components/auth_form/auth_form.js'
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'
export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => {
if (store.state.users.currentUser) {
next()
} else {
next(store.state.instance.redirectRootNoLogin || '/main/all')
}
}
return [
{ name: 'root',
path: '/',
@ -30,23 +38,23 @@ export default (store) => {
},
{ name: 'public-external-timeline', path: '/main/all', component: PublicAndExternalTimeline },
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions },
{ name: 'dms', path: '/users/:username/dms', component: DMs },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
{ name: 'settings', path: '/settings', component: Settings },
{ name: 'registration', path: '/registration', component: Registration },
{ name: 'registration-token', path: '/registration/:token', component: Registration },
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests },
{ name: 'user-settings', path: '/user-settings', component: UserSettings },
{ name: 'notifications', path: '/:username/notifications', component: Notifications },
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
{ name: 'user-settings', path: '/user-settings', component: UserSettings, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm },
{ name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ name: 'user-search', path: '/user-search', component: UserSearch, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow },
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About },
{ name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,12 @@
<template>
<basic-user-card :user="user">
<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">
{{ $t('user_card.unblock_progress') }}
</template>
@ -9,7 +14,12 @@
{{ $t('user_card.unblock') }}
</template>
</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">
{{ $t('user_card.block_progress') }}
</template>

View File

@ -1,21 +1,39 @@
<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-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">
<span>{{ $t('chat.title') }}</span>
<i class="icon-cancel" v-if="floating"></i>
<i
v-if="floating"
class="icon-cancel"
/>
</div>
</div>
<div class="chat-window" v-chat-scroll>
<div class="chat-message" v-for="message in messages" :key="message.id">
<div
v-chat-scroll
class="chat-window"
>
<div
v-for="message in messages"
:key="message.id"
class="chat-message"
>
<span class="chat-avatar">
<img :src="message.author.avatar" />
<img :src="message.author.avatar">
</span>
<div class="chat-content">
<router-link
class="chat-name"
:to="userProfileLink(message.author)">
:to="userProfileLink(message.author)"
>
{{ message.author.username }}
</router-link>
<br>
@ -26,15 +44,26 @@
</div>
</div>
<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 v-else class="chat-panel">
<div
v-else
class="chat-panel"
>
<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">
<i class="icon-comment-empty"></i>
<i class="icon-comment-empty" />
{{ $t('chat.title') }}
</div>
</div>

View File

@ -1,8 +1,13 @@
<template>
<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" />
<span v-if="!!$slots.default"><slot></slot></span>
<span v-if="!!$slots.default"><slot /></span>
</label>
</template>

View File

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

View File

@ -1,6 +1,12 @@
<template>
<span v-if="contrast" class="contrast-ratio">
<span :title="hint" class="rating">
<span
v-if="contrast"
class="contrast-ratio"
>
<span
:title="hint"
class="rating"
>
<span v-if="contrast.aaa">
<i class="icon-thumbs-up-alt" />
</span>
@ -11,7 +17,11 @@
<i class="icon-attention" />
</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">
<i class="icon-thumbs-up-alt" />
</span>

View File

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

View File

@ -42,7 +42,7 @@ const conversation = {
'statusoid',
'collapsable',
'isPage',
'showPinned'
'pinnedStatusIdsObject'
],
created () {
if (this.isPage) {
@ -86,6 +86,7 @@ const conversation = {
},
replies () {
let i = 1
// eslint-disable-next-line camelcase
return reduce(this.conversation, (result, { id, in_reply_to_status_id }) => {
/* eslint-disable camelcase */
const irid = in_reply_to_status_id
@ -109,7 +110,7 @@ const conversation = {
Status
},
watch: {
'$route': 'fetchConversation',
status: 'fetchConversation',
expanded (value) {
if (value) {
this.fetchConversation()
@ -139,6 +140,7 @@ const conversation = {
return (this.isExpanded) && id === this.status.id
},
setHighlight (id) {
if (!id) return
this.highlight = id
this.$store.dispatch('fetchFavsAndRepeats', id)
},
@ -147,9 +149,6 @@ const conversation = {
},
toggleExpanded () {
this.expanded = !this.expanded
if (!this.expanded) {
this.setHighlight(null)
}
}
}
}

View File

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

View File

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

View File

@ -1,5 +1,9 @@
<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>
<script src="./dm_timeline.js"></script>

View File

@ -1,51 +1,122 @@
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 = {
props: [
'value',
'placeholder',
'type',
'classname'
],
props: {
suggest: {
/**
* suggest: function (input: String) => Suggestion[]
*
* 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 () {
return {
input: undefined,
highlighted: 0,
caret: 0
caret: 0,
focused: false,
blurTimeout: null
}
},
computed: {
suggestions () {
const firstchar = this.textAtCaret.charAt(0)
if (firstchar === ':') {
if (this.textAtCaret === ':') { return }
const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1)))
if (matchedEmoji.length <= 0) {
return false
if (this.textAtCaret === firstchar) { return [] }
const matchedSuggestions = this.suggest(this.textAtCaret)
if (matchedSuggestions.length <= 0) {
return []
}
return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({
shortcode: `:${shortcode}:`,
utf: utf || '',
return take(matchedSuggestions, 5)
.map(({ imageUrl, ...rest }, index) => ({
...rest,
// eslint-disable-next-line camelcase
img: utf ? '' : this.$store.state.instance.server + image_url,
img: imageUrl || '',
highlighted: index === this.highlighted
}))
} else {
return false
}
},
showPopup () {
return this.focused && this.suggestions && this.suggestions.length > 0
},
textAtCaret () {
return (this.wordAtCaret || {}).word || ''
},
wordAtCaret () {
if (this.value && this.caret) {
const word = Completion.wordAtPosition(this.value, this.caret - 1) || {}
return word
}
}
},
emoji () {
return this.$store.state.instance.emoji || []
mounted () {
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 () {
return this.$store.state.instance.customEmoji || []
unmounted () {
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: {
@ -54,27 +125,35 @@ const EmojiInput = {
this.$emit('input', newValue)
this.caret = 0
},
replaceEmoji (e) {
replaceText (e, suggestion) {
const len = this.suggestions.length || 0
if (this.textAtCaret === ':' || e.ctrlKey) { return }
if (len > 0) {
e.preventDefault()
const emoji = this.suggestions[this.highlighted]
const replacement = emoji.utf || (emoji.shortcode + ' ')
if (this.textAtCaret.length === 1) { return }
if (len > 0 || suggestion) {
const chosenSuggestion = suggestion || this.suggestions[this.highlighted]
const replacement = chosenSuggestion.replacement
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
this.caret = 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) {
const len = this.suggestions.length || 0
if (len > 0) {
e.preventDefault()
this.highlighted -= 1
if (this.highlighted < 0) {
this.highlighted = this.suggestions.length - 1
}
e.preventDefault()
} else {
this.highlighted = 0
}
@ -82,24 +161,88 @@ const EmojiInput = {
cycleForward (e) {
const len = this.suggestions.length || 0
if (len > 0) {
if (e.shiftKey) { return }
e.preventDefault()
this.highlighted += 1
if (this.highlighted >= len) {
this.highlighted = 0
}
e.preventDefault()
} else {
this.highlighted = 0
}
},
onKeydown (e) {
e.stopPropagation()
onTransition (e) {
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) {
this.setCaret(e)
this.$emit('input', e.target.value)
},
onCompositionUpdate (e) {
this.setCaret(e)
this.resize()
this.$emit('input', e.target.value)
},
setCaret ({ target: { 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>
<div class="emoji-input">
<input
v-if="type !== 'textarea'"
:class="classname"
:type="type"
:value="value"
:placeholder="placeholder"
@input="onInput"
@click="setCaret"
@keyup="setCaret"
@keydown="onKeydown"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceEmoji"
/>
<textarea
v-else
:class="classname"
:value="value"
:placeholder="placeholder"
@input="onInput"
@click="setCaret"
@keyup="setCaret"
@keydown="onKeydown"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceEmoji"
></textarea>
<div class="autocomplete-panel" v-if="suggestions">
<slot />
<div
ref="panel"
class="autocomplete-panel"
:class="{ hide: !showPopup }"
>
<div class="autocomplete-panel-body">
<div
v-for="(emoji, index) in suggestions"
v-for="(suggestion, index) in suggestions"
:key="index"
@click="replace(emoji.utf || (emoji.shortcode + ' '))"
class="autocomplete-item"
:class="{ highlighted: emoji.highlighted }"
:class="{ highlighted: suggestion.highlighted }"
@click.stop.prevent="onClick($event, suggestion)"
>
<span v-if="emoji.img">
<img :src="emoji.img" />
<span class="image">
<img
v-if="suggestion.img"
:src="suggestion.img"
>
<span v-else>{{ suggestion.replacement }}</span>
</span>
<span v-else>{{emoji.utf}}</span>
<span>{{emoji.shortcode}}</span>
<div class="label">
<span class="displayText">{{ suggestion.displayText }}</span>
<span class="detailText">{{ suggestion.detailText }}</span>
</div>
</div>
</div>
</div>
@ -57,8 +37,81 @@
@import '../../_variables.scss';
.emoji-input {
.form-control {
width: 100%;
display: flex;
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>

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,10 +1,25 @@
<template>
<div class="import-export-container">
<slot name="before" />
<button class="btn" @click="exportData">{{ exportLabel }}</button>
<button class="btn" @click="importData">{{ importLabel }}</button>
<button
class="btn"
@click="exportData"
>
{{ exportLabel }}
</button>
<button
class="btn"
@click="importData"
>
{{ importLabel }}
</button>
<slot name="afterButtons" />
<p v-if="importFailed" class="alert error">{{ importFailedText }}</p>
<p
v-if="importFailed"
class="alert error"
>
{{ importFailedText }}
</p>
<slot name="afterError" />
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,10 +8,18 @@
</div>
<div class="panel-body features-panel">
<ul>
<li v-if="chat">{{$t('features_panel.chat')}}</li>
<li v-if="gopher">{{$t('features_panel.gopher')}}</li>
<li v-if="whoToFollow">{{$t('features_panel.who_to_follow')}}</li>
<li v-if="mediaProxy">{{$t('features_panel.media_proxy')}}</li>
<li v-if="chat">
{{ $t('features_panel.chat') }}
</li>
<li v-if="gopher">
{{ $t('features_panel.gopher') }}
</li>
<li v-if="whoToFollow">
{{ $t('features_panel.who_to_follow') }}
</li>
<li v-if="mediaProxy">
{{ $t('features_panel.media_proxy') }}
</li>
<li>{{ $t('features_panel.scope_options') }}</li>
<li>{{ $t('features_panel.text_limit') }} = {{ textlimit }}</li>
</ul>

View File

@ -1,11 +1,17 @@
<template>
<basic-user-card :user="user">
<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') }}
</span>
<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" />
</div>
</template>
@ -13,9 +19,9 @@
<button
v-if="!user.following"
class="btn btn-default follow-card-follow-button"
@click="followUser"
:disabled="inProgress"
:title="requestSent ? $t('user_card.follow_again') : ''"
@click="followUser"
>
<template v-if="inProgress">
{{ $t('user_card.follow_progress') }}
@ -27,7 +33,12 @@
{{ $t('user_card.follow') }}
</template>
</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">
{{ $t('user_card.follow_progress') }}
</template>

View File

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

View File

@ -4,7 +4,12 @@
{{ $t('nav.friend_requests') }}
</div>
<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>
</template>

View File

@ -1,23 +1,43 @@
<template>
<div class="font-control style-control" :class="{ custom: isCustom }">
<label :for="preset === 'custom' ? name : name + '-font-switcher'" class="label">
<div
class="font-control style-control"
:class="{ custom: isCustom }"
>
<label
:for="preset === 'custom' ? name : name + '-font-switcher'"
class="label"
>
{{ label }}
</label>
<input
v-if="typeof fallback !== 'undefined'"
:id="name + '-o'"
class="opt exlcude-disabled"
type="checkbox"
:id="name + '-o'"
:checked="present"
@input="$emit('input', typeof value === 'undefined' ? fallback : undefined)">
<label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label>
<label :for="name + '-font-switcher'" class="select" :disabled="!present">
<select
@input="$emit('input', typeof value === 'undefined' ? fallback : undefined)"
>
<label
v-if="typeof fallback !== 'undefined'"
class="opt-l"
:for="name + '-o'"
/>
<label
:for="name + '-font-switcher'"
class="select"
:disabled="!present"
>
<select
:id="name + '-font-switcher'"
v-model="preset"
:disabled="!present"
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>
</select>
@ -25,10 +45,11 @@
</label>
<input
v-if="isCustom"
:id="name"
v-model="family"
class="custom-font"
type="text"
:id="name"
v-model="family">
>
</div>
</template>

View File

@ -1,5 +1,9 @@
<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>
<script src="./friends_timeline.js"></script>

View File

@ -1,13 +1,22 @@
<template>
<div ref="galleryContainer" style="width: 100%;">
<div class="gallery-row" v-for="row in rows" :style="rowHeight(row.length)" :class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }">
<div
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
v-for="attachment in row"
:setMedia="setMedia"
:key="attachment.id"
:set-media="setMedia"
:nsfw="nsfw"
:attachment="attachment"
:allowPlay="false"
:key="attachment.id"
:allow-play="false"
/>
</div>
</div>
@ -28,7 +37,9 @@
flex-grow: 1;
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;
flex-grow: 1;
height: 100%;
@ -50,13 +61,17 @@
}
&.contain-fit {
img, video {
img,
video,
canvas {
object-fit: contain;
}
}
&.cover-fit {
img, video {
img,
video,
canvas {
object-fit: cover;
}
}

View File

@ -2,20 +2,57 @@
<div class="image-cropper">
<div v-if="dataUrl">
<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 class="image-cropper-buttons-wrapper">
<button class="btn" type="button" :disabled="submitting" @click="submit()" v-text="saveText"></button>
<button class="btn" type="button" :disabled="submitting" @click="destroy" v-text="cancelText"></button>
<button class="btn" type="button" :disabled="submitting" @click="submit(false)" v-text="saveWithoutCroppingText"></button>
<i class="icon-spin4 animate-spin" v-if="submitting"></i>
<button
class="btn"
type="button"
: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 class="alert error" v-if="submitError">
<div
v-if="submitError"
class="alert error"
>
{{ submitErrorMsg }}
<i class="button-icon icon-cancel" @click="clearError"></i>
<i
class="button-icon icon-cancel"
@click="clearError"
/>
</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>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,9 +3,19 @@
<label for="interface-language-switcher">
{{ $t('settings.interfaceLanguage') }}
</label>
<label for="interface-language-switcher" class='select'>
<select id="interface-language-switcher" v-model="language">
<option v-for="(langCode, i) in languageCodes" :value="langCode">
<label
for="interface-language-switcher"
class="select"
>
<select
id="interface-language-switcher"
v-model="language"
>
<option
v-for="(langCode, i) in languageCodes"
:key="langCode"
:value="langCode"
>
{{ languageNames[i] }}
</option>
</select>

View File

@ -5,6 +5,11 @@ const LinkPreview = {
'size',
'nsfw'
],
data () {
return {
imageLoaded: false
}
},
computed: {
useImage () {
// Currently BE shoudn't give cards if tagged NSFW, this is a bit paranoid
@ -15,6 +20,15 @@ const LinkPreview = {
useDescription () {
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>
<div>
<a class="link-preview-card" :href="card.url" target="_blank" rel="noopener">
<div class="card-image" :class="{ 'small-image': size === 'small' }" v-if="useImage">
<img :src="card.image"></img>
<a
class="link-preview-card"
: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 class="card-content">
<span class="card-host faint">{{ card.provider_name }}</span>
<h4 class="card-title">{{ card.title }}</h4>
<p class="card-description" v-if="useDescription">{{ card.description }}</p>
<p
v-if="useDescription"
class="card-description"
>{{ card.description }}</p>
</div>
</a>
</div>

View File

@ -1,9 +1,19 @@
<template>
<div class="list">
<div v-for="item in items" class="list-item" :key="getKey(item)">
<slot name="item" :item="item" />
<div
v-for="item in items"
:key="getKey(item)"
class="list-item"
>
<slot
name="item"
:item="item"
/>
</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" />
</div>
</div>

View File

@ -26,9 +26,10 @@ const LoginForm = {
this.isTokenAuth ? this.submitToken() : this.submitPassword()
},
submitToken () {
const { clientId } = this.oauth
const { clientId, clientSecret } = this.oauth
const data = {
clientId,
clientSecret,
instance: this.instance.server,
commit: this.$store.commit
}

View File

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

View File

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

View File

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

View File

@ -1,5 +1,9 @@
<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>
<script src="./mentions.js"></script>

View File

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

View File

@ -7,26 +7,42 @@
</div>
<div class="panel-body">
<form class='login-form' @submit.prevent='submit'>
<div class='form-group'>
<label for='code'>
<form
class="login-form"
@submit.prevent="submit"
>
<div class="form-group">
<label for="code">
{{ $t('login.authentication_code') }}
</label>
<input v-model='code' class='form-control' id='code'>
<input
id="code"
v-model="code"
class="form-control"
>
</div>
<div class='form-group'>
<div class='login-bottom'>
<div class="form-group">
<div class="login-bottom">
<div>
<a href="#" @click.prevent="requireRecovery">
<a
href="#"
@click.prevent="requireRecovery"
>
{{ $t('login.enter_recovery_code') }}
</a>
<br />
<a href="#" @click.prevent="abortMFA">
<br>
<a
href="#"
@click.prevent="abortMFA"
>
{{ $t('general.cancel') }}
</a>
</div>
<button type='submit' class='btn btn-default'>
<button
type="submit"
class="btn btn-default"
>
{{ $t('general.verify') }}
</button>
</div>
@ -34,10 +50,16 @@
</form>
</div>
<div v-if="error" class='form-group'>
<div class='alert error'>
<div
v-if="error"
class="form-group"
>
<div class="alert error">
{{ error }}
<i class="button-icon icon-cancel" @click="clearError"></i>
<i
class="button-icon icon-cancel"
@click="clearError"
/>
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import DialogModal from '../dialog_modal/dialog_modal.vue'
import Popper from 'vue-popperjs/src/component/popper.js.vue'
const FORCE_NSFW = 'mrf_tag:media-force-nsfw'
const STRIP_MEDIA = 'mrf_tag:media-strip'
@ -29,8 +28,7 @@ const ModerationTools = {
}
},
components: {
DialogModal,
Popper
DialogModal
},
computed: {
tagsSet () {
@ -41,9 +39,6 @@ const ModerationTools = {
}
},
methods: {
toggleMenu () {
this.showDropDown = !this.showDropDown
},
hasTag (tagName) {
return this.tagsSet.has(tagName)
},

View File

@ -1,79 +1,155 @@
<template>
<div class='block' style='position: relative'>
<Popper
<div>
<v-popover
trigger="click"
@hide='showDropDown = false'
append-to-body
:options="{
placement: 'bottom-end',
modifiers: {
arrow: { enabled: true },
offset: { offset: '0, 5px' },
}
}">
<div class="popper-wrapper">
class="moderation-tools-popover"
:container="false"
placement="bottom-end"
:offset="5"
@show="showDropDown = true"
@hide="showDropDown = false"
>
<div slot="popover">
<div class="dropdown-menu">
<span v-if='user.is_local'>
<button class="dropdown-item" @click='toggleRight("admin")'>
<span v-if="user.is_local">
<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') }}
</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') }}
</button>
<div role="separator" class="dropdown-divider"></div>
<div
role="separator"
class="dropdown-divider"
/>
</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') }}
</button>
<button class="dropdown-item" @click='deleteUserDialog(true)'>
<button
class="dropdown-item"
@click="deleteUserDialog(true)"
>
{{ $t('user_card.admin_menu.delete_account') }}
</button>
<div role="separator" class="dropdown-divider" v-if='hasTagPolicy'></div>
<span v-if='hasTagPolicy'>
<button class="dropdown-item" @click='toggleTag(tags.FORCE_NSFW)'>
<div
v-if="hasTagPolicy"
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') }}
<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 class="dropdown-item" @click='toggleTag(tags.STRIP_MEDIA)'>
<button
class="dropdown-item"
@click="toggleTag(tags.STRIP_MEDIA)"
>
{{ $t('user_card.admin_menu.strip_media') }}
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }"></span>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }"
/>
</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') }}
<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 class="dropdown-item" @click='toggleTag(tags.SANDBOX)'>
<button
class="dropdown-item"
@click="toggleTag(tags.SANDBOX)"
>
{{ $t('user_card.admin_menu.sandbox') }}
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }"></span>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }"
/>
</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') }}
<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 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') }}
<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 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') }}
<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>
</span>
</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') }}
</button>
</Popper>
</v-popover>
<portal to="modal">
<DialogModal v-if="showDeleteUserDialog" :onCancel='deleteUserDialog.bind(this, false)'>
<template slot="header">{{ $t('user_card.admin_menu.delete_user') }}</template>
<DialogModal
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>
<template slot="footer">
<button class="btn btn-default" @click='deleteUserDialog(false)'>
<button
class="btn btn-default"
@click="deleteUserDialog(false)"
>
{{ $t('general.cancel') }}
</button>
<button class="btn btn-default danger" @click='deleteUser()'>
<button
class="btn btn-default danger"
@click="deleteUser()"
>
{{ $t('user_card.admin_menu.delete_user') }}
</button>
</template>
@ -107,4 +183,11 @@
}
}
.moderation-tools-popover {
height: 100%;
.trigger {
display: flex !important;
height: 100%;
}
}
</style>

View File

@ -1,7 +1,12 @@
<template>
<basic-user-card :user="user">
<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">
{{ $t('user_card.unmute_progress') }}
</template>
@ -9,7 +14,12 @@
{{ $t('user_card.unmute') }}
</template>
</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">
{{ $t('user_card.mute_progress') }}
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -2,9 +2,11 @@ import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji-input/emoji-input.vue'
import 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 Completion from '../../services/completion/completion.js'
import { take, filter, reject, map, uniqBy } from 'lodash'
import { reject, map, uniqBy } from 'lodash'
import suggestor from '../emoji-input/suggestor.js'
const buildMentionsString = ({ user, attentions }, currentUser) => {
let allAttentions = [...attentions]
@ -31,8 +33,10 @@ const PostStatusForm = {
],
components: {
MediaUpload,
ScopeSelector,
EmojiInput
EmojiInput,
PollForm,
StickerPicker,
ScopeSelector
},
mounted () {
this.resize(this.$refs.textarea)
@ -56,7 +60,7 @@ const PostStatusForm = {
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.$store.state.users.currentUser.default_scope
@ -75,57 +79,16 @@ const PostStatusForm = {
status: statusText,
nsfw: false,
files: [],
poll: {},
visibility: scope,
contentType
},
caret: 0
caret: 0,
pollFormVisible: false,
stickerPickerVisible: false
}
},
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 () {
return this.$store.state.users.users
},
@ -138,6 +101,24 @@ const PostStatusForm = {
: this.$store.state.config.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 () {
return this.$store.state.instance.emoji || []
},
@ -174,71 +155,32 @@ const PostStatusForm = {
return true
}
},
formattingOptionsEnabled () {
return this.$store.state.instance.formattingOptionsEnabled
},
postFormats () {
return this.$store.state.instance.postFormats || []
},
safeDMEnabled () {
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 () {
return this.$store.state.config.hideScopeNotice
},
pollContentError () {
return this.pollFormVisible &&
this.newStatus.poll &&
this.newStatus.poll.error
}
},
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) {
if (this.posting) { 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
statusPoster.postStatus({
status: newStatus.status,
@ -261,7 +209,8 @@ const PostStatusForm = {
media: newStatus.files,
store: this.$store,
inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType
contentType: newStatus.contentType,
poll
}).then((data) => {
if (!data.error) {
this.newStatus = {
@ -269,9 +218,13 @@ const PostStatusForm = {
spoilerText: '',
files: [],
visibility: newStatus.visibility,
contentType: newStatus.contentType
contentType: newStatus.contentType,
poll: {}
}
this.pollFormVisible = false
this.stickerPickerVisible = false
this.$refs.mediaUpload.clearFile()
this.clearPollForm()
this.$emit('posted')
let el = this.$el.querySelector('textarea')
el.style.height = 'auto'
@ -286,6 +239,7 @@ const PostStatusForm = {
addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo)
this.enableSubmit()
this.stickerPickerVisible = false
},
removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo)
@ -327,8 +281,11 @@ const PostStatusForm = {
resize (e) {
const target = e.target || e
if (!(target instanceof window.Element)) { return }
const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) +
Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1))
const topPaddingStr = window.getComputedStyle(target)['padding-top']
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
target.style.height = 'auto'
target.style.height = `${target.scrollHeight - vertPadding}px`
@ -342,6 +299,25 @@ const PostStatusForm = {
changeVis (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 () {
this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true })
}

View File

@ -1,127 +1,268 @@
<template>
<div class="post-status-form">
<form @submit.prevent="postStatus(newStatus)">
<form
autocomplete="off"
@submit.prevent="postStatus(newStatus)"
>
<div class="form-group">
<i18n
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'"
path="post_status.account_not_locked_warning"
tag="p"
class="visibility-notice">
<router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link>
class="visibility-notice"
>
<router-link :to="{ name: 'user-settings' }">
{{ $t('post_status.account_not_locked_warning_link') }}
</router-link>
</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>
<a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss">
<i class='icon-cancel'></i>
<a
class="button-icon dismiss"
@click.prevent="dismissScopeNotice()"
>
<i class="icon-cancel" />
</a>
</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>
<a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss">
<i class='icon-cancel'></i>
<a
class="button-icon dismiss"
@click.prevent="dismissScopeNotice()"
>
<i class="icon-cancel" />
</a>
</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>
<a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss">
<i class='icon-cancel'></i>
<a
class="button-icon dismiss"
@click.prevent="dismissScopeNotice()"
>
<i class="icon-cancel" />
</a>
</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-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p>
<EmojiInput
v-if="newStatus.spoilerText || alwaysShowSubject"
v-model="newStatus.spoilerText"
:suggest="emojiSuggestor"
class="form-control"
>
<input
v-model="newStatus.spoilerText"
type="text"
:placeholder="$t('post_status.content_warning')"
v-model="newStatus.spoilerText"
classname="form-control"
/>
class="form-post-subject"
>
</EmojiInput>
<EmojiInput
v-model="newStatus.status"
:suggest="emojiUserSuggestor"
class="form-control main-input"
>
<textarea
ref="textarea"
@click="setCaret"
@keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control"
@keydown="onKeydown"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceCandidate"
v-model="newStatus.status"
:placeholder="$t('post_status.default')"
rows="1"
:disabled="posting"
class="form-post-body"
@keydown.meta.enter="postStatus(newStatus)"
@keyup.ctrl.enter="postStatus(newStatus)"
@drop="fileDrop"
@dragover.prevent="fileDrag"
@input="resize"
@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="text-format" v-if="formattingOptionsEnabled">
<label for="post-content-type" class="select">
<select id="post-content-type" v-model="newStatus.contentType" class="form-control">
<option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat">
<scope-selector
:show-all="showAllScopes"
:user-default="userDefaultScope"
:original-scope="copyMessageScope"
: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>
</select>
<i class="icon-down-open"></i>
<i class="icon-down-open" />
</label>
</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
v-for="(candidate, index) in candidates"
:key="index"
@click="replace(candidate.utf || (candidate.screen_name + ' '))"
class="autocomplete-item"
:class="{ highlighted: candidate.highlighted }"
v-if="postFormats.length === 1 && postFormats[0] !== 'text/plain'"
class="text-format"
>
<span v-if="candidate.img"><img :src="candidate.img" /></span>
<span v-else>{{candidate.utf}}</span>
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
<span class="only-format">
{{ $t(`post_status.content_type["${postFormats[0]}"]`) }}
</span>
</div>
</div>
</div>
<div class='form-bottom'>
<media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload>
<p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p>
<p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p>
<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>
<poll-form
v-if="pollsAvailable"
ref="pollForm"
:visible="pollFormVisible"
@update-poll="setPoll"
/>
<div class="form-bottom">
<div class="form-bottom-left">
<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 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 }}
<i class="button-icon icon-cancel" @click="clearError"></i>
<i
class="button-icon icon-cancel"
@click="clearError"
/>
</div>
<div class="attachments">
<div class="media-upload-wrapper" v-for="file in newStatus.files">
<i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i>
<div
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">
<img class="thumbnail media-upload" :src="file.url" v-if="type(file) === 'image'"></img>
<video v-if="type(file) === 'video'" :src="file.url" controls></video>
<audio v-if="type(file) === 'audio'" :src="file.url" controls></audio>
<a v-if="type(file) === 'unknown'" :href="file.url">{{file.url}}</a>
<img
v-if="type(file) === 'image'"
class="thumbnail media-upload"
: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 class="upload_settings" v-if="newStatus.files.length > 0">
<input type="checkbox" id="filesSensitive" v-model="newStatus.nsfw">
<div
v-if="newStatus.files.length > 0"
class="upload_settings"
>
<input
id="filesSensitive"
v-model="newStatus.nsfw"
type="checkbox"
>
<label for="filesSensitive">{{ $t('post_status.attachments_sensitive') }}</label>
</div>
</form>
<sticker-picker
v-if="stickerPickerVisible"
ref="stickerPicker"
@uploaded="addMediaFile"
/>
</div>
</template>
@ -151,7 +292,6 @@
.visibility-tray {
display: flex;
justify-content: space-between;
flex-direction: row-reverse;
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 {
text-align: center;
}
@ -233,7 +404,6 @@
}
}
.btn {
cursor: pointer;
}
@ -263,19 +433,38 @@
min-height: 1px;
}
form textarea.form-control {
.form-post-body {
height: 16px; // Only affects the empty-height
line-height: 16px;
resize: none;
overflow: hidden;
transition: min-height 200ms 100ms;
padding-bottom: 1.75em;
min-height: 1px;
box-sizing: content-box;
}
form textarea.form-control:focus {
.form-post-body:focus {
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 {
cursor: pointer;
}

View File

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

View File

@ -1,5 +1,9 @@
<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>
<script src="./public_and_external_timeline.js"></script>

View File

@ -1,5 +1,9 @@
<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>
<script src="./public_timeline.js"></script>

View File

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

View File

@ -4,14 +4,32 @@
{{ $t('registration.registration') }}
</div>
<div class="panel-body">
<form v-on:submit.prevent='submit(user)' class='registration-form'>
<div class='container'>
<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 :disabled="isPending" v-model.trim='$v.user.username.$model' class='form-control' id='sign-up-username' :placeholder="$t('registration.username_placeholder')">
<form
class="registration-form"
@submit.prevent="submit(user)"
>
<div class="container">
<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 class="form-error" v-if="$v.user.username.$dirty">
<div
v-if="$v.user.username.$dirty"
class="form-error"
>
<ul>
<li v-if="!$v.user.username.required">
<span>{{ $t('registration.validations.username_required') }}</span>
@ -19,11 +37,26 @@
</ul>
</div>
<div class='form-group' :class="{ 'form-group--error': $v.user.fullname.$error }">
<label class='form--label' for='sign-up-fullname'>{{$t('registration.fullname')}}</label>
<input :disabled="isPending" v-model.trim='$v.user.fullname.$model' class='form-control' id='sign-up-fullname' :placeholder="$t('registration.fullname_placeholder')">
<div
class="form-group"
: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 class="form-error" v-if="$v.user.fullname.$dirty">
<div
v-if="$v.user.fullname.$dirty"
class="form-error"
>
<ul>
<li v-if="!$v.user.fullname.required">
<span>{{ $t('registration.validations.fullname_required') }}</span>
@ -31,11 +64,26 @@
</ul>
</div>
<div class='form-group' :class="{ 'form-group--error': $v.user.email.$error }">
<label class='form--label' for='email'>{{$t('registration.email')}}</label>
<input :disabled="isPending" v-model='$v.user.email.$model' class='form-control' id='email' type="email">
<div
class="form-group"
: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 class="form-error" v-if="$v.user.email.$dirty">
<div
v-if="$v.user.email.$dirty"
class="form-error"
>
<ul>
<li v-if="!$v.user.email.required">
<span>{{ $t('registration.validations.email_required') }}</span>
@ -43,16 +91,40 @@
</ul>
</div>
<div class='form-group'>
<label class='form--label' for='bio'>{{$t('registration.bio')}} ({{$t('general.optional')}})</label>
<textarea :disabled="isPending" v-model='user.bio' class='form-control' id='bio' :placeholder="bioPlaceholder"></textarea>
<div class="form-group">
<label
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 class='form-group' :class="{ 'form-group--error': $v.user.password.$error }">
<label class='form--label' for='sign-up-password'>{{$t('login.password')}}</label>
<input :disabled="isPending" v-model='user.password' class='form-control' id='sign-up-password' type='password'>
<div
class="form-group"
: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 class="form-error" v-if="$v.user.password.$dirty">
<div
v-if="$v.user.password.$dirty"
class="form-error"
>
<ul>
<li v-if="!$v.user.password.required">
<span>{{ $t('registration.validations.password_required') }}</span>
@ -60,11 +132,26 @@
</ul>
</div>
<div class='form-group' :class="{ 'form-group--error': $v.user.confirm.$error }">
<label class='form--label' for='sign-up-password-confirmation'>{{$t('registration.password_confirm')}}</label>
<input :disabled="isPending" v-model='user.confirm' class='form-control' id='sign-up-password-confirmation' type='password'>
<div
class="form-group"
: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 class="form-error" v-if="$v.user.confirm.$dirty">
<div
v-if="$v.user.confirm.$dirty"
class="form-error"
>
<ul>
<li v-if="!$v.user.confirm.required">
<span>{{ $t('registration.validations.password_confirmation_required') }}</span>
@ -75,35 +162,75 @@
</ul>
</div>
<div class="form-group" id="captcha-group" v-if="captcha.type != 'none'">
<label class='form--label' for='captcha-label'>{{$t('captcha')}}</label>
<div
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'">
<img v-bind:src="captcha.url" v-on:click="setCaptcha">
<img
:src="captcha.url"
@click="setCaptcha"
>
<sub>{{ $t('registration.new_captcha') }}</sub>
<input :disabled="isPending"
v-model='captcha.solution'
class='form-control' id='captcha-answer' type='text' autocomplete="off">
<input
id="captcha-answer"
v-model="captcha.solution"
:disabled="isPending"
class="form-control"
type="text"
autocomplete="off"
>
</template>
</div>
<div class='form-group' v-if='token' >
<label for='token'>{{$t('registration.token')}}</label>
<input disabled='true' v-model='token' class='form-control' id='token' type='text'>
<div
v-if="token"
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 class='form-group'>
<button :disabled="isPending" type='submit' class='btn btn-default'>{{$t('general.submit')}}</button>
<div class="form-group">
<button
:disabled="isPending"
type="submit"
class="btn btn-default"
>
{{ $t('general.submit') }}
</button>
</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 v-if="serverValidationErrors.length" class='form-group'>
<div class='alert error'>
<span v-for="error in serverValidationErrors">{{error}}</span>
<div
v-if="serverValidationErrors.length"
class="form-group"
>
<div class="alert error">
<span
v-for="error in serverValidationErrors"
:key="error"
>{{ error }}</span>
</div>
</div>
</form>
@ -141,6 +268,7 @@ $validations-cRed: #f04124;
textarea {
min-height: 100px;
resize: vertical;
}
.form-group {

View File

@ -1,9 +1,23 @@
<template>
<div class="remote-follow">
<form method="POST" :action='subscribeUrl'>
<input type="hidden" name="nickname" :value="user.screen_name">
<input type="hidden" name="profile" value="">
<button click="submit" class="remote-button">
<form
method="POST"
:action="subscribeUrl"
>
<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') }}
</button>
</form>

View File

@ -1,16 +1,29 @@
<template>
<div v-if="loggedIn">
<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>
<span v-if='!hidePostStatsLocal && status.repeat_num > 0'>{{status.repeat_num}}</span>
<i
: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 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>
</div>
<div v-else-if="!loggedIn">
<i :class='classes' class='button-icon icon-retweet' :title="$t('tool_tip.repeat')"></i>
<span v-if='!hidePostStatsLocal && status.repeat_num > 0'>{{status.repeat_num}}</span>
<i
: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>
</template>

View File

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

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>

View File

@ -0,0 +1,32 @@
const SearchBar = {
data: () => ({
searchTerm: undefined,
hidden: true,
error: false,
loading: false
}),
watch: {
'$route': function (route) {
if (route.name === 'search') {
this.searchTerm = route.query.query
}
}
},
methods: {
find (searchTerm) {
this.$router.push({ name: 'search', query: { query: searchTerm } })
this.$refs.searchInput.focus()
},
toggleHidden () {
this.hidden = !this.hidden
this.$emit('toggled', this.hidden)
this.$nextTick(() => {
if (!this.hidden) {
this.$refs.searchInput.focus()
}
})
}
}
}
export default SearchBar

View File

@ -0,0 +1,73 @@
<template>
<div>
<div class="search-bar-container">
<i
v-if="loading"
class="icon-spin4 finder-icon animate-spin-slow"
/>
<a
v-if="hidden"
href="#"
:title="$t('nav.search')"
><i
class="button-icon icon-search"
@click.prevent.stop="toggleHidden"
/></a>
<template v-else>
<input
id="search-bar-input"
ref="searchInput"
v-model="searchTerm"
class="search-bar-input"
:placeholder="$t('nav.search')"
type="text"
@keyup.enter="find(searchTerm)"
>
<button
class="btn search-button"
@click="find(searchTerm)"
>
<i class="icon-search" />
</button>
<i
class="button-icon icon-cancel"
@click.prevent.stop="toggleHidden"
/>
</template>
</div>
</div>
</template>
<script src="./search_bar.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.search-bar-container {
max-width: 100%;
display: inline-flex;
align-items: baseline;
vertical-align: baseline;
justify-content: flex-end;
.search-bar-input,
.search-button {
height: 29px;
}
.search-bar-input {
// TODO: do this properly without a rough guesstimate of 2 icons + paddings
max-width: calc(100% - 30px - 30px - 20px);
}
.search-button {
margin-left: .5em;
margin-right: .5em;
}
.icon-cancel {
cursor: pointer;
}
}
</style>

View File

@ -1,23 +1,52 @@
<template>
<div class="selectable-list">
<div class="selectable-list-header" v-if="items.length > 0">
<div
v-if="items.length > 0"
class="selectable-list-header"
>
<div class="selectable-list-checkbox-wrapper">
<Checkbox :checked="allSelected" @change="toggleAll" :indeterminate="someSelected">{{ $t('selectable_list.select_all') }}</Checkbox>
<Checkbox
:checked="allSelected"
:indeterminate="someSelected"
@change="toggleAll"
>
{{ $t('selectable_list.select_all') }}
</Checkbox>
</div>
<div class="selectable-list-header-actions">
<slot name="header" :selected="filteredSelected" />
<slot
name="header"
:selected="filteredSelected"
/>
</div>
</div>
<List :items="items" :getKey="getKey">
<template slot="item" slot-scope="{item}">
<div class="selectable-list-item-inner" :class="{ 'selectable-list-item-selected-inner': isSelected(item) }">
<List
:items="items"
:get-key="getKey"
>
<template
slot="item"
slot-scope="{item}"
>
<div
class="selectable-list-item-inner"
:class="{ 'selectable-list-item-selected-inner': isSelected(item) }"
>
<div class="selectable-list-checkbox-wrapper">
<Checkbox :checked="isSelected(item)" @change="checked => toggle(checked, item)" />
<Checkbox
:checked="isSelected(item)"
@change="checked => toggle(checked, item)"
/>
</div>
<slot name="item" :item="item" />
<slot
name="item"
:item="item"
/>
</div>
</template>
<template slot="empty"><slot name="empty" /></template>
<template slot="empty">
<slot name="empty" />
</template>
</List>
</div>
</template>

View File

@ -7,11 +7,19 @@
<transition name="fade">
<template v-if="currentSaveStateNotice">
<div @click.prevent class="alert error" v-if="currentSaveStateNotice.error">
<div
v-if="currentSaveStateNotice.error"
class="alert error"
@click.prevent
>
{{ $t('settings.saving_err') }}
</div>
<div @click.prevent class="alert transparent" v-if="!currentSaveStateNotice.error">
<div
v-if="!currentSaveStateNotice.error"
class="alert transparent"
@click.prevent
>
{{ $t('settings.saving_ok') }}
</div>
</template>
@ -28,7 +36,11 @@
<interface-language-switcher />
</li>
<li v-if="instanceSpecificPanelPresent">
<input type="checkbox" id="hideISP" v-model="hideISPLocal">
<input
id="hideISP"
v-model="hideISPLocal"
type="checkbox"
>
<label for="hideISP">{{ $t('settings.hide_isp') }}</label>
</li>
</ul>
@ -37,29 +49,57 @@
<h2>{{ $t('nav.timeline') }}</h2>
<ul class="setting-list">
<li>
<input type="checkbox" id="hideMutedPosts" v-model="hideMutedPostsLocal">
<input
id="hideMutedPosts"
v-model="hideMutedPostsLocal"
type="checkbox"
>
<label for="hideMutedPosts">{{ $t('settings.hide_muted_posts') }} {{ $t('settings.instance_default', { value: hideMutedPostsDefault }) }}</label>
</li>
<li>
<input type="checkbox" id="collapseMessageWithSubject" v-model="collapseMessageWithSubjectLocal">
<input
id="collapseMessageWithSubject"
v-model="collapseMessageWithSubjectLocal"
type="checkbox"
>
<label for="collapseMessageWithSubject">{{ $t('settings.collapse_subject') }} {{ $t('settings.instance_default', { value: collapseMessageWithSubjectDefault }) }}</label>
</li>
<li>
<input type="checkbox" id="streaming" v-model="streamingLocal">
<input
id="streaming"
v-model="streamingLocal"
type="checkbox"
>
<label for="streaming">{{ $t('settings.streaming') }}</label>
<ul class="setting-list suboptions" :class="[{disabled: !streamingLocal}]">
<ul
class="setting-list suboptions"
:class="[{disabled: !streamingLocal}]"
>
<li>
<input :disabled="!streamingLocal" type="checkbox" id="pauseOnUnfocused" v-model="pauseOnUnfocusedLocal">
<input
id="pauseOnUnfocused"
v-model="pauseOnUnfocusedLocal"
:disabled="!streamingLocal"
type="checkbox"
>
<label for="pauseOnUnfocused">{{ $t('settings.pause_on_unfocused') }}</label>
</li>
</ul>
</li>
<li>
<input type="checkbox" id="autoload" v-model="autoLoadLocal">
<input
id="autoload"
v-model="autoLoadLocal"
type="checkbox"
>
<label for="autoload">{{ $t('settings.autoload') }}</label>
</li>
<li>
<input type="checkbox" id="hoverPreview" v-model="hoverPreviewLocal">
<input
id="hoverPreview"
v-model="hoverPreviewLocal"
type="checkbox"
>
<label for="hoverPreview">{{ $t('settings.reply_link_preview') }}</label>
</li>
</ul>
@ -69,13 +109,21 @@
<h2>{{ $t('settings.composing') }}</h2>
<ul class="setting-list">
<li>
<input type="checkbox" id="scopeCopy" v-model="scopeCopyLocal">
<input
id="scopeCopy"
v-model="scopeCopyLocal"
type="checkbox"
>
<label for="scopeCopy">
{{ $t('settings.scope_copy') }} {{ $t('settings.instance_default', { value: scopeCopyDefault }) }}
</label>
</li>
<li>
<input type="checkbox" id="subjectHide" v-model="alwaysShowSubjectInputLocal">
<input
id="subjectHide"
v-model="alwaysShowSubjectInputLocal"
type="checkbox"
>
<label for="subjectHide">
{{ $t('settings.subject_input_always_show') }} {{ $t('settings.instance_default', { value: alwaysShowSubjectInputDefault }) }}
</label>
@ -83,8 +131,14 @@
<li>
<div>
{{ $t('settings.subject_line_behavior') }}
<label for="subjectLineBehavior" class="select">
<select id="subjectLineBehavior" v-model="subjectLineBehaviorLocal">
<label
for="subjectLineBehavior"
class="select"
>
<select
id="subjectLineBehavior"
v-model="subjectLineBehaviorLocal"
>
<option value="email">
{{ $t('settings.subject_line_email') }}
{{ subjectLineBehaviorDefault == 'email' ? $t('settings.instance_default_simple') : '' }}
@ -102,12 +156,22 @@
</label>
</div>
</li>
<li>
<li v-if="postFormats.length > 0">
<div>
{{ $t('settings.post_status_content_type') }}
<label for="postContentType" class="select">
<select id="postContentType" v-model="postContentTypeLocal">
<option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat">
<label
for="postContentType"
class="select"
>
<select
id="postContentType"
v-model="postContentTypeLocal"
>
<option
v-for="postFormat in postFormats"
:key="postFormat"
:value="postFormat"
>
{{ $t(`post_status.content_type["${postFormat}"]`) }}
{{ postContentTypeDefault === postFormat ? $t('settings.instance_default_simple') : '' }}
</option>
@ -117,13 +181,21 @@
</div>
</li>
<li>
<input type="checkbox" id="minimalScopesMode" v-model="minimalScopesModeLocal">
<input
id="minimalScopesMode"
v-model="minimalScopesModeLocal"
type="checkbox"
>
<label for="minimalScopesMode">
{{ $t('settings.minimal_scopes_mode') }} {{ $t('settings.instance_default', { value: minimalScopesModeDefault }) }}
</label>
</li>
<li>
<input type="checkbox" id="autohideFloatingPostButton" v-model="autohideFloatingPostButtonLocal">
<input
id="autohideFloatingPostButton"
v-model="autohideFloatingPostButtonLocal"
type="checkbox"
>
<label for="autohideFloatingPostButton">{{ $t('settings.autohide_floating_post_button') }}</label>
</li>
</ul>
@ -133,54 +205,110 @@
<h2>{{ $t('settings.attachments') }}</h2>
<ul class="setting-list">
<li>
<input type="checkbox" id="hideAttachments" v-model="hideAttachmentsLocal">
<input
id="hideAttachments"
v-model="hideAttachmentsLocal"
type="checkbox"
>
<label for="hideAttachments">{{ $t('settings.hide_attachments_in_tl') }}</label>
</li>
<li>
<input type="checkbox" id="hideAttachmentsInConv" v-model="hideAttachmentsInConvLocal">
<input
id="hideAttachmentsInConv"
v-model="hideAttachmentsInConvLocal"
type="checkbox"
>
<label for="hideAttachmentsInConv">{{ $t('settings.hide_attachments_in_convo') }}</label>
</li>
<li>
<label for="maxThumbnails">{{ $t('settings.max_thumbnails') }}</label>
<input class="number-input" type="number" id="maxThumbnails" v-model.number="maxThumbnails" min="0" step="1">
<input
id="maxThumbnails"
v-model.number="maxThumbnails"
class="number-input"
type="number"
min="0"
step="1"
>
</li>
<li>
<input type="checkbox" id="hideNsfw" v-model="hideNsfwLocal">
<input
id="hideNsfw"
v-model="hideNsfwLocal"
type="checkbox"
>
<label for="hideNsfw">{{ $t('settings.nsfw_clickthrough') }}</label>
</li>
<ul class="setting-list suboptions">
<li>
<input :disabled="!hideNsfwLocal" type="checkbox" id="preloadImage" v-model="preloadImage">
<input
id="preloadImage"
v-model="preloadImage"
:disabled="!hideNsfwLocal"
type="checkbox"
>
<label for="preloadImage">{{ $t('settings.preload_images') }}</label>
</li>
<li>
<input :disabled="!hideNsfwLocal" type="checkbox" id="useOneClickNsfw" v-model="useOneClickNsfw">
<input
id="useOneClickNsfw"
v-model="useOneClickNsfw"
:disabled="!hideNsfwLocal"
type="checkbox"
>
<label for="useOneClickNsfw">{{ $t('settings.use_one_click_nsfw') }}</label>
</li>
</ul>
<li>
<input type="checkbox" id="stopGifs" v-model="stopGifs">
<input
id="stopGifs"
v-model="stopGifs"
type="checkbox"
>
<label for="stopGifs">{{ $t('settings.stop_gifs') }}</label>
</li>
<li>
<input type="checkbox" id="loopVideo" v-model="loopVideoLocal">
<input
id="loopVideo"
v-model="loopVideoLocal"
type="checkbox"
>
<label for="loopVideo">{{ $t('settings.loop_video') }}</label>
<ul class="setting-list suboptions" :class="[{disabled: !streamingLocal}]">
<ul
class="setting-list suboptions"
:class="[{disabled: !streamingLocal}]"
>
<li>
<input :disabled="!loopVideoLocal || !loopSilentAvailable" type="checkbox" id="loopVideoSilentOnly" v-model="loopVideoSilentOnlyLocal">
<input
id="loopVideoSilentOnly"
v-model="loopVideoSilentOnlyLocal"
:disabled="!loopVideoLocal || !loopSilentAvailable"
type="checkbox"
>
<label for="loopVideoSilentOnly">{{ $t('settings.loop_video_silent_only') }}</label>
<div v-if="!loopSilentAvailable" class="unavailable">
<div
v-if="!loopSilentAvailable"
class="unavailable"
>
<i class="icon-globe" />! {{ $t('settings.limited_availability') }}
</div>
</li>
</ul>
</li>
<li>
<input type="checkbox" id="playVideosInModal" v-model="playVideosInModal">
<input
id="playVideosInModal"
v-model="playVideosInModal"
type="checkbox"
>
<label for="playVideosInModal">{{ $t('settings.play_videos_in_modal') }}</label>
</li>
<li>
<input type="checkbox" id="useContainFit" v-model="useContainFit">
<input
id="useContainFit"
v-model="useContainFit"
type="checkbox"
>
<label for="useContainFit">{{ $t('settings.use_contain_fit') }}</label>
</li>
</ul>
@ -190,7 +318,11 @@
<h2>{{ $t('settings.notifications') }}</h2>
<ul class="setting-list">
<li>
<input type="checkbox" id="webPushNotifications" v-model="webPushNotificationsLocal">
<input
id="webPushNotifications"
v-model="webPushNotificationsLocal"
type="checkbox"
>
<label for="webPushNotifications">
{{ $t('settings.enable_web_push_notifications') }}
</label>
@ -201,7 +333,7 @@
<div :label="$t('settings.theme')">
<div class="setting-item">
<style-switcher></style-switcher>
<style-switcher />
</div>
</div>
@ -211,25 +343,41 @@
<span class="label">{{ $t('settings.notification_visibility') }}</span>
<ul class="option-list">
<li>
<input type="checkbox" id="notification-visibility-likes" v-model="notificationVisibilityLocal.likes">
<input
id="notification-visibility-likes"
v-model="notificationVisibilityLocal.likes"
type="checkbox"
>
<label for="notification-visibility-likes">
{{ $t('settings.notification_visibility_likes') }}
</label>
</li>
<li>
<input type="checkbox" id="notification-visibility-repeats" v-model="notificationVisibilityLocal.repeats">
<input
id="notification-visibility-repeats"
v-model="notificationVisibilityLocal.repeats"
type="checkbox"
>
<label for="notification-visibility-repeats">
{{ $t('settings.notification_visibility_repeats') }}
</label>
</li>
<li>
<input type="checkbox" id="notification-visibility-follows" v-model="notificationVisibilityLocal.follows">
<input
id="notification-visibility-follows"
v-model="notificationVisibilityLocal.follows"
type="checkbox"
>
<label for="notification-visibility-follows">
{{ $t('settings.notification_visibility_follows') }}
</label>
</li>
<li>
<input type="checkbox" id="notification-visibility-mentions" v-model="notificationVisibilityLocal.mentions">
<input
id="notification-visibility-mentions"
v-model="notificationVisibilityLocal.mentions"
type="checkbox"
>
<label for="notification-visibility-mentions">
{{ $t('settings.notification_visibility_mentions') }}
</label>
@ -238,9 +386,18 @@
</div>
<div>
{{ $t('settings.replies_in_timeline') }}
<label for="replyVisibility" class="select">
<select id="replyVisibility" v-model="replyVisibilityLocal">
<option value="all" selected>{{$t('settings.reply_visibility_all')}}</option>
<label
for="replyVisibility"
class="select"
>
<select
id="replyVisibility"
v-model="replyVisibilityLocal"
>
<option
value="all"
selected
>{{ $t('settings.reply_visibility_all') }}</option>
<option value="following">{{ $t('settings.reply_visibility_following') }}</option>
<option value="self">{{ $t('settings.reply_visibility_self') }}</option>
</select>
@ -248,13 +405,21 @@
</label>
</div>
<div>
<input type="checkbox" id="hidePostStats" v-model="hidePostStatsLocal">
<input
id="hidePostStats"
v-model="hidePostStatsLocal"
type="checkbox"
>
<label for="hidePostStats">
{{ $t('settings.hide_post_stats') }} {{ $t('settings.instance_default', { value: hidePostStatsDefault }) }}
</label>
</div>
<div>
<input type="checkbox" id="hideUserStats" v-model="hideUserStatsLocal">
<input
id="hideUserStats"
v-model="hideUserStatsLocal"
type="checkbox"
>
<label for="hideUserStats">
{{ $t('settings.hide_user_stats') }} {{ $t('settings.instance_default', { value: hideUserStatsDefault }) }}
</label>
@ -263,10 +428,17 @@
<div class="setting-item">
<div>
<p>{{ $t('settings.filtering_explanation') }}</p>
<textarea id="muteWords" v-model="muteWordsString"></textarea>
<textarea
id="muteWords"
v-model="muteWordsString"
/>
</div>
<div>
<input type="checkbox" id="hideFilteredStatuses" v-model="hideFilteredStatusesLocal">
<input
id="hideFilteredStatuses"
v-model="hideFilteredStatusesLocal"
type="checkbox"
>
<label for="hideFilteredStatuses">
{{ $t('settings.hide_filtered_statuses') }} {{ $t('settings.instance_default', { value: hideFilteredStatusesDefault }) }}
</label>
@ -280,7 +452,10 @@
<p>{{ $t('settings.version.backend_version') }}</p>
<ul class="option-list">
<li>
<a :href="backendVersionLink" target="_blank">{{backendVersion}}</a>
<a
:href="backendVersionLink"
target="_blank"
>{{ backendVersion }}</a>
</li>
</ul>
</li>
@ -288,7 +463,10 @@
<p>{{ $t('settings.version.frontend_version') }}</p>
<ul class="option-list">
<li>
<a :href="frontendVersionLink" target="_blank">{{frontendVersion}}</a>
<a
:href="frontendVersionLink"
target="_blank"
>{{ frontendVersion }}</a>
</li>
</ul>
</li>

View File

@ -1,12 +1,19 @@
<template>
<div class="shadow-control" :class="{ disabled: !present }">
<div
class="shadow-control"
:class="{ disabled: !present }"
>
<div class="shadow-preview-container">
<div :disabled="!present" class="y-shift-control">
<div
:disabled="!present"
class="y-shift-control"
>
<input
v-model="selected.y"
:disabled="!present"
class="input-number"
type="number">
type="number"
>
<div class="wrap">
<input
v-model="selected.y"
@ -14,18 +21,26 @@
class="input-range"
type="range"
max="20"
min="-20">
min="-20"
>
</div>
</div>
<div class="preview-window">
<div class="preview-block" :style="style"></div>
<div
class="preview-block"
:style="style"
/>
</div>
<div :disabled="!present" class="x-shift-control">
<div
:disabled="!present"
class="x-shift-control"
>
<input
v-model="selected.x"
:disabled="!present"
class="input-number"
type="number">
type="number"
>
<div class="wrap">
<input
v-model="selected.x"
@ -33,97 +48,155 @@
class="input-range"
type="range"
max="20"
min="-20">
min="-20"
>
</div>
</div>
</div>
<div class="shadow-tweak">
<div :disabled="usingFallback" class="id-control style-control">
<label for="shadow-switcher" class="select" :disabled="!ready || usingFallback">
<select
v-model="selectedId" class="shadow-switcher"
<div
:disabled="usingFallback"
class="id-control style-control"
>
<label
for="shadow-switcher"
class="select"
:disabled="!ready || usingFallback"
id="shadow-switcher">
<option v-for="(shadow, index) in cValue" :value="index">
>
<select
id="shadow-switcher"
v-model="selectedId"
class="shadow-switcher"
:disabled="!ready || usingFallback"
>
<option
v-for="(shadow, index) in cValue"
:key="index"
:value="index"
>
{{ $t('settings.style.shadows.shadow_id', { value: index }) }}
</option>
</select>
<i class="icon-down-open" />
</label>
<button class="btn btn-default" :disabled="!ready || !present" @click="del">
<button
class="btn btn-default"
:disabled="!ready || !present"
@click="del"
>
<i class="icon-cancel" />
</button>
<button class="btn btn-default" :disabled="!moveUpValid" @click="moveUp">
<button
class="btn btn-default"
:disabled="!moveUpValid"
@click="moveUp"
>
<i class="icon-up-open" />
</button>
<button class="btn btn-default" :disabled="!moveDnValid" @click="moveDn">
<button
class="btn btn-default"
:disabled="!moveDnValid"
@click="moveDn"
>
<i class="icon-down-open" />
</button>
<button class="btn btn-default" :disabled="usingFallback" @click="add">
<button
class="btn btn-default"
:disabled="usingFallback"
@click="add"
>
<i class="icon-plus" />
</button>
</div>
<div :disabled="!present" class="inset-control style-control">
<label for="inset" class="label">
<div
:disabled="!present"
class="inset-control style-control"
>
<label
for="inset"
class="label"
>
{{ $t('settings.style.shadows.inset') }}
</label>
<input
id="inset"
v-model="selected.inset"
:disabled="!present"
name="inset"
id="inset"
class="input-inset"
type="checkbox">
<label class="checkbox-label" for="inset"></label>
type="checkbox"
>
<label
class="checkbox-label"
for="inset"
/>
</div>
<div :disabled="!present" class="blur-control style-control">
<label for="spread" class="label">
<div
:disabled="!present"
class="blur-control style-control"
>
<label
for="spread"
class="label"
>
{{ $t('settings.style.shadows.blur') }}
</label>
<input
id="blur"
v-model="selected.blur"
:disabled="!present"
name="blur"
id="blur"
class="input-range"
type="range"
max="20"
min="0">
min="0"
>
<input
v-model="selected.blur"
:disabled="!present"
class="input-number"
type="number"
min="0">
min="0"
>
</div>
<div :disabled="!present" class="spread-control style-control">
<label for="spread" class="label">
<div
:disabled="!present"
class="spread-control style-control"
>
<label
for="spread"
class="label"
>
{{ $t('settings.style.shadows.spread') }}
</label>
<input
id="spread"
v-model="selected.spread"
:disabled="!present"
name="spread"
id="spread"
class="input-range"
type="range"
max="20"
min="-20">
min="-20"
>
<input
v-model="selected.spread"
:disabled="!present"
class="input-number"
type="number">
type="number"
>
</div>
<ColorInput
v-model="selected.color"
:disabled="!present"
:label="$t('settings.style.common.color')"
name="shadow"/>
name="shadow"
/>
<OpacityInput
v-model="selected.alpha"
:disabled="!present"/>
:disabled="!present"
/>
<p>
{{ $t('settings.style.shadows.hint') }}
</p>

View File

@ -1,63 +1,98 @@
<template>
<div class="side-drawer-container"
<div
class="side-drawer-container"
:class="{ 'side-drawer-container-closed': closed, 'side-drawer-container-open': !closed }"
>
<div class="side-drawer-darken" :class="{ 'side-drawer-darken-closed': closed}" />
<div class="side-drawer"
<div
class="side-drawer-darken"
:class="{ 'side-drawer-darken-closed': closed}"
/>
<div
class="side-drawer"
:class="{'side-drawer-closed': closed}"
@touchstart="touchStart"
@touchmove="touchMove"
>
<div class="side-drawer-heading" @click="toggleDrawer">
<UserCard :user="currentUser" :hideBio="true" v-if="currentUser"/>
<div class="side-drawer-logo-wrapper" v-else>
<img :src="logo"/>
<div
class="side-drawer-heading"
@click="toggleDrawer"
>
<UserCard
v-if="currentUser"
:user="currentUser"
:hide-bio="true"
/>
<div
v-else
class="side-drawer-logo-wrapper"
>
<img :src="logo">
<span>{{ sitename }}</span>
</div>
</div>
<ul>
<li v-if="!currentUser" @click="toggleDrawer">
<li
v-if="!currentUser"
@click="toggleDrawer"
>
<router-link :to="{ name: 'login' }">
{{ $t("login.login") }}
</router-link>
</li>
<li v-if="currentUser" @click="toggleDrawer">
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
{{ $t("nav.dms") }}
</router-link>
</li>
<li v-if="currentUser" @click="toggleDrawer">
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }">
{{ $t("nav.interactions") }}
</router-link>
</li>
</ul>
<ul>
<li v-if="currentUser" @click="toggleDrawer">
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link :to="{ name: 'friends' }">
{{ $t("nav.timeline") }}
</router-link>
</li>
<li v-if="currentUser && currentUser.locked" @click="toggleDrawer">
<router-link to='/friend-requests'>
<li
v-if="currentUser && currentUser.locked"
@click="toggleDrawer"
>
<router-link to="/friend-requests">
{{ $t("nav.friend_requests") }}
<span v-if='followRequestCount > 0' class="badge follow-request-count">
<span
v-if="followRequestCount > 0"
class="badge follow-request-count"
>
{{ followRequestCount }}
</span>
</router-link>
</li>
<li @click="toggleDrawer">
<router-link to='/main/public'>
<router-link to="/main/public">
{{ $t("nav.public_tl") }}
</router-link>
</li>
<li @click="toggleDrawer">
<router-link to='/main/all'>
<router-link to="/main/all">
{{ $t("nav.twkn") }}
</router-link>
</li>
<li v-if="currentUser && chat" @click="toggleDrawer">
<li
v-if="currentUser && chat"
@click="toggleDrawer"
>
<router-link :to="{ name: 'chat' }">
{{ $t("nav.chat") }}
</router-link>
@ -65,11 +100,14 @@
</ul>
<ul>
<li @click="toggleDrawer">
<router-link :to="{ name: 'user-search' }">
{{ $t("nav.user_search") }}
<router-link :to="{ name: 'search' }">
{{ $t("nav.search") }}
</router-link>
</li>
<li v-if="currentUser && suggestionsEnabled" @click="toggleDrawer">
<li
v-if="currentUser && suggestionsEnabled"
@click="toggleDrawer"
>
<router-link :to="{ name: 'who-to-follow' }">
{{ $t("nav.who_to_follow") }}
</router-link>
@ -84,17 +122,24 @@
{{ $t("nav.about") }}
</router-link>
</li>
<li v-if="currentUser" @click="toggleDrawer">
<a @click="doLogout" href="#">
<li
v-if="currentUser"
@click="toggleDrawer"
>
<a
href="#"
@click="doLogout"
>
{{ $t("login.logout") }}
</a>
</li>
</ul>
</div>
<div class="side-drawer-click-outside"
@click.stop.prevent="toggleDrawer"
<div
class="side-drawer-click-outside"
:class="{'side-drawer-click-outside-closed': closed}"
></div>
@click.stop.prevent="toggleDrawer"
/>
</div>
</template>

View File

@ -1,6 +1,7 @@
import Attachment from '../attachment/attachment.vue'
import FavoriteButton from '../favorite_button/favorite_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue'
import Poll from '../poll/poll.vue'
import ExtraButtons from '../extra_buttons/extra_buttons.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCard from '../user_card/user_card.vue'
@ -8,6 +9,7 @@ import UserAvatar from '../user_avatar/user_avatar.vue'
import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import fileType from 'src/services/file_type/file_type.service'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
@ -108,8 +110,9 @@ const Status = {
},
muteWordHits () {
const statusText = this.status.text.toLowerCase()
const statusSummary = this.status.summary.toLowerCase()
const hits = filter(this.muteWords, (muteWord) => {
return statusText.includes(muteWord.toLowerCase())
return statusText.includes(muteWord.toLowerCase()) || statusSummary.includes(muteWord.toLowerCase())
})
return hits
@ -171,12 +174,13 @@ const Status = {
if (this.status.type === 'retweet') {
return false
}
var checkFollowing = this.$store.state.config.replyVisibility === 'following'
const checkFollowing = this.$store.state.config.replyVisibility === 'following'
for (var i = 0; i < this.status.attentions.length; ++i) {
if (this.status.user.id === this.status.attentions[i].id) {
continue
}
if (checkFollowing && this.$store.getters.findUser(this.status.attentions[i].id).following) {
const taggedUser = this.$store.getters.findUser(this.status.attentions[i].id)
if (checkFollowing && taggedUser && taggedUser.following) {
return false
}
if (this.status.attentions[i].id === this.$store.state.users.currentUser.id) {
@ -219,7 +223,7 @@ const Status = {
? this.$store.state.instance.subjectLineBehavior
: this.$store.state.config.subjectLineBehavior
const startsWithRe = decodedSummary.match(/^re[: ]/i)
if (behavior !== 'noop' && startsWithRe || behavior === 'masto') {
if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') {
return decodedSummary
} else if (behavior === 'email') {
return 're: '.concat(decodedSummary)
@ -277,6 +281,11 @@ const Status = {
},
tags () {
return this.status.tags.filter(tagObj => tagObj.hasOwnProperty('name')).map(tagObj => tagObj.name).join(' ')
},
hidePostStats () {
return typeof this.$store.state.config.hidePostStats === 'undefined'
? this.$store.state.instance.hidePostStats
: this.$store.state.config.hidePostStats
}
},
components: {
@ -285,11 +294,13 @@ const Status = {
RetweetButton,
ExtraButtons,
PostStatusForm,
Poll,
UserCard,
UserAvatar,
Gallery,
LinkPreview,
AvatarList
AvatarList,
Timeago
},
methods: {
visibilityIcon (visibility) {
@ -311,11 +322,8 @@ const Status = {
this.error = undefined
},
linkClicked (event) {
let { target } = event
if (target.tagName === 'SPAN') {
target = target.parentNode
}
if (target.tagName === 'A') {
const target = event.target.closest('.status-content a')
if (target) {
if (target.className.match(/mention/)) {
const href = target.href
const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href))
@ -327,7 +335,7 @@ const Status = {
return
}
}
if (target.className.match(/hashtag/)) {
if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) {
// Extract tag name from link url
const tag = extractTagFromUrl(target.href)
if (tag) {
@ -414,6 +422,18 @@ const Status = {
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (this.isFocused && this.statusFromGlobalRepository.rebloggedBy && this.statusFromGlobalRepository.rebloggedBy.length !== num) {
this.$store.dispatch('fetchRepeats', this.status.id)
}
},
'status.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (this.isFocused && this.statusFromGlobalRepository.favoritedBy && this.statusFromGlobalRepository.favoritedBy.length !== num) {
this.$store.dispatch('fetchFavs', this.status.id)
}
}
},
filters: {

View File

@ -1,8 +1,19 @@
<template>
<div class="status-el" v-if="!hideStatus" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]">
<div v-if="error" class="alert error">
<!-- eslint-disable vue/no-v-html -->
<div
v-if="!hideStatus"
class="status-el"
:class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"
>
<div
v-if="error"
class="alert error"
>
{{ error }}
<i class="button-icon icon-cancel" @click="clearError"></i>
<i
class="button-icon icon-cancel"
@click="clearError"
/>
</div>
<template v-if="muted && !isPreview">
<div class="media status container muted">
@ -12,175 +23,409 @@
</router-link>
</small>
<small class="muteWords">{{ muteWordHits.join(', ') }}</small>
<a href="#" class="unmute" @click.prevent="toggleMute"><i class="button-icon icon-eye-off"></i></a>
<a
href="#"
class="unmute"
@click.prevent="toggleMute"
><i class="button-icon icon-eye-off" /></a>
</div>
</template>
<template v-else>
<div v-if="showPinned && statusoid.pinned" class="status-pin">
<i class="fa icon-pin faint"></i>
<div
v-if="showPinned"
class="status-pin"
>
<i class="fa icon-pin faint" />
<span class="faint">{{ $t('status.pinned') }}</span>
</div>
<div v-if="retweet && !noHeading && !inConversation" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info">
<UserAvatar class="media-left" v-if="retweet" :betterShadow="betterShadow" :user="statusoid.user"/>
<div
v-if="retweet && !noHeading && !inConversation"
:class="[repeaterClass, { highlighted: repeaterStyle }]"
:style="[repeaterStyle]"
class="media container retweet-info"
>
<UserAvatar
v-if="retweet"
class="media-left"
:better-shadow="betterShadow"
:user="statusoid.user"
/>
<div class="media-body faint">
<span class="user-name">
<router-link v-if="retweeterHtml" :to="retweeterProfileLink" v-html="retweeterHtml"/>
<router-link v-else :to="retweeterProfileLink">{{retweeter}}</router-link>
<router-link
v-if="retweeterHtml"
:to="retweeterProfileLink"
v-html="retweeterHtml"
/>
<router-link
v-else
:to="retweeterProfileLink"
>{{ retweeter }}</router-link>
</span>
<i class='fa icon-retweet retweeted' :title="$t('tool_tip.repeat')"></i>
<i
class="fa icon-retweet retweeted"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
</div>
</div>
<div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]" :style="[ userStyle ]" class="media status" :data-tags="tags">
<div v-if="!noHeading" class="media-left">
<router-link :to="userProfileLink" @click.stop.prevent.capture.native="toggleUserExpanded">
<UserAvatar :compact="compact" :betterShadow="betterShadow" :user="status.user"/>
<div
:class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]"
:style="[ userStyle ]"
class="media status"
:data-tags="tags"
>
<div
v-if="!noHeading"
class="media-left"
>
<router-link
:to="userProfileLink"
@click.stop.prevent.capture.native="toggleUserExpanded"
>
<UserAvatar
:compact="compact"
:better-shadow="betterShadow"
:user="status.user"
/>
</router-link>
</div>
<div class="status-body">
<UserCard :user="status.user" :rounded="true" :bordered="true" class="status-usercard" v-if="userExpanded"/>
<div v-if="!noHeading" class="media-heading">
<UserCard
v-if="userExpanded"
:user="status.user"
:rounded="true"
:bordered="true"
class="status-usercard"
/>
<div
v-if="!noHeading"
class="media-heading"
>
<div class="heading-name-row">
<div class="name-and-account-name">
<h4 class="user-name" v-if="status.user.name_html" v-html="status.user.name_html"></h4>
<h4 class="user-name" v-else>{{status.user.name}}</h4>
<router-link class="account-name" :to="userProfileLink">
<h4
v-if="status.user.name_html"
class="user-name"
v-html="status.user.name_html"
/>
<h4
v-else
class="user-name"
>
{{ status.user.name }}
</h4>
<router-link
class="account-name"
:to="userProfileLink"
>
{{ status.user.screen_name }}
</router-link>
</div>
<span class="heading-right">
<router-link class="timeago faint-link" :to="{ name: 'conversation', params: { id: status.id } }">
<timeago :since="status.created_at" :auto-update="60"></timeago>
<router-link
class="timeago faint-link"
:to="{ name: 'conversation', params: { id: status.id } }"
>
<Timeago
:time="status.created_at"
:auto-update="60"
/>
</router-link>
<div class="button-icon visibility-icon" v-if="status.visibility">
<i :class="visibilityIcon(status.visibility)" :title="status.visibility | capitalize"></i>
<div
v-if="status.visibility"
class="button-icon visibility-icon"
>
<i
:class="visibilityIcon(status.visibility)"
:title="status.visibility | capitalize"
/>
</div>
<a :href="status.external_url" target="_blank" v-if="!status.is_local && !isPreview" class="source_url" title="Source">
<i class="button-icon icon-link-ext-alt"></i>
<a
v-if="!status.is_local && !isPreview"
:href="status.external_url"
target="_blank"
class="source_url"
title="Source"
>
<i class="button-icon icon-link-ext-alt" />
</a>
<template v-if="expandable && !isPreview">
<a href="#" @click.prevent="toggleExpanded" title="Expand">
<i class="button-icon icon-plus-squared"></i>
<a
href="#"
title="Expand"
@click.prevent="toggleExpanded"
>
<i class="button-icon icon-plus-squared" />
</a>
</template>
<a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="button-icon icon-eye-off"></i></a>
<a
v-if="unmuted"
href="#"
@click.prevent="toggleMute"
><i class="button-icon icon-eye-off" /></a>
</span>
</div>
<div class="heading-reply-row">
<div v-if="isReply" class="reply-to-and-accountname">
<a class="reply-to"
href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
<div
v-if="isReply"
class="reply-to-and-accountname"
>
<a
class="reply-to"
href="#"
:aria-label="$t('tool_tip.reply')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
@mouseenter.prevent.stop="replyEnter(status.in_reply_to_status_id, $event)"
@mouseleave.prevent.stop="replyLeave()"
>
<i class="button-icon icon-reply" v-if="!isPreview"></i>
<i
v-if="!isPreview"
class="button-icon icon-reply"
/>
<span class="faint-link reply-to-text">{{ $t('status.reply_to') }}</span>
</a>
<router-link :to="replyProfileLink">
{{ replyToName }}
</router-link>
<span class="faint replies-separator" v-if="replies && replies.length">
<span
v-if="replies && replies.length"
class="faint replies-separator"
>
-
</span>
</div>
<div class="replies" v-if="inConversation && !isPreview">
<span class="faint" v-if="replies && replies.length">{{$t('status.replies_list')}}</span>
<span class="reply-link faint" v-if="replies" v-for="reply in replies">
<a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}}</a>
<div
v-if="inConversation && !isPreview"
class="replies"
>
<span
v-if="replies && replies.length"
class="faint"
>{{ $t('status.replies_list') }}</span>
<template v-if="replies">
<span
v-for="reply in replies"
:key="reply.id"
class="reply-link faint"
>
<a
href="#"
@click.prevent="gotoOriginal(reply.id)"
@mouseenter="replyEnter(reply.id, $event)"
@mouseout="replyLeave()"
>{{ reply.name }}</a>
</span>
</template>
</div>
</div>
</div>
</div>
<div v-if="showPreview" class="status-preview-container">
<status class="status-preview"
<div
v-if="showPreview"
class="status-preview-container"
>
<status
v-if="preview"
:isPreview="true"
class="status-preview"
:is-preview="true"
:statusoid="preview"
:compact="true"
/>
<div v-else class="status-preview status-preview-loading">
<i class="icon-spin4 animate-spin"></i>
<div
v-else
class="status-preview status-preview-loading"
>
<i class="icon-spin4 animate-spin" />
</div>
</div>
<div class="status-content-wrapper" :class="{ 'tall-status': !showingLongSubject }" v-if="longSubject">
<a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="!showingLongSubject" href="#" @click.prevent="showingLongSubject=true">{{$t("general.show_more")}}</a>
<div @click.prevent="linkClicked" class="status-content media-body" v-html="contentHtml"></div>
<a v-if="showingLongSubject" href="#" class="status-unhider" @click.prevent="showingLongSubject=false">{{$t("general.show_less")}}</a>
<div
v-if="longSubject"
class="status-content-wrapper"
:class="{ 'tall-status': !showingLongSubject }"
>
<a
v-if="!showingLongSubject"
class="tall-status-hider"
:class="{ 'tall-status-hider_focused': isFocused }"
href="#"
@click.prevent="showingLongSubject=true"
>{{ $t("general.show_more") }}</a>
<div
class="status-content media-body"
@click.prevent="linkClicked"
v-html="contentHtml"
/>
<a
v-if="showingLongSubject"
href="#"
class="status-unhider"
@click.prevent="showingLongSubject=false"
>{{ $t("general.show_less") }}</a>
</div>
<div :class="{'tall-status': hideTallStatus}" class="status-content-wrapper" v-else>
<a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowMore">{{$t("general.show_more")}}</a>
<div @click.prevent="linkClicked" class="status-content media-body" v-html="contentHtml" v-if="!hideSubjectStatus"></div>
<div @click.prevent="linkClicked" class="status-content media-body" v-html="status.summary_html" v-else></div>
<a v-if="hideSubjectStatus" href="#" class="cw-status-hider" @click.prevent="toggleShowMore">{{$t("general.show_more")}}</a>
<a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">{{$t("general.show_less")}}</a>
<div
v-else
:class="{'tall-status': hideTallStatus}"
class="status-content-wrapper"
>
<a
v-if="hideTallStatus"
class="tall-status-hider"
:class="{ 'tall-status-hider_focused': isFocused }"
href="#"
@click.prevent="toggleShowMore"
>{{ $t("general.show_more") }}</a>
<div
v-if="!hideSubjectStatus"
class="status-content media-body"
@click.prevent="linkClicked"
v-html="contentHtml"
/>
<div
v-else
class="status-content media-body"
@click.prevent="linkClicked"
v-html="status.summary_html"
/>
<a
v-if="hideSubjectStatus"
href="#"
class="cw-status-hider"
@click.prevent="toggleShowMore"
>{{ $t("general.show_more") }}</a>
<a
v-if="showingMore"
href="#"
class="status-unhider"
@click.prevent="toggleShowMore"
>{{ $t("general.show_less") }}</a>
</div>
<div v-if="status.attachments && (!hideSubjectStatus || showingLongSubject)" class="attachments media-body">
<div v-if="status.poll && status.poll.options">
<poll :base-poll="status.poll" />
</div>
<div
v-if="status.attachments && (!hideSubjectStatus || showingLongSubject)"
class="attachments media-body"
>
<attachment
class="non-gallery"
v-for="attachment in nonGalleryAttachments"
:key="attachment.id"
class="non-gallery"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
:attachment="attachment"
:allowPlay="true"
:setMedia="setMedia()"
:key="attachment.id"
:allow-play="true"
:set-media="setMedia()"
/>
<gallery
v-if="galleryAttachments.length > 0"
:nsfw="nsfwClickthrough"
:attachments="galleryAttachments"
:setMedia="setMedia()"
:set-media="setMedia()"
/>
</div>
<div v-if="status.card && !hideSubjectStatus && !noHeading" class="link-preview media-body">
<link-preview :card="status.card" :size="attachmentSize" :nsfw="nsfwClickthrough" />
<div
v-if="status.card && !hideSubjectStatus && !noHeading"
class="link-preview media-body"
>
<link-preview
:card="status.card"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
/>
</div>
<transition name="fade">
<div class="favs-repeated-users" v-if="isFocused && combinedFavsAndRepeatsUsers.length > 0">
<div
v-if="!hidePostStats && isFocused && combinedFavsAndRepeatsUsers.length > 0"
class="favs-repeated-users"
>
<div class="stats">
<div class="stat-count" v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0">
<div
v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0"
class="stat-count"
>
<a class="stat-title">{{ $t('status.repeats') }}</a>
<div class="stat-number">{{ statusFromGlobalRepository.rebloggedBy.length }}</div>
<div class="stat-number">
{{ statusFromGlobalRepository.rebloggedBy.length }}
</div>
<div class="stat-count" v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0">
</div>
<div
v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0"
class="stat-count"
>
<a class="stat-title">{{ $t('status.favorites') }}</a>
<div class="stat-number">{{ statusFromGlobalRepository.favoritedBy.length }}</div>
<div class="stat-number">
{{ statusFromGlobalRepository.favoritedBy.length }}
</div>
</div>
<div class="avatar-row">
<AvatarList :users="combinedFavsAndRepeatsUsers"></AvatarList>
<AvatarList :users="combinedFavsAndRepeatsUsers" />
</div>
</div>
</div>
</transition>
<div v-if="!noHeading && !isPreview" class='status-actions media-body'>
<div
v-if="!noHeading && !isPreview"
class="status-actions media-body"
>
<div>
<i class="button-icon icon-reply" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')" :class="{'button-icon-active': replying}" v-if="loggedIn"/>
<i class="button-icon button-icon-disabled icon-reply" :title="$t('tool_tip.reply')" v-else />
<i
v-if="loggedIn"
class="button-icon icon-reply"
:title="$t('tool_tip.reply')"
:class="{'button-icon-active': replying}"
@click.prevent="toggleReplying"
/>
<i
v-else
class="button-icon button-icon-disabled icon-reply"
:title="$t('tool_tip.reply')"
/>
<span v-if="status.replies_count > 0">{{ status.replies_count }}</span>
</div>
<retweet-button :visibility='status.visibility' :loggedIn='loggedIn' :status='status'></retweet-button>
<favorite-button :loggedIn='loggedIn' :status='status'></favorite-button>
<extra-buttons :status="status" @onError="showError" @onSuccess="clearError"></extra-buttons>
<retweet-button
:visibility="status.visibility"
:logged-in="loggedIn"
:status="status"
/>
<favorite-button
:logged-in="loggedIn"
:status="status"
/>
<extra-buttons
:status="status"
@onError="showError"
@onSuccess="clearError"
/>
</div>
</div>
</div>
<div class="container" v-if="replying">
<post-status-form class="reply-body" :reply-to="status.id" :attentions="status.attentions" :repliedUser="status.user" :copy-message-scope="status.visibility" :subject="replySubject" v-on:posted="toggleReplying"/>
<div
v-if="replying"
class="container"
>
<post-status-form
class="reply-body"
:reply-to="status.id"
:attentions="status.attentions"
:replied-user="status.user"
:copy-message-scope="status.visibility"
:subject="replySubject"
@posted="toggleReplying"
/>
</div>
</template>
</div>
<!-- eslint-enable vue/no-v-html -->
</template>
<script src="./status.js" ></script>
@ -449,6 +694,7 @@ $status-margin: 0.75em;
.status-content {
font-family: var(--postFont, sans-serif);
line-height: 1.4em;
white-space: pre-wrap;
img, video {
max-width: 100%;
@ -574,11 +820,12 @@ $status-margin: 0.75em;
}
.status-actions {
position: relative;
width: 100%;
display: flex;
margin-top: $status-margin;
div, favorite-button {
> * {
max-width: 4em;
flex: 1;
}

View File

@ -0,0 +1,52 @@
/* eslint-env browser */
import statusPosterService from '../../services/status_poster/status_poster.service.js'
import TabSwitcher from '../tab_switcher/tab_switcher.js'
const StickerPicker = {
components: [
TabSwitcher
],
data () {
return {
meta: {
stickers: []
},
path: ''
}
},
computed: {
pack () {
return this.$store.state.instance.stickers || []
}
},
methods: {
clear () {
this.meta = {
stickers: []
}
},
pick (sticker, name) {
const store = this.$store
// TODO remove this workaround by finding a way to bypass reuploads
fetch(sticker)
.then((res) => {
res.blob().then((blob) => {
var file = new File([blob], name, { mimetype: 'image/png' })
var formData = new FormData()
formData.append('file', file)
statusPosterService.uploadMedia({ store, formData })
.then((fileData) => {
this.$emit('uploaded', fileData)
this.clear()
}, (error) => {
console.warn("Can't attach sticker")
console.warn(error)
this.$emit('upload-failed', 'default')
})
})
})
}
}
}
export default StickerPicker

View File

@ -0,0 +1,62 @@
<template>
<div
class="sticker-picker"
>
<div
class="sticker-picker-panel"
>
<tab-switcher
:render-only-focused="true"
>
<div
v-for="stickerpack in pack"
:key="stickerpack.path"
:image-tooltip="stickerpack.meta.title"
:image="stickerpack.path + stickerpack.meta.tabIcon"
class="sticker-picker-content"
>
<div
v-for="sticker in stickerpack.meta.stickers"
:key="sticker"
class="sticker"
@click="pick(stickerpack.path + sticker, stickerpack.meta.title)"
>
<img
:src="stickerpack.path + sticker"
>
</div>
</div>
</tab-switcher>
</div>
</div>
</template>
<script src="./sticker_picker.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.sticker-picker {
.sticker-picker-panel {
display: inline-block;
width: 100%;
.sticker-picker-content {
max-height: 300px;
overflow-y: scroll;
overflow-x: auto;
.sticker {
display: inline-block;
width: 20%;
height: 20%;
img {
width: 100%;
&:hover {
filter: drop-shadow(0 0 5px var(--link, $fallback--link));
}
}
}
}
}
}
</style>

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