Compare commits

..

1 commit

Author SHA1 Message Date
Henry Jameson
bd49108d29 WIP exploration of 3-column mode 2020-11-03 22:44:40 +02:00
325 changed files with 9109 additions and 26283 deletions

View file

@ -1,5 +1,5 @@
{
"presets": ["@babel/preset-env"],
"plugins": ["@babel/plugin-transform-runtime", "lodash", "@vue/babel-plugin-jsx"],
"plugins": ["@babel/plugin-transform-runtime", "lodash", "@vue/babel-plugin-transform-vue-jsx"],
"comments": false
}

View file

@ -1,7 +1,7 @@
# This file is a template, and might need editing before it works on your project.
# Official framework image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/node/tags/
image: node:12
image: node:10
stages:
- lint

View file

@ -1 +0,0 @@
rinpatch <rin@patch.cx> <rinpatch@sdf.org>

View file

@ -3,174 +3,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased
### Fixed
- AdminFE button no longer scrolls page to top when clicked
- Pinned statuses no longer appear at bottom of user timeline (still appear as part of the timeline when fetched deep enough)
- Fixed many many bugs related to new mentions, including spacing and alignment issues
- Links in profile bios now properly open in new tabs
- Inline images now respect their intended width/height attributes
- Links with `&` in them work properly now
- Interaction list popovers now properly emojify names
- Completely hidden posts still had 1px border
- Attachments are ALWAYS in same order as user uploaded, no more "videos first"
- Attachment description is prefilled with backend-provided default when uploading
- Proper visual feedback that next image is loading when browsing
### Changed
- (You)s are optional (opt-in) now, bolding your nickname is also optional (opt-out)
- User highlight background now also covers the `@`
- Reverted back to textual `@`, svg version is opt-in.
- Settings window has been throughly rearranged to make make more sense and make navication settings easier.
- Uploaded attachments are uniform with displayed attachments
- Flash is watchable in media-modal (takes up nearly full screen though due to sizing issues)
- Notifications about likes/repeats/emoji reacts are now minimized so they always take up same amount of space irrelevant to size of post.
### Added
- Options to show domains in mentions
- Option to show user avatars in mention links (opt-in)
- Option to disable the tooltip for mentions
- Option to completely hide muted threads
- Ability to open videos in modal even if you disabled that feature, via an icon button
- New button on attachment that indicates that attachment has a description and shows a bar filled with description
- Attachments are truncated just like post contents
- Media modal now also displays description and counter position in gallery (i.e. 1/5)
- Ability to rearrange order of attachments when uploading
- Enabled users to zoom and pan images in media viewer with mouse and touch
## [2.4.2] - 2022-01-09
### Added
- Added Apply and Reset buttons to the bottom of theme tab to minimize UI travel
- Implemented user option to always show floating New Post button (normally mobile-only)
- Display reasons for instance specific policies
- Added functionality to cancel follow request
### Fixed
- Fixed link to external profile not working on user profiles
- Fixed mobile shoutbox display
- Fixed favicon badge not working in Chrome
- Escape html more properly in subject/display name
## [2.4.0] - 2021-08-08
### Added
- Added a quick settings to timeline header for easier access
- Added option to mark posts as sensitive by default
- Added quick filters for notifications
- Implemented user option to change sidebar position to the right side
- Implemented user option to hide floating shout panel
- Implemented "edit profile" button if viewing own profile which opens profile settings
### Fixed
- Fixed follow request count showing in the wrong location in mobile view
## [2.3.0] - 2021-03-01
### Fixed
- Button to remove uploaded media in post status form is now properly placed and sized.
- Fixed shoutbox not working in mobile layout
- Fixed missing highlighted border in expanded conversations again
- Fixed some UI jumpiness when opening images particularly in chat view
- Fixed chat unread badge looking weird
- Fixed punycode names not working properly
- Fixed notifications crashing on an invalid notification
### Changed
- Display 'people voted' instead of 'votes' for multi-choice polls
- Changed the "Timelines" link in side panel to toggle show all timeline options inside the panel
- Renamed "Timeline" to "Home Timeline" to be more clear
- Optimized chat to not get horrible performance after keeping the same chat open for a long time
- When opening emoji picker or react picker, it automatically focuses the search field
- Language picker now uses native language names
### Added
- Added reason field for registration when approval is required
- Group staff members by role in the About page
## [2.2.3] - 2021-01-18
### Added
- Added Report button to status ellipsis menu for easier reporting
### Fixed
- Follows/Followers tabs on user profiles now display the content properly.
- Handle punycode in screen names
- Fixed local dev mode having non-functional websockets in some cases
- Show notices for websocket events (errors, abnormal closures, reconnections)
- Fix not being able to re-enable websocket until page refresh
- Fix annoying issue where timeline might have few posts when streaming is enabled
### Changed
- Don't filter own posts when they hit your wordfilter
## [2.2.2] - 2020-12-22
### Added
- Mouseover titles for emojis in reaction picker
- Support to input emoji into the search box in reaction picker
- Added some missing unicode emoji
- Added the upload limit to the Features panel in the About page
- Support for solid color wallpaper, instance doesn't have to define a wallpaper anymore
### Fixed
- Fixed the occasional bug where screen would scroll 1px when typing into a reply form
- Fixed timeline errors locking timelines
- Fixed missing highlighted border in expanded conversations
- Fixed custom emoji not working in profile field names
- Fixed pinned statuses not appearing in user profiles
- Fixed some elements not being keyboard navigation friendly
- Fixed error handling when updating various profile images
- Fixed your latest chat messages disappearing when closing chat view and opening it again during the same session
- Fixed custom emoji not showing in poll options before voting
- Fixed link color not applied to instance name in topbar
### Changed
- Errors when fetching are now shown with popup errors instead of "Error fetching updates" in panel headers
- Made reply/fav/repeat etc buttons easier to hit
- Adjusted timeline menu clickable area to match the visible button
- Moved external source link from status heading to the ellipsis menu
- Disabled horizontal textarea resize
- Wallpaper is now top-aligned, horizontally centered.
## [2.2.1] - 2020-11-11
### Fixed
- Fixed regression in react popup alignment and overflowing
## [2.2.0] - 2020-11-06
## [Unreleased]
### Added
- New option to optimize timeline rendering to make the site more responsive (enabled by default)
- New instance option `logoLeft` to move logo to the left side in desktop nav bar
- Import/export a muted users
- Proper handling of deletes when using websocket streaming
- Added optimistic chat message sending, so you can start writing next message before the previous one has been sent
- Added a small red badge to the favicon when there's unread notifications
- Added the NSFW alert to link previews
### Fixed
- Fixed chats list not updating its order when new messages come in
- Fixed chat messages sometimes getting lost when you receive a message at the same time
- Fixed clicking NSFW hider through status popover
- Fixed chat-view back button being hard to click
- Fixed fresh chat notifications being cleared immediately while leaving the chat view and not having time to actually see the messages
- Fixed multiple regressions in CSS styles
- Fixed multiple issues with input fields when using CJK font as default
- Fixed search field in navbar infringing into logo in some cases
- Fixed not being able to load the chat history in vertical screens when the message list doesn't take the full height of the scrollable container on the first fetch.
### Changed
- Clicking immediately when timeline shifts is now blocked to prevent misclicks
- Icons changed from fontello (FontAwesome 4 + others) to FontAwesome 5 due to problems with fontello.
- Some icons changed for better accessibility (lock, globe)
- Logo is now clickable
- Changed default logo to SVG version
## [2.1.2] - 2020-09-17
### Fixed
- Fixed chats list not updating its order when new messages come in
- Fixed chat messages sometimes getting lost when you receive a message at the same time
## [2.1.1] - 2020-09-08
### Changed
@ -297,9 +152,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Ability to change user's email
- About page
- Added remote user redirect
- Bookmarks
### Changed
- changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes
### Fixed
- improved hotkey behavior on autocomplete popup

View file

@ -3,7 +3,6 @@ Contributors of this project.
- Constance Variable (lambadalambda@social.heldscal.la): Code
- Coco Snuss (cocosnuss@social.heldscal.la): Code
- wakarimasen (wakarimasen@shitposter.club): NSFW hiding image
- eris (eris@disqordia.space): Code
- dtluna (dtluna@social.heldscal.la): Code
- sonyam (sonyam@social.heldscal.la): Background images
- hakui (hakui@freezepeach.xyz): CSS and styling

View file

@ -21,7 +21,6 @@ var compiler = webpack(webpackConfig)
var devMiddleware = require('webpack-dev-middleware')(compiler, {
publicPath: webpackConfig.output.publicPath,
writeToDisk: true,
stats: {
colors: true,
chunks: false

View file

@ -3,8 +3,6 @@ var config = require('../config')
var utils = require('./utils')
var projectRoot = path.resolve(__dirname, '../')
var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin')
var CopyPlugin = require('copy-webpack-plugin');
var { VueLoaderPlugin } = require('vue-loader')
var env = process.env.NODE_ENV
// check env & config/index.js to decide weither to enable CSS Sourcemaps for the
@ -30,16 +28,16 @@ module.exports = {
}
},
resolve: {
extensions: ['.js', '.jsx', '.vue'],
extensions: ['.js', '.vue'],
modules: [
path.join(__dirname, '../node_modules')
],
alias: {
'vue$': 'vue/dist/vue.runtime.common',
'static': path.resolve(__dirname, '../static'),
'src': path.resolve(__dirname, '../src'),
'assets': path.resolve(__dirname, '../src/assets'),
'components': path.resolve(__dirname, '../src/components'),
'vue-i18n': 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js'
'components': path.resolve(__dirname, '../src/components')
}
},
module: {
@ -59,28 +57,9 @@ module.exports = {
}
}
},
{
enforce: 'post',
test: /\.(json5?|ya?ml)$/, // target json, json5, yaml and yml files
type: 'javascript/auto',
loader: '@intlify/vue-i18n-loader',
include: [ // Use `Rule.include` to specify the files of locale messages to be pre-compiled
path.resolve(__dirname, '../src/i18n')
]
},
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {
isCustomElement(tag) {
if (tag === 'pinch-zoom') {
return true
}
return false
}
}
}
use: 'vue-loader'
},
{
test: /\.jsx?$/,
@ -114,20 +93,6 @@ module.exports = {
new ServiceWorkerWebpackPlugin({
entry: path.join(__dirname, '..', 'src/sw.js'),
filename: 'sw-pleroma.js'
}),
new VueLoaderPlugin(),
// This copies Ruffle's WASM to a directory so that JS side can access it
new CopyPlugin({
patterns: [
{
from: "node_modules/ruffle-mirror/*",
to: "static/ruffle",
flatten: true
},
],
options: {
concurrency: 100,
},
})
]
}

View file

@ -21,9 +21,7 @@ module.exports = merge(baseWebpackConfig, {
new webpack.DefinePlugin({
'process.env': config.dev.env,
'COMMIT_HASH': JSON.stringify('DEV'),
'DEV_OVERRIDES': JSON.stringify(config.dev.settings),
'__VUE_OPTIONS_API__': true,
'__VUE_PROD_DEVTOOLS__': false
'DEV_OVERRIDES': JSON.stringify(config.dev.settings)
}),
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage
new webpack.HotModuleReplacementPlugin(),

View file

@ -36,9 +36,7 @@ var webpackConfig = merge(baseWebpackConfig, {
new webpack.DefinePlugin({
'process.env': env,
'COMMIT_HASH': JSON.stringify(commitHash),
'DEV_OVERRIDES': JSON.stringify(undefined),
'__VUE_OPTIONS_API__': true,
'__VUE_PROD_DEVTOOLS__': false
'DEV_OVERRIDES': JSON.stringify(undefined)
}),
// extract css into its own file
new MiniCssExtractPlugin({

View file

@ -3,11 +3,6 @@ const path = require('path')
let settings = {}
try {
settings = require('./local.json')
if (settings.target && settings.target.endsWith('/')) {
// replacing trailing slash since it can conflict with some apis
// and that's how actual BE reports its url
settings.target = settings.target.replace(/\/$/, '')
}
console.log('Using local dev server settings (/config/local.json):')
console.log(JSON.stringify(settings, null, 2))
} catch (e) {
@ -52,10 +47,7 @@ module.exports = {
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost',
ws: true,
headers: {
'Origin': target
}
ws: true
},
'/oauth/revoke': {
target,

View file

@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no">
<title>Pleroma</title>
<!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.png">
</head>

View file

@ -16,111 +16,105 @@
"lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
},
"dependencies": {
"@babel/runtime": "7.17.8",
"@chenfengyuan/vue-qrcode": "2.0.0",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/vue-fontawesome": "3.0.0-5",
"@kazvmoe-infra/pinch-zoom-element": "1.2.0",
"@vuelidate/core": "2.0.0-alpha.35",
"@vuelidate/validators": "2.0.0-alpha.27",
"body-scroll-lock": "2.7.1",
"chromatism": "3.0.0",
"click-outside-vue3": "4.0.1",
"cropperjs": "1.5.12",
"diff": "3.5.0",
"escape-html": "1.0.3",
"localforage": "1.10.0",
"parse-link-header": "1.0.1",
"phoenix": "1.6.2",
"punycode.js": "2.1.0",
"qrcode": "1",
"ruffle-mirror": "2021.12.31",
"vue": "^3.2.31",
"vue-i18n": "^9.2.0-beta.34",
"vue-router": "4.0.14",
"vue-template-compiler": "2.6.11",
"vuex": "4.0.2"
"@babel/runtime": "^7.7.6",
"@chenfengyuan/vue-qrcode": "^1.0.0",
"@fortawesome/fontawesome-svg-core": "^1.2.32",
"@fortawesome/free-regular-svg-icons": "^5.15.1",
"@fortawesome/free-solid-svg-icons": "^5.15.1",
"@fortawesome/vue-fontawesome": "^2.0.0",
"body-scroll-lock": "^2.6.4",
"chromatism": "^3.0.0",
"cropperjs": "^1.4.3",
"diff": "^3.0.1",
"escape-html": "^1.0.3",
"localforage": "^1.5.0",
"parse-link-header": "^1.0.1",
"phoenix": "^1.3.0",
"portal-vue": "^2.1.4",
"v-click-outside": "^2.1.1",
"vue": "^2.6.11",
"vue-chat-scroll": "^1.2.1",
"vue-i18n": "^7.3.2",
"vue-router": "^3.0.1",
"vue-template-compiler": "^2.6.11",
"vuelidate": "^0.7.4",
"vuex": "^3.0.1"
},
"devDependencies": {
"@babel/core": "7.17.8",
"@babel/plugin-transform-runtime": "7.17.0",
"@babel/preset-env": "7.16.11",
"@babel/register": "7.17.7",
"@intlify/vue-i18n-loader": "^5.0.0",
"@ungap/event-target": "0.2.3",
"@vue/babel-helper-vue-jsx-merge-props": "1.2.1",
"@vue/babel-plugin-jsx": "1.1.1",
"@vue/compiler-sfc": "^3.1.0",
"@vue/test-utils": "2.0.0-rc.17",
"autoprefixer": "6.7.7",
"babel-eslint": "7.2.3",
"babel-loader": "8.2.4",
"babel-plugin-lodash": "3.3.4",
"chai": "3.5.0",
"chalk": "1.1.3",
"chromedriver": "87.0.7",
"connect-history-api-fallback": "1.6.0",
"copy-webpack-plugin": "6.4.1",
"cross-spawn": "4.0.2",
"css-loader": "0.28.11",
"custom-event-polyfill": "1.0.7",
"eslint": "5.16.0",
"eslint-config-standard": "12.0.0",
"eslint-friendly-formatter": "2.0.7",
"eslint-loader": "2.2.1",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-node": "7.0.1",
"eslint-plugin-promise": "4.3.1",
"eslint-plugin-standard": "4.1.0",
"eslint-plugin-vue": "5.2.3",
"eventsource-polyfill": "0.9.6",
"express": "4.17.3",
"file-loader": "3.0.1",
"function-bind": "1.1.1",
"html-webpack-plugin": "3.2.0",
"http-proxy-middleware": "0.21.0",
"inject-loader": "2.0.1",
"iso-639-1": "2.1.13",
"isparta-loader": "2.0.0",
"json-loader": "0.5.7",
"karma": "6.3.17",
"karma-coverage": "1.1.2",
"karma-firefox-launcher": "1.3.0",
"karma-mocha": "2.0.1",
"karma-mocha-reporter": "2.2.5",
"karma-sinon-chai": "2.0.2",
"karma-sourcemap-loader": "0.3.8",
"karma-spec-reporter": "0.0.33",
"karma-webpack": "4.0.2",
"lodash": "4.17.21",
"lolex": "1.6.0",
"mini-css-extract-plugin": "0.12.0",
"mocha": "3.5.3",
"nightwatch": "0.9.21",
"opn": "4.0.2",
"ora": "0.4.1",
"postcss-loader": "3.0.0",
"raw-loader": "0.5.1",
"sass": "1.20.1",
"sass-loader": "7.2.0",
"@babel/core": "^7.7.5",
"@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.6",
"@babel/register": "^7.7.4",
"@ungap/event-target": "^0.1.0",
"@vue/babel-helper-vue-jsx-merge-props": "^1.0.0",
"@vue/babel-plugin-transform-vue-jsx": "^1.1.2",
"@vue/test-utils": "^1.0.0-beta.26",
"autoprefixer": "^6.4.0",
"babel-eslint": "^7.0.0",
"babel-loader": "^8.0.6",
"babel-plugin-lodash": "^3.3.4",
"chai": "^3.5.0",
"chalk": "^1.1.3",
"chromedriver": "^2.21.2",
"connect-history-api-fallback": "^1.1.0",
"cross-spawn": "^4.0.2",
"css-loader": "^0.28.0",
"custom-event-polyfill": "^1.0.7",
"eslint": "^5.16.0",
"eslint-config-standard": "^12.0.0",
"eslint-friendly-formatter": "^2.0.5",
"eslint-loader": "^2.1.0",
"eslint-plugin-import": "^2.13.0",
"eslint-plugin-node": "^7.0.0",
"eslint-plugin-promise": "^4.0.0",
"eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^5.2.2",
"eventsource-polyfill": "^0.9.6",
"express": "^4.13.3",
"file-loader": "^3.0.1",
"function-bind": "^1.0.2",
"html-webpack-plugin": "^3.0.0",
"http-proxy-middleware": "^0.17.2",
"inject-loader": "^2.0.1",
"iso-639-1": "^2.0.3",
"isparta-loader": "^2.0.0",
"json-loader": "^0.5.4",
"karma": "^3.0.0",
"karma-coverage": "^1.1.1",
"karma-firefox-launcher": "^1.1.0",
"karma-mocha": "^1.2.0",
"karma-mocha-reporter": "^2.2.1",
"karma-sinon-chai": "^2.0.2",
"karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "0.0.26",
"karma-webpack": "^4.0.0-rc.3",
"lodash": "^4.16.4",
"lolex": "^1.4.0",
"mini-css-extract-plugin": "^0.5.0",
"mocha": "^3.1.0",
"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",
"selenium-server": "2.53.1",
"semver": "5.6.0",
"serviceworker-webpack-plugin": "1.0.1",
"shelljs": "0.8.5",
"sinon": "2.4.1",
"sinon-chai": "2.14.0",
"stylelint": "13.6.1",
"stylelint-config-standard": "20.0.0",
"stylelint-rscss": "0.4.0",
"url-loader": "1.1.2",
"vue-loader": "^16.0.0",
"vue-style-loader": "4.1.2",
"webpack": "4.46.0",
"webpack-dev-middleware": "3.7.3",
"webpack-hot-middleware": "2.24.3",
"webpack-merge": "0.14.1"
"semver": "^5.3.0",
"serviceworker-webpack-plugin": "^1.0.0",
"shelljs": "^0.7.4",
"sinon": "^2.1.0",
"sinon-chai": "^2.8.0",
"stylelint": "^13.6.1",
"stylelint-config-standard": "^20.0.0",
"stylelint-rscss": "^0.4.0",
"url-loader": "^1.1.2",
"vue-loader": "^14.0.0",
"vue-style-loader": "^4.0.0",
"webpack": "^4.0.0",
"webpack-dev-middleware": "^3.6.0",
"webpack-hot-middleware": "^2.12.2",
"webpack-merge": "^0.14.1"
},
"engines": {
"node": ">= 4.0.0",

View file

@ -1,6 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base"
]
}

View file

@ -4,7 +4,7 @@ import Notifications from './components/notifications/notifications.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import ShoutPanel from './components/shout_panel/shout_panel.vue'
import ChatPanel from './components/chat_panel/chat_panel.vue'
import SettingsModal from './components/settings_modal/settings_modal.vue'
import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
@ -14,8 +14,7 @@ import DesktopNav from './components/desktop_nav/desktop_nav.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
import { windowWidth, windowHeight } from './services/window_utils/window_utils'
import { mapGetters } from 'vuex'
import { windowWidth, windowHeight, getLayout } from './services/window_utils/window_utils'
export default {
name: 'app',
@ -26,7 +25,7 @@ export default {
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel,
ShoutPanel,
ChatPanel,
MediaModal,
SideDrawer,
MobilePostStatusButton,
@ -44,28 +43,27 @@ export default {
// Load the locale from the storage
const val = this.$store.getters.mergedConfig.interfaceLanguage
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
window.addEventListener('resize', this.updateMobileState)
window.addEventListener('resize', this.updateLayoutState)
},
unmounted () {
window.removeEventListener('resize', this.updateMobileState)
destroyed () {
window.removeEventListener('resize', this.updateLayoutState)
},
computed: {
currentUser () { return this.$store.state.users.currentUser },
userBackground () { return this.currentUser.background_image },
instanceBackground () {
return this.mergedConfig.hideInstanceWallpaper
? null
: this.$store.state.instance.background
background () {
return this.currentUser.background_image || this.$store.state.instance.background
},
background () { return this.userBackground || this.instanceBackground },
bgStyle () {
if (this.background) {
return {
'--body-background-image': `url(${this.background})`
}
return {
'background-image': `url(${this.background})`
}
},
shout () { return this.$store.state.shout.joined },
bgAppStyle () {
return {
'--body-background-image': `url(${this.background})`
}
},
chat () { return this.$store.state.chat.channel.state === 'joined' },
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
@ -73,28 +71,24 @@ export default {
this.$store.state.instance.instanceSpecificPanelContent
},
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
shoutboxPosition () {
return this.$store.getters.mergedConfig.showNewPostButton || false
},
hideShoutbox () {
return this.$store.getters.mergedConfig.hideShoutbox
},
isMobileLayout () { return this.$store.state.interface.mobileLayout },
layout () { return console.log(this.$store.state.interface.layout) || this.$store.state.interface.layout },
isMobileLayout () { return this.$store.state.interface.layout === '1column' },
is3ColumnLayout () { return this.$store.state.interface.layout === '3column' },
privateMode () { return this.$store.state.instance.private },
sidebarAlign () {
return {
'order': this.$store.getters.mergedConfig.sidebarRight ? 99 : 0
'order': this.$store.state.instance.sidebarRight ? 99 : 0
}
},
...mapGetters(['mergedConfig'])
}
},
methods: {
updateMobileState () {
const mobileLayout = windowWidth() <= 800
updateLayoutState () {
const width = windowWidth()
const layout = getLayout(width)
const layoutHeight = windowHeight()
const changed = mobileLayout !== this.isMobileLayout
const changed = layout !== this.layout
if (changed) {
this.$store.dispatch('setMobileLayout', mobileLayout)
this.$store.dispatch('setLayout', layout)
}
this.$store.dispatch('setLayoutHeight', layoutHeight)
}

View file

@ -14,9 +14,7 @@
right: -20px;
background-size: cover;
background-repeat: no-repeat;
background-color: var(--wallpaper);
background-image: var(--body-background-image);
background-position: 50% 50px;
background-position: 0 50%;
}
i[class^='icon-'] {
@ -27,13 +25,44 @@ h4 {
margin: 0;
}
#content {
.main-layout {
display: grid;
box-sizing: border-box;
padding-top: 60px;
margin: auto;
min-height: 100vh;
max-width: 980px;
align-content: flex-start;
grid-template-columns: 365px 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
"post notice"
"post content";
.column-3 {
display: none;
}
.content {
grid-area: content;
}
.sidebar {
max-height: 100vh;
grid-area: post;
}
@media all and (min-width: 1200px) {
grid-template-columns: 365px 1fr 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
"post notice notifications"
"post content notifications";
.column-3 {
display: block;
grid-area: notifications;
}
}
}
.underlay {
@ -72,7 +101,7 @@ a {
color: var(--link, $fallback--link);
}
.button-default {
button {
user-select: none;
color: $fallback--text;
color: var(--btnText, $fallback--text);
@ -88,12 +117,7 @@ a {
font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif);
&.-sublime {
background: transparent;
}
i[class*=icon-],
.svg-inline--fa {
i[class*=icon-], .svg-inline--fa {
color: $fallback--text;
color: var(--btnText, $fallback--text);
}
@ -115,8 +139,7 @@ a {
background-color: $fallback--fg;
background-color: var(--btnPressed, $fallback--fg);
svg,
i {
svg, i {
color: $fallback--text;
color: var(--btnPressedText, $fallback--text);
}
@ -129,8 +152,7 @@ a {
background-color: $fallback--fg;
background-color: var(--btnDisabled, $fallback--fg);
svg,
i {
svg, i {
color: $fallback--text;
color: var(--btnDisabledText, $fallback--text);
}
@ -144,8 +166,7 @@ a {
box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset;
box-shadow: var(--buttonPressedShadow);
svg,
i {
svg, i {
color: $fallback--text;
color: var(--btnToggledText, $fallback--text);
}
@ -160,38 +181,7 @@ a {
}
}
.button-unstyled {
background: none;
border: none;
outline: none;
display: inline;
text-align: initial;
font-size: 100%;
font-family: inherit;
padding: 0;
line-height: unset;
cursor: pointer;
box-sizing: content-box;
color: inherit;
&.-link {
color: $fallback--link;
color: var(--link, $fallback--link);
}
&.-fullwidth {
width: 100%;
}
&.-hover-highlight {
&:hover svg {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
}
}
input, textarea, .input {
input, textarea, .select, .input {
&.unstyled {
border-radius: 0;
@ -221,11 +211,47 @@ input, textarea, .input {
hyphens: none;
padding: 8px .5em;
&:disabled, &[disabled=disabled], &.disabled {
&.select {
padding: 0;
}
&:disabled, &[disabled=disabled] {
cursor: not-allowed;
opacity: 0.5;
}
.select-down-icon {
position: absolute;
top: 0;
bottom: 0;
right: 5px;
height: 100%;
color: $fallback--text;
color: var(--inputText, $fallback--text);
line-height: 28px;
z-index: 0;
pointer-events: none;
}
select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background: transparent;
border: none;
color: $fallback--text;
color: var(--inputText, --text, $fallback--text);
margin: 0;
padding: 0 2em 0 .2em;
font-family: sans-serif;
font-family: var(--inputFont, sans-serif);
font-size: 14px;
width: 100%;
z-index: 1;
height: 28px;
line-height: 16px;
}
&[type=range] {
background: none;
border: none;
@ -309,10 +335,6 @@ input, textarea, .input {
box-sizing: border-box;
}
}
&.resize-height {
resize: vertical;
}
}
option {
@ -452,7 +474,6 @@ main-router {
color: $fallback--faint;
color: var(--panelFaint, $fallback--faint);
}
.faint-link {
color: $fallback--faint;
color: var(--faintLink, $fallback--faint);
@ -464,8 +485,11 @@ main-router {
overflow-x: hidden;
}
.button-default,
.alert {
button {
flex-shrink: 0;
}
button, .alert {
// height: 100%;
line-height: 21px;
min-height: 0;
@ -476,11 +500,8 @@ main-router {
align-self: stretch;
}
.button-default {
flex-shrink: 0;
&,
i[class*=icon-] {
button {
&, i[class*=icon-] {
color: $fallback--text;
color: var(--btnPanelText, $fallback--text);
}
@ -503,8 +524,7 @@ main-router {
}
}
a,
.-link {
a {
color: $fallback--link;
color: var(--panelLink, $fallback--link)
}
@ -515,31 +535,19 @@ main-router {
border-radius: var(--panelRadius, $fallback--panelRadius);
}
/* TODO Should remove timeline-footer from here when we refactor panels into
* separate component and utilize slots
*/
.panel-footer, .timeline-footer {
display: flex;
.panel-footer {
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
flex: none;
padding: 0.6em 0.6em;
text-align: left;
line-height: 28px;
align-items: baseline;
border-width: 1px 0 0 0;
border-style: solid;
border-color: var(--border, $fallback--border);
.faint {
color: $fallback--faint;
color: var(--panelFaint, $fallback--faint);
}
a,
.-link {
a {
color: $fallback--link;
color: var(--panelLink, $fallback--link);
color: var(--panelLink, $fallback--link)
}
}
@ -566,13 +574,12 @@ nav {
color: var(--faint, $fallback--faint);
box-shadow: 0px 0px 4px rgba(0,0,0,.6);
box-shadow: var(--topBarShadow);
box-sizing: border-box;
}
.fade-enter-active, .fade-leave-active {
transition: opacity .2s
}
.fade-enter-from, .fade-leave-active {
.fade-enter, .fade-leave-active {
opacity: 0
}
@ -628,24 +635,19 @@ nav {
flex-grow: 0;
}
}
.badge {
box-sizing: border-box;
display: inline-block;
border-radius: 99px;
max-width: 10em;
min-width: 1.7em;
height: 1.3em;
padding: 0.15em 0.15em;
vertical-align: middle;
font-weight: normal;
font-style: normal;
font-size: 0.9em;
line-height: 1;
min-width: 22px;
max-width: 22px;
min-height: 22px;
max-height: 22px;
font-size: 15px;
line-height: 22px;
text-align: center;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0;
&.badge-notification {
background-color: $fallback--cRed;
@ -686,15 +688,6 @@ nav {
color: var(--alertWarningPanelText, $fallback--text);
}
}
&.success {
background-color: var(--alertSuccess, $fallback--alertWarning);
color: var(--alertSuccessText, $fallback--text);
.panel-heading & {
color: var(--alertSuccessPanelText, $fallback--text);
}
}
}
.faint {
@ -798,6 +791,13 @@ nav {
}
}
.select-multiple {
display: flex;
.option-list {
margin: 0;
padding-left: .5em;
}
}
.setting-list,
.option-list{
list-style-type: none;
@ -824,7 +824,7 @@ nav {
}
}
.btn.button-default {
.btn.btn-default {
min-height: 28px;
}
@ -844,10 +844,16 @@ nav {
}
.new-status-notification {
position: relative;
position:relative;
margin-top: -1px;
font-size: 1.1em;
border-width: 1px 0 0 0;
border-style: solid;
border-color: var(--border, $fallback--border);
padding: 10px;
z-index: 1;
flex: 1;
background-color: $fallback--fg;
background-color: var(--panel, $fallback--fg);
}
.chat-layout {
@ -855,11 +861,6 @@ nav {
overflow: hidden;
height: 100%;
// Get rid of scrollbar on body as scrolling happens on different element
body {
overflow: hidden;
}
// Ensures the fixed position of the mobile browser bars on scroll up / down events.
// Prevents the mobile browser bars from overlapping or hiding the message posting form.
@media all and (max-width: 800px) {

View file

@ -1,65 +1,59 @@
<template>
<div
id="app-loaded"
:style="bgStyle"
id="app"
:style="bgAppStyle"
>
<div
id="app_bg_wrapper"
class="app-bg-wrapper"
:style="bgStyle"
/>
<MobileNav v-if="isMobileLayout" />
<DesktopNav v-else />
<div class="app-bg-wrapper app-container-wrapper" />
<div
id="content"
class="container underlay"
class="main-layout underlay"
>
<div class="sidebar">
<user-panel />
<div v-if="!isMobileLayout">
<nav-panel />
<instance-specific-panel v-if="showInstanceSpecificPanel" />
<features-panel v-if="!currentUser && showFeaturesPanel" />
<who-to-follow-panel v-if="currentUser && suggestionsEnabled" />
<portal to="notifications-3rd-column" :disabled="!is3ColumnLayout">
<keep-alive>
<notifications v-if="currentUser"/>
</keep-alive>
</portal>
</div>
</div>
<portal-target class="column-3" name="notifications-3rd-column" />
<div
class="sidebar-flexer mobile-hidden"
:style="sidebarAlign"
v-if="!currentUser"
class="login-hint panel panel-default"
>
<div class="sidebar-bounds">
<div class="sidebar-scroller">
<div class="sidebar">
<user-panel />
<div v-if="!isMobileLayout">
<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"
>
<router-link
:to="{ name: 'login' }"
class="panel-body"
>
{{ $t("login.hint") }}
</router-link>
</div>
<router-view />
{{ $t("login.hint") }}
</router-link>
</div>
<media-modal />
<router-view class="content" />
</div>
<shout-panel
v-if="currentUser && shout && !hideShoutbox"
<chat-panel
v-if="currentUser && chat"
:floating="true"
class="floating-shout mobile-hidden"
:class="{ 'left': shoutboxPosition }"
class="floating-chat mobile-hidden"
/>
<MobilePostStatusButton />
<UserReportingModal />
<PostStatusModal />
<SettingsModal />
<div id="modal" />
<media-modal />
<portal-target name="modal" />
<GlobalNoticeList />
</div>
</template>

View file

@ -30,5 +30,3 @@ $fallback--attachmentRadius: 10px;
$fallback--chatMessageRadius: 10px;
$fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
$status-margin: 0.75em;

View file

@ -1,19 +1,12 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import vClickOutside from 'click-outside-vue3'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import App from '../App.vue'
import Vue from 'vue'
import VueRouter from 'vue-router'
import routes from './routes'
import VBodyScrollLock from 'src/directives/body_scroll_lock'
import { windowWidth } from '../services/window_utils/window_utils'
import App from '../App.vue'
import { windowWidth, getLayout } from '../services/window_utils/window_utils'
import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import { applyTheme } from '../services/style_setter/style_setter.js'
import FaviconService from '../services/favicon_service/favicon_service.js'
let staticInitialResults = null
@ -57,7 +50,6 @@ const getInstanceConfig = async ({ store }) => {
const vapidPublicKey = data.pleroma.vapid_public_key
store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit })
store.dispatch('setInstanceOption', { name: 'accountApprovalRequired', value: data.approval_required })
if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
@ -121,7 +113,6 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('nsfwCensorImage')
copyInstanceOption('background')
copyInstanceOption('hidePostStats')
copyInstanceOption('hideBotIndication')
copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('logo')
@ -247,7 +238,7 @@ const getNodeInfo = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations })
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
store.dispatch('setInstanceOption', { name: 'shoutAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
@ -333,9 +324,7 @@ const checkOAuthToken = async ({ store }) => {
const afterStoreSetup = async ({ store, i18n }) => {
const width = windowWidth()
store.dispatch('setMobileLayout', width <= 800)
FaviconService.initFaviconService()
store.dispatch('setLayout', getLayout(width))
const overrides = window.___pleromafe_dev_overrides || {}
const server = (typeof overrides.target !== 'undefined') ? overrides.target : window.location.origin
@ -373,32 +362,25 @@ const afterStoreSetup = async ({ store, i18n }) => {
getTOS({ store })
getStickers({ store })
const router = createRouter({
history: createWebHistory(),
const router = new VueRouter({
mode: 'history',
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some(m => m.meta.dontScroll)) {
return false
}
return savedPosition || { left: 0, top: 0 }
return savedPosition || { x: 0, y: 0 }
}
})
const app = createApp(App)
app.use(router)
app.use(store)
app.use(i18n)
app.use(vClickOutside)
app.use(VBodyScrollLock)
app.component('FAIcon', FontAwesomeIcon)
app.component('FALayers', FontAwesomeLayers)
app.mount('#app')
return app
/* eslint-disable no-new */
return new Vue({
router,
store,
i18n,
el: '#app',
render: h => h(App)
})
}
export default afterStoreSetup

View file

@ -16,7 +16,7 @@ import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import Notifications from 'components/notifications/notifications.vue'
import AuthForm from 'components/auth_form/auth_form.js'
import ShoutPanel from 'components/shout_panel/shout_panel.vue'
import ChatPanel from 'components/chat_panel/chat_panel.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import About from 'components/about/about.vue'
import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
@ -46,7 +46,7 @@ export default (store) => {
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'remote-user-profile-acct',
path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)',
path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute
},
@ -64,12 +64,12 @@ export default (store) => {
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm },
{ name: 'shout-panel', path: '/shout-panel', component: ShoutPanel, props: () => ({ floating: false }) },
{ name: 'chat-panel', path: '/chat-panel', component: ChatPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About },
{ name: 'user-profile', path: '/:_(users)?/:name', component: UserProfile }
{ name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
]
if (store.state.instance.pleromaChatMessagesAvailable) {

View file

@ -35,7 +35,7 @@ const AccountActions = {
this.$store.dispatch('unblockUser', this.user.id)
},
reportUser () {
this.$store.dispatch('openUserReportingModal', { userId: this.user.id })
this.$store.dispatch('openUserReportingModal', this.user.id)
},
openChat () {
this.$router.push({

View file

@ -4,21 +4,23 @@
trigger="click"
placement="bottom"
:bound-to="{ x: 'container' }"
remove-padding
>
<template v-slot:content>
<div
slot="content"
class="account-tools-popover"
>
<div class="dropdown-menu">
<template v-if="relationship.following">
<button
v-if="relationship.showing_reblogs"
class="btn button-default dropdown-item"
class="btn btn-default dropdown-item"
@click="hideRepeats"
>
{{ $t('user_card.hide_repeats') }}
</button>
<button
v-if="!relationship.showing_reblogs"
class="btn button-default dropdown-item"
class="btn btn-default dropdown-item"
@click="showRepeats"
>
{{ $t('user_card.show_repeats') }}
@ -30,41 +32,42 @@
</template>
<button
v-if="relationship.blocking"
class="btn button-default btn-block dropdown-item"
class="btn btn-default btn-block dropdown-item"
@click="unblockUser"
>
{{ $t('user_card.unblock') }}
</button>
<button
v-else
class="btn button-default btn-block dropdown-item"
class="btn btn-default btn-block dropdown-item"
@click="blockUser"
>
{{ $t('user_card.block') }}
</button>
<button
class="btn button-default btn-block dropdown-item"
class="btn btn-default btn-block dropdown-item"
@click="reportUser"
>
{{ $t('user_card.report') }}
</button>
<button
v-if="pleromaChatMessagesAvailable"
class="btn button-default btn-block dropdown-item"
class="btn btn-default btn-block dropdown-item"
@click="openChat"
>
{{ $t('user_card.message') }}
</button>
</div>
</template>
<template v-slot:trigger>
<button class="button-unstyled ellipsis-button">
<FAIcon
class="icon"
icon="ellipsis-v"
/>
</button>
</template>
</div>
<div
slot="trigger"
class="btn btn-default ellipsis-button"
>
<FAIcon
class="icon"
icon="ellipsis-v"
/>
</div>
</Popover>
</div>
</template>
@ -79,6 +82,7 @@
}
.ellipsis-button {
cursor: pointer;
width: 2.5em;
margin: -0.5em 0;
padding: 0.5em 0;

View file

@ -8,7 +8,7 @@
{{ $t('general.error_retry') }}
</p>
<button
class="btn button-default"
class="btn"
@click="retry"
>
{{ $t('general.retry') }}
@ -19,7 +19,6 @@
<script>
export default {
emits: ['resetAsyncComponent'],
methods: {
retry () {
this.$emit('resetAsyncComponent')

View file

@ -1,5 +1,4 @@
import StillImage from '../still-image/still-image.vue'
import Flash from '../flash/flash.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import nsfwImage from '../../assets/nsfw.png'
import fileTypeService from '../../services/file_type/file_type.service.js'
@ -9,80 +8,43 @@ import {
faFile,
faMusic,
faImage,
faVideo,
faPlayCircle,
faTimes,
faStop,
faSearchPlus,
faTrashAlt,
faPencilAlt,
faAlignRight
faVideo
} from '@fortawesome/free-solid-svg-icons'
library.add(
faFile,
faMusic,
faImage,
faVideo,
faPlayCircle,
faTimes,
faStop,
faSearchPlus,
faTrashAlt,
faPencilAlt,
faAlignRight
faVideo
)
const Attachment = {
props: [
'attachment',
'description',
'hideDescription',
'nsfw',
'size',
'allowPlay',
'setMedia',
'remove',
'shiftUp',
'shiftDn',
'edit'
'naturalSizeLoad'
],
data () {
return {
localDescription: this.description || this.attachment.description,
nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage,
hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw,
preloadImage: this.$store.getters.mergedConfig.preloadImage,
loading: false,
img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
modalOpen: false,
showHidden: false,
flashLoaded: false,
showDescription: false
showHidden: false
}
},
components: {
Flash,
StillImage,
VideoAttachment
},
computed: {
classNames () {
return [
{
'-loading': this.loading,
'-nsfw-placeholder': this.hidden,
'-editable': this.edit !== undefined
},
'-type-' + this.type,
this.size && '-size-' + this.size,
`-${this.useContainFit ? 'contain' : 'cover'}-fit`
]
},
usePlaceholder () {
return this.size === 'hide'
},
useContainFit () {
return this.$store.getters.mergedConfig.useContainFit
return this.size === 'hide' || this.type === 'unknown'
},
placeholderName () {
if (this.attachment.description === '' || !this.attachment.description) {
@ -106,33 +68,24 @@ const Attachment = {
return this.nsfw && this.hideNsfwLocal && !this.showHidden
},
isEmpty () {
return (this.type === 'html' && !this.attachment.oembed)
return (this.type === 'html' && !this.attachment.oembed) || this.type === 'unknown'
},
isSmall () {
return this.size === 'small'
},
fullwidth () {
if (this.size === 'hide') return false
return this.type === 'html' || this.type === 'audio' || this.type === 'unknown'
},
useModal () {
let modalTypes = []
switch (this.size) {
case 'hide':
case 'small':
modalTypes = ['image', 'video', 'audio', 'flash']
break
default:
modalTypes = this.mergedConfig.playVideosInModal
? ['image', 'video', 'flash']
: ['image']
break
}
const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio']
: this.mergedConfig.playVideosInModal
? ['image', 'video']
: ['image']
return modalTypes.includes(this.type)
},
videoTag () {
return this.useModal ? 'button' : 'span'
},
...mapGetters(['mergedConfig'])
},
watch: {
localDescription (newVal) {
this.onEdit(newVal)
}
},
methods: {
linkClicked ({ target }) {
if (target.tagName === 'A') {
@ -141,37 +94,12 @@ const Attachment = {
},
openModal (event) {
if (this.useModal) {
this.$emit('setMedia')
this.$store.dispatch('setCurrentMedia', this.attachment)
} else if (this.type === 'unknown') {
window.open(this.attachment.url)
event.stopPropagation()
event.preventDefault()
this.setMedia()
this.$store.dispatch('setCurrent', this.attachment)
}
},
openModalForce (event) {
this.$emit('setMedia')
this.$store.dispatch('setCurrentMedia', this.attachment)
},
onEdit (event) {
this.edit && this.edit(this.attachment, event)
},
onRemove () {
this.remove && this.remove(this.attachment)
},
onShiftUp () {
this.shiftUp && this.shiftUp(this.attachment)
},
onShiftDn () {
this.shiftDn && this.shiftDn(this.attachment)
},
stopFlash () {
this.$refs.flash.closePlayer()
},
setFlashLoaded (event) {
this.flashLoaded = event
},
toggleDescription () {
this.showDescription = !this.showDescription
},
toggleHidden (event) {
if (
(this.mergedConfig.useOneClickNsfw && !this.showHidden) &&
@ -198,7 +126,7 @@ const Attachment = {
onImageLoad (image) {
const width = image.naturalWidth
const height = image.naturalHeight
this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height })
this.naturalSizeLoad && this.naturalSizeLoad({ width, height })
}
}
}

View file

@ -1,268 +0,0 @@
@import '../../_variables.scss';
.Attachment {
display: inline-flex;
flex-direction: column;
position: relative;
align-self: flex-start;
line-height: 0;
height: 100%;
border-style: solid;
border-width: 1px;
border-radius: $fallback--attachmentRadius;
border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
.attachment-wrapper {
flex: 1 1 auto;
height: 100%;
position: relative;
overflow: hidden;
}
.description-container {
flex: 0 1 0;
display: flex;
padding-top: 0.5em;
z-index: 1;
p {
flex: 1;
text-align: center;
line-height: 1.5;
padding: 0.5em;
margin: 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
&.-static {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding-top: 0;
background: var(--popover);
box-shadow: var(--popupShadow);
}
}
.description-field {
flex: 1;
min-width: 0;
}
& .placeholder-container,
& .image-container,
& .audio-container,
& .video-container,
& .flash-container,
& .oembed-container {
display: flex;
justify-content: center;
width: 100%;
height: 100%;
}
.image-container {
.image {
width: 100%;
height: 100%;
}
}
& .flash-container,
& .video-container {
& .flash,
& video {
width: 100%;
height: 100%;
object-fit: contain;
align-self: center;
}
}
.audio-container {
display: flex;
align-items: flex-end;
audio {
width: 100%;
height: 100%;
}
}
.placeholder-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 0.5em;
}
.play-icon {
position: absolute;
font-size: 64px;
top: calc(50% - 32px);
left: calc(50% - 32px);
color: rgba(255, 255, 255, 0.75);
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
&::before {
margin: 0;
}
}
.attachment-buttons {
display: flex;
position: absolute;
right: 0;
top: 0;
margin-top: 0.5em;
margin-right: 0.5em;
z-index: 1;
.attachment-button {
padding: 0;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
text-align: center;
width: 2em;
height: 2em;
margin-left: 0.5em;
font-size: 1.25em;
// TODO: theming? hard to theme with unknown background image color
background: rgba(230, 230, 230, 0.7);
.svg-inline--fa {
color: rgba(0, 0, 0, 0.6);
}
&:hover .svg-inline--fa {
color: rgba(0, 0, 0, 0.9);
}
}
}
.oembed-container {
line-height: 1.2em;
flex: 1 0 100%;
width: 100%;
margin-right: 15px;
display: flex;
img {
width: 100%;
}
.image {
flex: 1;
img {
border: 0px;
border-radius: 5px;
height: 100%;
object-fit: cover;
}
}
.text {
flex: 2;
margin: 8px;
word-break: break-all;
h1 {
font-size: 14px;
margin: 0px;
}
}
}
&.-size-small {
.play-icon {
zoom: 0.5;
opacity: 0.7;
}
.attachment-buttons {
zoom: 0.7;
opacity: 0.5;
}
}
&.-editable {
padding: 0.5em;
& .description-container,
& .attachment-buttons {
margin: 0;
}
}
&.-placeholder {
display: inline-block;
color: $fallback--link;
color: var(--postLink, $fallback--link);
overflow: hidden;
white-space: nowrap;
height: auto;
line-height: 1.5;
&:not(.-editable) {
border: none;
}
&.-editable {
display: flex;
flex-direction: row;
align-items: baseline;
& .description-container,
& .attachment-buttons {
margin: 0;
padding: 0;
position: relative;
}
.description-container {
flex: 1;
padding-left: 0.5em;
}
.attachment-buttons {
order: 99;
align-self: center;
}
}
a {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
svg {
color: inherit;
}
}
&.-loading {
cursor: progress;
}
&.-contain-fit {
img,
canvas {
object-fit: contain;
}
}
&.-cover-fit {
img,
canvas {
object-fit: cover;
}
}
}

View file

@ -1,8 +1,7 @@
<template>
<button
<div
v-if="usePlaceholder"
class="Attachment -placeholder button-unstyled"
:class="classNames"
:class="{ 'fullwidth': fullwidth }"
@click="openModal"
>
<a
@ -12,257 +11,306 @@
:href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
@click.prevent
>
<FAIcon :icon="placeholderIconClass" />
<b>{{ nsfw ? "NSFW / " : "" }}</b>{{ edit ? '' : placeholderName }}
<b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }}
</a>
<div
v-if="edit || remove"
class="attachment-buttons"
>
<button
v-if="remove"
class="button-unstyled attachment-button"
@click.prevent="onRemove"
>
<FAIcon icon="trash-alt" />
</button>
</div>
<div
v-if="size !== 'hide' && !hideDescription && (edit || localDescription || showDescription)"
class="description-container"
:class="{ '-static': !edit }"
>
<input
v-if="edit"
v-model="localDescription"
type="text"
class="description-field"
:placeholder="$t('post_status.media_description')"
@keydown.enter.prevent=""
>
<p v-else>
{{ localDescription }}
</p>
</div>
</button>
</div>
<div
v-else
class="Attachment"
:class="classNames"
v-show="!isEmpty"
class="attachment"
:class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
>
<a
v-if="hidden"
class="image-attachment"
:href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
@click.prevent.stop="toggleHidden"
>
<img
:key="nsfwImage"
class="nsfw"
:src="nsfwImage"
:class="{'small': isSmall}"
>
<FAIcon
v-if="type === 'video'"
class="play-icon"
icon="play-circle"
/>
</a>
<div
v-show="!isEmpty"
class="attachment-wrapper"
v-if="nsfw && hideNsfwLocal && !hidden"
class="hider"
>
<a
v-if="hidden"
class="image-container"
:href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
@click.prevent.stop="toggleHidden"
>
<img
:key="nsfwImage"
class="nsfw"
:src="nsfwImage"
>
<FAIcon
v-if="type === 'video'"
class="play-icon"
icon="play-circle"
/>
</a>
<div
v-if="!hidden"
class="attachment-buttons"
>
<button
v-if="type === 'flash' && flashLoaded"
class="button-unstyled attachment-button"
:title="$t('status.attachment_stop_flash')"
@click.prevent="stopFlash"
>
<FAIcon icon="stop" />
</button>
<button
v-if="attachment.description && size !== 'small' && !edit && type !== 'unknown'"
class="button-unstyled attachment-button"
:title="$t('status.show_attachment_description')"
@click.prevent="toggleDescription"
>
<FAIcon icon="align-right" />
</button>
<button
v-if="!useModal && type !== 'unknown'"
class="button-unstyled attachment-button"
:title="$t('status.show_attachment_in_modal')"
@click.prevent="openModalForce"
>
<FAIcon icon="search-plus" />
</button>
<button
v-if="nsfw && hideNsfwLocal"
class="button-unstyled attachment-button"
:title="$t('status.hide_attachment')"
@click.prevent="toggleHidden"
>
<FAIcon icon="times" />
</button>
<button
v-if="shiftUp"
class="button-unstyled attachment-button"
:title="$t('status.move_up')"
@click.prevent="onShiftUp"
>
<FAIcon icon="chevron-left" />
</button>
<button
v-if="shiftDn"
class="button-unstyled attachment-button"
:title="$t('status.move_down')"
@click.prevent="onShiftDn"
>
<FAIcon icon="chevron-right" />
</button>
<button
v-if="remove"
class="button-unstyled attachment-button"
:title="$t('status.remove_attachment')"
@click.prevent="onRemove"
>
<FAIcon icon="trash-alt" />
</button>
</div>
<a
v-if="type === 'image' && (!hidden || preloadImage)"
class="image-container"
:class="{'-hidden': hidden && preloadImage }"
:href="attachment.url"
target="_blank"
@click.stop.prevent="openModal"
>
<StillImage
class="image"
:referrerpolicy="referrerpolicy"
:mimetype="attachment.mimetype"
:src="attachment.large_thumb_url || attachment.url"
:image-load-handler="onImageLoad"
:alt="attachment.description"
/>
</a>
<a
v-if="type === 'unknown' && !hidden"
class="placeholder-container"
:href="attachment.url"
target="_blank"
>
<FAIcon
size="5x"
:icon="placeholderIconClass"
/>
<p>
{{ localDescription }}
</p>
</a>
<component
:is="videoTag"
v-if="type === 'video' && !hidden"
class="video-container"
:class="{ 'button-unstyled': 'isModal' }"
:href="attachment.url"
@click.stop.prevent="openModal"
>
<VideoAttachment
class="video"
:attachment="attachment"
:controls="!useModal"
@play="$emit('play')"
@pause="$emit('pause')"
/>
<FAIcon
v-if="useModal"
class="play-icon"
icon="play-circle"
/>
</component>
<span
v-if="type === 'audio' && !hidden"
class="audio-container"
:href="attachment.url"
@click.stop.prevent="openModal"
>
<audio
v-if="type === 'audio'"
:src="attachment.url"
:alt="attachment.description"
:title="attachment.description"
controls
@play="$emit('play')"
@pause="$emit('pause')"
/>
</span>
<div
v-if="type === 'html' && attachment.oembed"
class="oembed-container"
@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" />
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
<span
v-if="type === 'flash' && !hidden"
class="flash-container"
:href="attachment.url"
@click.stop.prevent="openModal"
>
<Flash
ref="flash"
class="flash"
:src="attachment.large_thumb_url || attachment.url"
@playerOpened="setFlashLoaded(true)"
@playerClosed="setFlashLoaded(false)"
/>
</span>
href="#"
@click.prevent="toggleHidden"
>Hide</a>
</div>
<div
v-if="size !== 'hide' && !hideDescription && (edit || (localDescription && showDescription))"
class="description-container"
:class="{ '-static': !edit }"
<a
v-if="type === 'image' && (!hidden || preloadImage)"
class="image-attachment"
:class="{'hidden': hidden && preloadImage }"
:href="attachment.url"
target="_blank"
@click="openModal"
>
<input
v-if="edit"
v-model="localDescription"
type="text"
class="description-field"
:placeholder="$t('post_status.media_description')"
@keydown.enter.prevent=""
<StillImage
class="image"
:referrerpolicy="referrerpolicy"
:mimetype="attachment.mimetype"
:src="attachment.large_thumb_url || attachment.url"
:image-load-handler="onImageLoad"
:alt="attachment.description"
/>
</a>
<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"
@play="$emit('play')"
@pause="$emit('pause')"
/>
<FAIcon
v-if="!allowPlay"
class="play-icon"
icon="play-circle"
/>
</a>
<audio
v-if="type === 'audio'"
:src="attachment.url"
:alt="attachment.description"
:title="attachment.description"
controls
@play="$emit('play')"
@pause="$emit('pause')"
/>
<div
v-if="type === 'html' && attachment.oembed"
class="oembed"
@click.prevent="linkClicked"
>
<div
v-if="attachment.thumb_url"
class="image"
>
<p v-else>
{{ localDescription }}
</p>
<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" />
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
</div>
</template>
<script src="./attachment.js"></script>
<style src="./attachment.scss" lang="scss"></style>
<style lang="scss">
@import '../../_variables.scss';
.attachments {
display: flex;
flex-wrap: wrap;
.non-gallery {
max-width: 100%;
}
.placeholder {
display: inline-block;
padding: 0.3em 1em 0.3em 0;
color: $fallback--link;
color: var(--postLink, $fallback--link);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
svg {
color: inherit;
}
}
.nsfw-placeholder {
cursor: pointer;
&.loading {
cursor: progress;
}
}
.attachment {
position: relative;
margin-top: 0.5em;
align-self: flex-start;
line-height: 0;
border-style: solid;
border-width: 1px;
border-radius: $fallback--attachmentRadius;
border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
overflow: hidden;
}
.non-gallery.attachment {
&.video {
flex: 1 0 40%;
}
.nsfw {
height: 260px;
}
.small {
height: 120px;
flex-grow: 0;
}
.video {
height: 260px;
display: flex;
}
video {
max-height: 100%;
object-fit: contain;
}
}
.fullwidth {
flex-basis: 100%;
}
// fixes small gap below video
&.video {
line-height: 0;
}
.video-container {
display: flex;
max-height: 100%;
}
.video {
width: 100%;
height: 100%;
}
.play-icon {
position: absolute;
font-size: 64px;
top: calc(50% - 32px);
left: calc(50% - 32px);
color: rgba(255, 255, 255, 0.75);
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
}
.play-icon::before {
margin: 0;
}
&.html {
flex-basis: 90%;
width: 100%;
display: flex;
}
.hider {
position: absolute;
right: 0;
white-space: nowrap;
margin: 10px;
padding: 5px;
background: rgba(230,230,230,0.6);
font-weight: bold;
z-index: 4;
line-height: 1;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
}
video {
z-index: 0;
}
audio {
width: 100%;
}
img.media-upload {
line-height: 0;
max-height: 200px;
max-width: 100%;
}
.oembed {
line-height: 1.2em;
flex: 1 0 100%;
width: 100%;
margin-right: 15px;
display: flex;
img {
width: 100%;
}
.image {
flex: 1;
img {
border: 0px;
border-radius: 5px;
height: 100%;
object-fit: cover;
}
}
.text {
flex: 2;
margin: 8px;
word-break: break-all;
h1 {
font-size: 14px;
margin: 0px;
}
}
}
.image-attachment {
&,
& .image {
width: 100%;
height: 100%;
}
&.hidden {
display: none;
}
.nsfw {
object-fit: cover;
width: 100%;
height: 100%;
}
img {
image-orientation: from-image; // NOTE: only FF supports this
}
}
}
</style>

View file

@ -1,4 +1,3 @@
import { h, resolveComponent } from 'vue'
import LoginForm from '../login_form/login_form.vue'
import MFARecoveryForm from '../mfa_form/recovery_form.vue'
import MFATOTPForm from '../mfa_form/totp_form.vue'
@ -6,8 +5,8 @@ import { mapGetters } from 'vuex'
const AuthForm = {
name: 'AuthForm',
render () {
return h(resolveComponent(this.authForm))
render (createElement) {
return createElement('component', { is: this.authForm })
},
computed: {
authForm () {

View file

@ -1,6 +1,5 @@
import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const BasicUserCard = {
@ -14,8 +13,7 @@ const BasicUserCard = {
},
components: {
UserCard,
UserAvatar,
RichContent
UserAvatar
},
methods: {
toggleUserExpanded () {

View file

@ -4,7 +4,7 @@
<UserAvatar
class="avatar"
:user="user"
@click.prevent="toggleUserExpanded"
@click.prevent.native="toggleUserExpanded"
/>
</router-link>
<div
@ -25,18 +25,24 @@
:title="user.name"
class="basic-user-card-user-name"
>
<RichContent
<!-- eslint-disable vue/no-v-html -->
<span
v-if="user.name_html"
class="basic-user-card-user-name-value"
:html="user.name"
:emoji="user.emoji"
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)"
>
@{{ user.screen_name_ui }}
@{{ user.screen_name }}
</router-link>
</div>
<slot />

View file

@ -3,7 +3,7 @@
<div class="block-card-content-container">
<button
v-if="blocked"
class="btn button-default"
class="btn btn-default"
:disabled="progress"
@click="unblockUser"
>
@ -16,7 +16,7 @@
</button>
<button
v-else
class="btn button-default"
class="btn btn-default"
:disabled="progress"
@click="blockUser"
>

View file

@ -9,7 +9,7 @@ const Bookmarks = {
components: {
Timeline
},
unmounted () {
destroyed () {
this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
}
}

View file

@ -6,7 +6,7 @@ import PostStatusForm from '../post_status_form/post_status_form.vue'
import ChatTitle from '../chat_title/chat_title.vue'
import chatService from '../../services/chat_service/chat_service.js'
import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight, isScrollable } from './chat_layout_utils.js'
import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight } from './chat_layout_utils.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronDown,
@ -57,7 +57,7 @@ const Chat = {
})
this.setChatLayout()
},
unmounted () {
destroyed () {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('resize', this.handleLayoutChange)
this.unsetChatLayout()
@ -73,7 +73,7 @@ const Chat = {
},
formPlaceholder () {
if (this.recipient) {
return this.$t('chats.message_user', { nickname: this.recipient.screen_name_ui })
return this.$t('chats.message_user', { nickname: this.recipient.screen_name })
} else {
return ''
}
@ -234,13 +234,6 @@ const Chat = {
const scrollable = this.$refs.scrollable
return scrollable && scrollable.scrollTop <= 0
},
cullOlderCheck () {
window.setTimeout(() => {
if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.$store.dispatch('cullOlderMessages', this.currentChatMessageService.chatId)
}
}, 5000)
},
handleScroll: _.throttle(function () {
if (!this.currentChat) { return }
@ -248,7 +241,6 @@ const Chat = {
this.fetchChat({ maxId: this.currentChatMessageService.minId })
} else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.jumpToBottomButtonVisible = false
this.cullOlderCheck()
if (this.newMessageCount > 0) {
// Use a delay before marking as read to prevent situation where new messages
// arrive just as you're leaving the view and messages that you didn't actually
@ -295,14 +287,6 @@ const Chat = {
if (isFirstFetch) {
this.updateScrollableContainerHeight()
}
// In vertical screens, the first batch of fetched messages may not always take the
// full height of the scrollable container.
// If this is the case, we want to fetch the messages until the scrollable container
// is fully populated so that the user has the ability to scroll up and load the history.
if (!isScrollable(this.$refs.scrollable) && messages.length > 0) {
this.fetchChat({ maxId: this.currentChatMessageService.minId })
}
})
})
})

View file

@ -98,10 +98,10 @@
.unread-message-count {
font-size: 0.8em;
left: 50%;
transform: translate(-50%, 0);
border-radius: 100%;
margin-top: -1rem;
padding: 0.1em;
border-radius: 50px;
position: absolute;
padding: 0;
}
.chat-loading-error {
@ -138,21 +138,11 @@
}
.chat-view-heading {
box-sizing: border-box;
position: static;
z-index: 9999;
top: 0;
margin-top: 0;
border-radius: 0;
/* This practically overlays the panel heading color over panel background
* color. This is needed because we allow transparent panel background and
* it doesn't work well in this "disjointed panel header" case
*/
background:
linear-gradient(to top, var(--panel), var(--panel)),
linear-gradient(to top, var(--bg), var(--bg));
height: 50px;
}
.scrollable-message-list {

View file

@ -26,71 +26,73 @@
/>
</div>
</div>
<div
ref="scrollable"
class="scrollable-message-list"
:style="{ height: scrollableContainerHeight }"
@scroll="handleScroll"
>
<template v-if="!errorLoadingChat">
<ChatMessage
v-for="chatViewItem in chatViewItems"
:key="chatViewItem.id"
:author="recipient"
:chat-view-item="chatViewItem"
:hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId"
@hover="onMessageHover"
/>
</template>
<template>
<div
v-else
class="chat-loading-error"
ref="scrollable"
class="scrollable-message-list"
:style="{ height: scrollableContainerHeight }"
@scroll="handleScroll"
>
<div class="alert error">
{{ $t('chats.error_loading_chat') }}
<template v-if="!errorLoadingChat">
<ChatMessage
v-for="chatViewItem in chatViewItems"
:key="chatViewItem.id"
:author="recipient"
:chat-view-item="chatViewItem"
:hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId"
@hover="onMessageHover"
/>
</template>
<div
v-else
class="chat-loading-error"
>
<div class="alert error">
{{ $t('chats.error_loading_chat') }}
</div>
</div>
</div>
</div>
<div
ref="footer"
class="panel-body footer"
>
<div
class="jump-to-bottom-button"
:class="{ 'visible': jumpToBottomButtonVisible }"
@click="scrollDown({ behavior: 'smooth' })"
ref="footer"
class="panel-body footer"
>
<span>
<FAIcon icon="chevron-down" />
<div
v-if="newMessageCount"
class="badge badge-notification unread-chat-count unread-message-count"
>
{{ newMessageCount }}
</div>
</span>
<div
class="jump-to-bottom-button"
:class="{ 'visible': jumpToBottomButtonVisible }"
@click="scrollDown({ behavior: 'smooth' })"
>
<span>
<FAIcon icon="chevron-down" />
<div
v-if="newMessageCount"
class="badge badge-notification unread-chat-count unread-message-count"
>
{{ newMessageCount }}
</div>
</span>
</div>
<PostStatusForm
:disable-subject="true"
:disable-scope-selector="true"
:disable-notice="true"
:disable-lock-warning="true"
:disable-polls="true"
:disable-sensitivity-checkbox="true"
:disable-submit="errorLoadingChat || !currentChat"
:disable-preview="true"
:optimistic-posting="true"
:post-handler="sendMessage"
:submit-on-enter="!mobileLayout"
:preserve-focus="!mobileLayout"
:auto-focus="!mobileLayout"
:placeholder="formPlaceholder"
:file-limit="1"
max-height="160"
emoji-picker-placement="top"
@resize="handleResize"
/>
</div>
<PostStatusForm
:disable-subject="true"
:disable-scope-selector="true"
:disable-notice="true"
:disable-lock-warning="true"
:disable-polls="true"
:disable-sensitivity-checkbox="true"
:disable-submit="errorLoadingChat || !currentChat"
:disable-preview="true"
:optimistic-posting="true"
:post-handler="sendMessage"
:submit-on-enter="!mobileLayout"
:preserve-focus="!mobileLayout"
:auto-focus="!mobileLayout"
:placeholder="formPlaceholder"
:file-limit="1"
max-height="160"
emoji-picker-placement="top"
@resize="handleResize"
/>
</div>
</template>
</div>
</div>
</div>

View file

@ -24,10 +24,3 @@ export const isBottomedOut = (el, offset = 0) => {
export const scrollableContainerHeight = (inner, header, footer) => {
return inner.offsetHeight - header.clientHeight - footer.clientHeight
}
// Returns whether or not the scrollbar is visible.
export const isScrollable = (el) => {
if (!el) return
return el.scrollHeight > el.clientHeight
}

View file

@ -10,10 +10,7 @@
<span class="title">
{{ $t("chats.chats") }}
</span>
<button
class="button-default"
@click="newChat"
>
<button @click="newChat">
{{ $t("chats.new") }}
</button>
</div>
@ -23,7 +20,10 @@
class="timeline"
>
<List :items="sortedChatList">
<template v-slot:item="{item}">
<template
slot="item"
slot-scope="{item}"
>
<ChatListItem
:key="item.id"
:compact="false"

View file

@ -1,5 +1,5 @@
import { mapState } from 'vuex'
import StatusBody from '../status_content/status_content.vue'
import StatusContent from '../status_content/status_content.vue'
import fileType from 'src/services/file_type/file_type.service'
import UserAvatar from '../user_avatar/user_avatar.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
@ -16,7 +16,7 @@ const ChatListItem = {
AvatarList,
Timeago,
ChatTitle,
StatusBody
StatusContent
},
computed: {
...mapState({
@ -38,14 +38,12 @@ const ChatListItem = {
},
messageForStatusContent () {
const message = this.chat.lastMessage
const messageEmojis = message ? message.emojis : []
const isYou = message && message.account_id === this.currentUser.id
const content = message ? (this.attachmentInfo || message.content) : ''
const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content
return {
summary: '',
emojis: messageEmojis,
raw_html: messagePreview,
statusnet_html: messagePreview,
text: messagePreview,
attachments: []
}

View file

@ -77,15 +77,18 @@
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
}
.chat-preview-body {
--emoji-size: 1.4em;
.StatusContent {
img.emoji {
width: 1.4em;
height: 1.4em;
}
}
.time-wrapper {
line-height: 1.4em;
}
.chat-preview-body {
.single-line {
padding-right: 1em;
}
}

View file

@ -21,16 +21,9 @@
/>
</span>
<span class="heading-right" />
<div class="time-wrapper">
<Timeago
:time="chat.updated_at"
:auto-update="60"
/>
</div>
</div>
<div class="chat-preview">
<StatusBody
class="chat-preview-body"
<StatusContent
:status="messageForStatusContent"
:single-line="true"
/>
@ -42,6 +35,12 @@
</div>
</div>
</div>
<div class="time-wrapper">
<Timeago
:time="chat.updated_at"
:auto-update="60"
/>
</div>
</div>
</template>

View file

@ -27,7 +27,6 @@ const ChatMessage = {
'chatViewItem',
'hoveredMessageChain'
],
emits: ['hover'],
components: {
Popover,
Attachment,
@ -58,9 +57,8 @@ const ChatMessage = {
messageForStatusContent () {
return {
summary: '',
emojis: this.message.emojis,
raw_html: this.message.content || '',
text: this.message.content || '',
statusnet_html: this.message.content,
text: this.message.content,
attachments: this.message.attachments
}
},

View file

@ -1,7 +1,6 @@
@import '../../_variables.scss';
.chat-message-wrapper {
&.hovered-message-chain {
.animated.Avatar {
canvas {
@ -32,6 +31,9 @@
color: $fallback--text;
color: var(--text, $fallback--text);
}
border-radius: $fallback--chatMessageRadius;
border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
}
.popover {
@ -41,12 +43,6 @@
.chat-message {
display: flex;
padding-bottom: 0.5em;
.status-body:hover {
--_still-image-img-visibility: visible;
--_still-image-canvas-visibility: hidden;
--_still-image-label-visibility: hidden;
}
}
.avatar-wrapper {
@ -69,6 +65,10 @@
&.with-media {
width: 100%;
.gallery-row {
overflow: hidden;
}
.status {
width: 100%;
}
@ -92,9 +92,8 @@
}
.without-attachment {
.message-content {
// TODO figure out how to do it properly
.RichContent::after {
.status-content {
&::after {
margin-right: 5.4em;
content: " ";
display: inline-block;
@ -166,7 +165,6 @@
.visible {
opacity: 1;
}
}
.chat-message-date-separator {

View file

@ -50,38 +50,35 @@
@show="menuOpened = true"
@close="menuOpened = false"
>
<template v-slot:content>
<div slot="content">
<div class="dropdown-menu">
<button
class="button-default dropdown-item dropdown-item-icon"
class="dropdown-item dropdown-item-icon"
@click="deleteMessage"
>
<FAIcon icon="times" /> {{ $t("chats.delete") }}
</button>
</div>
</template>
<template v-slot:trigger>
<button
class="button-default menu-icon"
:title="$t('chats.more')"
>
<FAIcon icon="ellipsis-h" />
</button>
</template>
</div>
<button
slot="trigger"
class="menu-icon"
:title="$t('chats.more')"
>
<FAIcon icon="ellipsis-h" />
</button>
</Popover>
</div>
<StatusContent
class="message-content"
:status="messageForStatusContent"
:full-content="true"
>
<template v-slot:footer>
<span
class="created-at"
>
{{ createdAt }}
</span>
</template>
<span
slot="footer"
class="created-at"
>
{{ createdAt }}
</span>
</StatusContent>
</div>
</div>

View file

@ -5,8 +5,6 @@
</template>
<script>
import localeService from 'src/services/locale/locale.service.js'
export default {
name: 'Timeago',
props: ['date'],
@ -18,7 +16,7 @@ export default {
if (this.date.getTime() === today.getTime()) {
return this.$t('display_date.today')
} else {
return this.date.toLocaleDateString(localeService.internalToBrowserLocale(this.$i18n.locale), { day: 'numeric', month: 'long' })
return this.date.toLocaleDateString('en', { day: 'numeric', month: 'long' })
}
}
}

View file

@ -10,7 +10,7 @@ library.add(
faTimes
)
const shoutPanel = {
const chatPanel = {
props: [ 'floating' ],
data () {
return {
@ -21,12 +21,12 @@ const shoutPanel = {
},
computed: {
messages () {
return this.$store.state.shout.messages
return this.$store.state.chat.messages
}
},
methods: {
submit (message) {
this.$store.state.shout.channel.push('new_msg', { text: message }, 10000)
this.$store.state.chat.channel.push('new_msg', { text: message }, 10000)
this.currentMessage = ''
},
togglePanel () {
@ -35,19 +35,7 @@ const shoutPanel = {
userProfileLink (user) {
return generateProfileLink(user.id, user.username, this.$store.state.instance.restrictedNicknames)
}
},
watch: {
messages (newVal) {
const scrollEl = this.$el.querySelector('.chat-window')
if (!scrollEl) return
if (scrollEl.scrollTop + scrollEl.offsetHeight + 20 > scrollEl.scrollHeight) {
this.$nextTick(() => {
if (!scrollEl) return
scrollEl.scrollTop = scrollEl.scrollHeight - scrollEl.offsetHeight
})
}
}
}
}
export default shoutPanel
export default chatPanel

View file

@ -1,50 +1,52 @@
<template>
<div
v-if="!collapsed || !floating"
class="shout-panel"
class="chat-panel"
>
<div class="panel panel-default">
<div
class="panel-heading timeline-heading"
:class="{ 'shout-heading': floating }"
:class="{ 'chat-heading': floating }"
@click.stop.prevent="togglePanel"
>
<div class="title">
{{ $t('shoutbox.title') }}
<span>{{ $t('shoutbox.title') }}</span>
<FAIcon
v-if="floating"
icon="times"
class="close-icon"
/>
</div>
</div>
<div class="shout-window">
<div
v-chat-scroll
class="chat-window"
>
<div
v-for="message in messages"
:key="message.id"
class="shout-message"
class="chat-message"
>
<span class="shout-avatar">
<span class="chat-avatar">
<img :src="message.author.avatar">
</span>
<div class="shout-content">
<div class="chat-content">
<router-link
class="shout-name"
class="chat-name"
:to="userProfileLink(message.author)"
>
{{ message.author.username }}
</router-link>
<br>
<span class="shout-text">
<span class="chat-text">
{{ message.text }}
</span>
</div>
</div>
</div>
<div class="shout-input">
<div class="chat-input">
<textarea
v-model="currentMessage"
class="shout-input-textarea"
class="chat-input-textarea"
rows="1"
@keyup.enter="submit(currentMessage)"
/>
@ -53,11 +55,11 @@
</div>
<div
v-else
class="shout-panel"
class="chat-panel"
>
<div class="panel panel-default">
<div
class="panel-heading stub timeline-heading shout-heading"
class="panel-heading stub timeline-heading chat-heading"
@click.stop.prevent="togglePanel"
>
<div class="title">
@ -72,59 +74,45 @@
</div>
</template>
<script src="./shout_panel.js"></script>
<script src="./chat_panel.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.floating-shout {
.floating-chat {
position: fixed;
right: 0px;
bottom: 0px;
z-index: 1000;
max-width: 25em;
}
.floating-shout.left {
left: 0px;
}
.floating-shout:not(.left) {
right: 0px;
}
.shout-panel {
.shout-heading {
.chat-panel {
.chat-heading {
cursor: pointer;
.icon {
color: $fallback--text;
color: var(--panelText, $fallback--text);
margin-right: 0.5em;
}
.title {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text, $fallback--text);
}
}
.shout-window {
.chat-window {
overflow-y: auto;
overflow-x: hidden;
max-height: 20em;
}
.shout-window-container {
.chat-window-container {
height: 100%;
}
.shout-message {
.chat-message {
display: flex;
padding: 0.2em 0.5em
}
.shout-avatar {
.chat-avatar {
img {
height: 24px;
width: 24px;
@ -135,7 +123,7 @@
}
}
.shout-input {
.chat-input {
display: flex;
textarea {
flex: 1;
@ -145,7 +133,7 @@
}
}
.shout-panel {
.chat-panel {
.title {
display: flex;
justify-content: space-between;

View file

@ -1,19 +1,18 @@
import Vue from 'vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import UserAvatar from '../user_avatar/user_avatar.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
export default {
export default Vue.component('chat-title', {
name: 'ChatTitle',
components: {
UserAvatar,
RichContent
UserAvatar
},
props: [
'user', 'withAvatar'
],
computed: {
title () {
return this.user ? this.user.screen_name_ui : ''
return this.user ? this.user.screen_name : ''
},
htmlTitle () {
return this.user ? this.user.name_html : ''
@ -24,4 +23,4 @@ export default {
return generateProfileLink(user.id, user.screen_name)
}
}
}
})

View file

@ -1,4 +1,5 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<div
class="chat-title"
:title="title"
@ -13,14 +14,12 @@
height="23px"
/>
</router-link>
<RichContent
v-if="user"
<span
class="username"
:title="'@'+user.screen_name_ui"
:html="htmlTitle"
:emoji="user.emoji || []"
v-html="htmlTitle"
/>
</div>
<!-- eslint-enable vue/no-v-html -->
</template>
<script src="./chat_title.js"></script>
@ -35,8 +34,6 @@
white-space: nowrap;
align-items: center;
--emoji-size: 14px;
.username {
max-width: 100%;
text-overflow: ellipsis;
@ -44,6 +41,14 @@
display: inline;
word-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
.emoji {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
.Avatar {

View file

@ -6,9 +6,9 @@
<input
type="checkbox"
:disabled="disabled"
:checked="modelValue"
:indeterminate="indeterminate"
@change="$emit('update:modelValue', $event.target.checked)"
:checked="checked"
:indeterminate.prop="indeterminate"
@change="$emit('change', $event.target.checked)"
>
<i class="checkbox-indicator" />
<span
@ -22,9 +22,12 @@
<script>
export default {
emits: ['update:modelValue'],
model: {
prop: 'checked',
event: 'change'
},
props: [
'modelValue',
'checked',
'indeterminate',
'disabled'
]

View file

@ -11,28 +11,28 @@
</label>
<Checkbox
v-if="typeof fallback !== 'undefined' && showOptionalTickbox"
:model-value="present"
:checked="present"
:disabled="disabled"
class="opt"
@update:modelValue="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
@change="$emit('input', typeof value === 'undefined' ? fallback : undefined)"
/>
<div class="input color-input-field">
<input
:id="name + '-t'"
class="textColor unstyled"
type="text"
:value="modelValue || fallback"
:value="value || fallback"
:disabled="!present || disabled"
@input="$emit('update:modelValue', $event.target.value)"
@input="$emit('input', $event.target.value)"
>
<input
v-if="validColor"
:id="name"
class="nativeColor unstyled"
type="color"
:value="modelValue || fallback"
:value="value || fallback"
:disabled="!present || disabled"
@input="$emit('update:modelValue', $event.target.value)"
@input="$emit('input', $event.target.value)"
>
<div
v-if="transparentColor"
@ -67,7 +67,7 @@ export default {
},
// Color value, should be required but vue cannot tell the difference
// between "property missing" and "property set to undefined"
modelValue: {
value: {
required: false,
type: String,
default: undefined
@ -91,19 +91,18 @@ export default {
default: true
}
},
emits: ['update:modelValue'],
computed: {
present () {
return typeof this.modelValue !== 'undefined'
return typeof this.value !== 'undefined'
},
validColor () {
return hex2rgb(this.modelValue || this.fallback)
return hex2rgb(this.value || this.fallback)
},
transparentColor () {
return this.modelValue === 'transparent'
return this.value === 'transparent'
},
computedColor () {
return this.modelValue && this.modelValue.startsWith('--')
return this.value && this.value.startsWith('--')
}
}
}

View file

@ -1,19 +1,5 @@
import { reduce, filter, findIndex, clone, get } from 'lodash'
import Status from '../status/status.vue'
import ThreadTree from '../thread_tree/thread_tree.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft
)
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
@ -49,10 +35,7 @@ const conversation = {
data () {
return {
highlight: null,
expanded: false,
threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
statusContentPropertiesObject: {},
inlineDivePosition: null
expanded: false
}
},
props: [
@ -70,50 +53,12 @@ const conversation = {
}
},
computed: {
maxDepthToShowByDefault () {
// maxDepthInThread = max number of depths that is *visible*
// since our depth starts with 0 and "showing" means "showing children"
// there is a -2 here
const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1
},
displayStyle () {
return this.$store.getters.mergedConfig.conversationDisplay
},
isTreeView () {
return !this.isLinearView
},
treeViewIsSimple () {
return !this.$store.getters.mergedConfig.conversationTreeAdvanced
},
isLinearView () {
return this.displayStyle === 'linear'
},
shouldFadeAncestors () {
return this.$store.getters.mergedConfig.conversationTreeFadeAncestors
},
otherRepliesButtonPosition () {
return this.$store.getters.mergedConfig.conversationOtherRepliesButton
},
showOtherRepliesButtonBelowStatus () {
return this.otherRepliesButtonPosition === 'below'
},
showOtherRepliesButtonInsideStatus () {
return this.otherRepliesButtonPosition === 'inside'
},
suspendable () {
if (this.isTreeView) {
return Object.entries(this.statusContentProperties)
.every(([k, prop]) => !prop.replying && prop.mediaPlaying.length === 0)
}
if (this.$refs.statusComponent && this.$refs.statusComponent[0]) {
return this.$refs.statusComponent.every(s => s.suspendable)
} else {
return true
}
},
hideStatus () {
return this.virtualHidden && this.suspendable
if (this.$refs.statusComponent && this.$refs.statusComponent[0]) {
return this.virtualHidden && this.$refs.statusComponent[0].suspendable
} else {
return this.virtualHidden
}
},
status () {
return this.$store.state.statuses.allStatusesObject[this.statusId]
@ -145,121 +90,6 @@ const conversation = {
return sortAndFilterConversation(conversation, this.status)
},
statusMap () {
return this.conversation.reduce((res, s) => {
res[s.id] = s
return res
}, {})
},
threadTree () {
const reverseLookupTable = this.conversation.reduce((table, status, index) => {
table[status.id] = index
return table
}, {})
const threads = this.conversation.reduce((a, cur) => {
const id = cur.id
a.forest[id] = this.getReplies(id)
.map(s => s.id)
return a
}, {
forest: {}
})
const walk = (forest, topLevel, depth = 0, processed = {}) => topLevel.map(id => {
if (processed[id]) {
return []
}
processed[id] = true
return [{
status: this.conversation[reverseLookupTable[id]],
id,
depth
}, walk(forest, forest[id], depth + 1, processed)].reduce((a, b) => a.concat(b), [])
}).reduce((a, b) => a.concat(b), [])
const linearized = walk(threads.forest, this.topLevel.map(k => k.id))
return linearized
},
replyIds () {
return this.conversation.map(k => k.id)
.reduce((res, id) => {
res[id] = (this.replies[id] || []).map(k => k.id)
return res
}, {})
},
totalReplyCount () {
const sizes = {}
const subTreeSizeFor = (id) => {
if (sizes[id]) {
return sizes[id]
}
sizes[id] = 1 + this.replyIds[id].map(cid => subTreeSizeFor(cid)).reduce((a, b) => a + b, 0)
return sizes[id]
}
this.conversation.map(k => k.id).map(subTreeSizeFor)
return Object.keys(sizes).reduce((res, id) => {
res[id] = sizes[id] - 1 // exclude itself
return res
}, {})
},
totalReplyDepth () {
const depths = {}
const subTreeDepthFor = (id) => {
if (depths[id]) {
return depths[id]
}
depths[id] = 1 + this.replyIds[id].map(cid => subTreeDepthFor(cid)).reduce((a, b) => a > b ? a : b, 0)
return depths[id]
}
this.conversation.map(k => k.id).map(subTreeDepthFor)
return Object.keys(depths).reduce((res, id) => {
res[id] = depths[id] - 1 // exclude itself
return res
}, {})
},
depths () {
return this.threadTree.reduce((a, k) => {
a[k.id] = k.depth
return a
}, {})
},
topLevel () {
const topLevel = this.conversation.reduce((tl, cur) =>
tl.filter(k => this.getReplies(cur.id).map(v => v.id).indexOf(k.id) === -1), this.conversation)
return topLevel
},
otherTopLevelCount () {
return this.topLevel.length - 1
},
showingTopLevel () {
if (this.canDive && this.diveRoot) {
return [this.statusMap[this.diveRoot]]
}
return this.topLevel
},
diveRoot () {
const statusId = this.inlineDivePosition || this.statusId
const isTopLevel = !this.parentOf(statusId)
return isTopLevel ? null : statusId
},
diveDepth () {
return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0
},
diveMode () {
return this.canDive && !!this.diveRoot
},
shouldShowAllConversationButton () {
// The "show all conversation" button tells the user that there exist
// other toplevel statuses, so do not show it if there is only a single root
return this.isTreeView && this.isExpanded && this.diveMode && this.topLevel.length > 1
},
shouldShowAncestors () {
return this.isTreeView && this.isExpanded && this.ancestorsOf(this.diveRoot).length
},
replies () {
let i = 1
// eslint-disable-next-line camelcase
@ -279,71 +109,15 @@ const conversation = {
}, {})
},
isExpanded () {
return !!(this.expanded || this.isPage)
return this.expanded || this.isPage
},
hiddenStyle () {
const height = (this.status && this.status.virtualHeight) || '120px'
return this.virtualHidden ? { height } : {}
},
threadDisplayStatus () {
return this.conversation.reduce((a, k) => {
const id = k.id
const depth = this.depths[id]
const status = (() => {
if (this.threadDisplayStatusObject[id]) {
return this.threadDisplayStatusObject[id]
}
if ((depth - this.diveDepth) <= this.maxDepthToShowByDefault) {
return 'showing'
} else {
return 'hidden'
}
})()
a[id] = status
return a
}, {})
},
statusContentProperties () {
return this.conversation.reduce((a, k) => {
const id = k.id
const props = (() => {
const def = {
showingTall: false,
expandingSubject: false,
showingLongSubject: false,
isReplying: false,
mediaPlaying: []
}
if (this.statusContentPropertiesObject[id]) {
return {
...def,
...this.statusContentPropertiesObject[id]
}
}
return def
})()
a[id] = props
return a
}, {})
},
canDive () {
return this.isTreeView && this.isExpanded
},
focused () {
return (id) => {
return (this.isExpanded) && id === this.highlight
}
},
maybeHighlight () {
return this.isExpanded ? this.highlight : null
}
},
components: {
Status,
ThreadTree
Status
},
watch: {
statusId (newVal, oldVal) {
@ -358,8 +132,6 @@ const conversation = {
expanded (value) {
if (value) {
this.fetchConversation()
} else {
this.resetDisplayState()
}
},
virtualHidden (value) {
@ -389,8 +161,8 @@ const conversation = {
getReplies (id) {
return this.replies[id] || []
},
getHighlight () {
return this.isExpanded ? this.highlight : null
focused (id) {
return (this.isExpanded) && id === this.statusId
},
setHighlight (id) {
if (!id) return
@ -398,139 +170,15 @@ const conversation = {
this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
},
getHighlight () {
return this.isExpanded ? this.highlight : null
},
toggleExpanded () {
this.expanded = !this.expanded
},
getConversationId (statusId) {
const status = this.$store.state.statuses.allStatusesObject[statusId]
return get(status, 'retweeted_status.statusnet_conversation_id', get(status, 'statusnet_conversation_id'))
},
setThreadDisplay (id, nextStatus) {
this.threadDisplayStatusObject = {
...this.threadDisplayStatusObject,
[id]: nextStatus
}
},
toggleThreadDisplay (id) {
const curStatus = this.threadDisplayStatus[id]
const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
this.setThreadDisplay(id, nextStatus)
},
setThreadDisplayRecursively (id, nextStatus) {
this.setThreadDisplay(id, nextStatus)
this.getReplies(id).map(k => k.id).map(id => this.setThreadDisplayRecursively(id, nextStatus))
},
showThreadRecursively (id) {
this.setThreadDisplayRecursively(id, 'showing')
},
setStatusContentProperty (id, name, value) {
this.statusContentPropertiesObject = {
...this.statusContentPropertiesObject,
[id]: {
...this.statusContentPropertiesObject[id],
[name]: value
}
}
},
toggleStatusContentProperty (id, name) {
this.setStatusContentProperty(id, name, !this.statusContentProperties[id][name])
},
leastVisibleAncestor (id) {
let cur = id
let parent = this.parentOf(cur)
while (cur) {
// if the parent is showing it means cur is visible
if (this.threadDisplayStatus[parent] === 'showing') {
return cur
}
parent = this.parentOf(parent)
cur = this.parentOf(cur)
}
// nothing found, fall back to toplevel
return this.topLevel[0] ? this.topLevel[0].id : undefined
},
diveIntoStatus (id, preventScroll) {
this.tryScrollTo(id)
},
diveToTopLevel () {
this.tryScrollTo(this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id)
},
// only used when we are not on a page
undive () {
this.inlineDivePosition = null
this.setHighlight(this.statusId)
},
tryScrollTo (id) {
if (!id) {
return
}
if (this.isPage) {
// set statusId
this.$router.push({ name: 'conversation', params: { id } })
} else {
this.inlineDivePosition = id
}
// Because the conversation can be unmounted when out of sight
// and mounted again when it comes into sight,
// the `mounted` or `created` function in `status` should not
// contain scrolling calls, as we do not want the page to jump
// when we scroll with an expanded conversation.
//
// Now the method is to rely solely on the `highlight` watcher
// in `status` components.
// In linear views, all statuses are rendered at all times, but
// in tree views, it is possible that a change in active status
// removes and adds status components (e.g. an originally child
// status becomes an ancestor status, and thus they will be
// different).
// Here, let the components be rendered first, in order to trigger
// the `highlight` watcher.
this.$nextTick(() => {
this.setHighlight(id)
})
},
goToCurrent () {
this.tryScrollTo(this.diveRoot || this.topLevel[0].id)
},
statusById (id) {
return this.statusMap[id]
},
parentOf (id) {
const status = this.statusById(id)
if (!status) {
return undefined
}
const { in_reply_to_status_id: parentId } = status
if (!this.statusMap[parentId]) {
return undefined
}
return parentId
},
parentOrSelf (id) {
return this.parentOf(id) || id
},
// Ancestors of some status, from top to bottom
ancestorsOf (id) {
const ancestors = []
let cur = this.parentOf(id)
while (cur) {
ancestors.unshift(this.statusMap[cur])
cur = this.parentOf(cur)
}
return ancestors
},
topLevelAncestorOrSelfId (id) {
let cur = id
let parent = this.parentOf(id)
while (parent) {
cur = this.parentOf(cur)
parent = this.parentOf(parent)
}
return cur
},
resetDisplayState () {
this.undive()
this.threadDisplayStatusObject = {}
}
}
}

View file

@ -10,184 +10,31 @@
class="panel-heading conversation-heading"
>
<span class="title"> {{ $t('timeline.conversation') }} </span>
<button
v-if="collapsable"
class="button-unstyled -link"
@click.prevent="toggleExpanded"
>
{{ $t('timeline.collapse') }}
</button>
</div>
<div class="conversation-body panel-body">
<div
v-if="isTreeView"
class="thread-body"
>
<div
v-if="shouldShowAllConversationButton"
class="conversation-dive-to-top-level-box"
>
<i18n-t
keypath="status.show_all_conversation_with_icon"
tag="button"
class="button-unstyled -link"
@click.prevent="diveToTopLevel"
scope="global"
>
<template #icon>
<FAIcon
icon="angle-double-left"
/>
</template>
<template #text>
<span>
{{ $tc('status.show_all_conversation', otherTopLevelCount, { numStatus: otherTopLevelCount }) }}
</span>
</template>
</i18n-t>
</div>
<div
v-if="shouldShowAncestors"
class="thread-ancestors"
>
<div
v-for="status in ancestorsOf(diveRoot)"
:key="status.id"
class="thread-ancestor"
:class="{'thread-ancestor-has-other-replies': getReplies(status.id).length > 1, '-faded': shouldFadeAncestors}"
>
<status
ref="statusComponent"
:inline-expanded="collapsable && isExpanded"
:statusoid="status"
:expandable="!isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="focused(status.id)"
:in-conversation="isExpanded"
:highlight="getHighlight()"
:replies="getReplies(status.id)"
:in-profile="inProfile"
:profile-user-id="profileUserId"
class="conversation-status status-fadein panel-body"
:simple-tree="treeViewIsSimple"
:toggle-thread-display="toggleThreadDisplay"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:show-other-replies-as-button="showOtherRepliesButtonInsideStatus"
:dive="() => diveIntoStatus(status.id)"
:controlled-showing-tall="statusContentProperties[status.id].showingTall"
:controlled-expanding-subject="statusContentProperties[status.id].expandingSubject"
:controlled-showing-long-subject="statusContentProperties[status.id].showingLongSubject"
:controlled-replying="statusContentProperties[status.id].replying"
:controlled-media-playing="statusContentProperties[status.id].mediaPlaying"
:controlled-toggle-showing-tall="() => toggleStatusContentProperty(status.id, 'showingTall')"
:controlled-toggle-expanding-subject="() => toggleStatusContentProperty(status.id, 'expandingSubject')"
:controlled-toggle-showing-long-subject="() => toggleStatusContentProperty(status.id, 'showingLongSubject')"
:controlled-toggle-replying="() => toggleStatusContentProperty(status.id, 'replying')"
:controlled-set-media-playing="(newVal) => toggleStatusContentProperty(status.id, 'mediaPlaying', newVal)"
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
/>
<div
v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1"
class="thread-ancestor-dive-box"
>
<div
class="thread-ancestor-dive-box-inner"
>
<i18n-t
tag="button"
scope="global"
keypath="status.ancestor_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="diveIntoStatus(status.id)"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
{{ $tc('status.ancestor_follow', getReplies(status.id).length - 1, { numReplies: getReplies(status.id).length - 1 }) }}
</span>
</template>
</i18n-t>
</div>
</div>
</div>
</div>
<thread-tree
v-for="status in showingTopLevel"
:key="status.id"
ref="statusComponent"
:depth="0"
:status="status"
:in-profile="inProfile"
:conversation="conversation"
:collapsable="collapsable"
:is-expanded="isExpanded"
:pinned-status-ids-object="pinnedStatusIdsObject"
:profile-user-id="profileUserId"
:focused="focused"
:get-replies="getReplies"
:highlight="maybeHighlight"
:set-highlight="setHighlight"
:toggle-expanded="toggleExpanded"
:simple="treeViewIsSimple"
:toggle-thread-display="toggleThreadDisplay"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:status-content-properties="statusContentProperties"
:set-status-content-property="setStatusContentProperty"
:toggle-status-content-property="toggleStatusContentProperty"
:dive="canDive ? diveIntoStatus : undefined"
/>
</div>
<div
v-if="isLinearView"
class="thread-body"
>
<status
v-for="status in conversation"
:key="status.id"
ref="statusComponent"
:inline-expanded="collapsable && isExpanded"
:statusoid="status"
:expandable="!isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="focused(status.id)"
:in-conversation="isExpanded"
:highlight="getHighlight()"
:replies="getReplies(status.id)"
:in-profile="inProfile"
:profile-user-id="profileUserId"
class="conversation-status status-fadein panel-body"
:toggle-thread-display="toggleThreadDisplay"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:status-content-properties="statusContentProperties"
:set-status-content-property="setStatusContentProperty"
:toggle-status-content-property="toggleStatusContentProperty"
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
/>
</div>
<span v-if="collapsable">
<a
href="#"
@click.prevent="toggleExpanded"
>{{ $t('timeline.collapse') }}</a>
</span>
</div>
<status
v-for="status in conversation"
:key="status.id"
ref="statusComponent"
:inline-expanded="collapsable && isExpanded"
:statusoid="status"
:expandable="!isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="focused(status.id)"
:in-conversation="isExpanded"
:highlight="getHighlight()"
:replies="getReplies(status.id)"
:in-profile="inProfile"
:profile-user-id="profileUserId"
class="conversation-status status-fadein panel-body"
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
/>
</div>
<div
v-else
@ -201,74 +48,27 @@
@import '../../_variables.scss';
.Conversation {
.conversation-dive-to-top-level-box {
padding: var(--status-margin, $status-margin);
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: var(--border, $fallback--border);
border-radius: 0;
/* Make the button stretch along the whole row */
display: flex;
align-items: stretch;
flex-direction: column;
}
.thread-ancestors {
margin-left: var(--status-margin, $status-margin);
border-left: 2px solid var(--border, $fallback--border);
}
.thread-ancestor.-faded .StatusContent {
--link: var(--faintLink);
--text: var(--faint);
color: var(--text);
}
.thread-ancestor-dive-box {
padding-left: var(--status-margin, $status-margin);
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: var(--border, $fallback--border);
border-radius: 0;
/* Make the button stretch along the whole row */
&, &-inner {
display: flex;
align-items: stretch;
flex-direction: column;
}
}
.thread-ancestor-dive-box-inner {
padding: var(--status-margin, $status-margin);
}
.conversation-status {
border-left: none;
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: var(--border, $fallback--border);
border-radius: 0;
}
.thread-ancestor-has-other-replies .conversation-status,
.thread-ancestor:last-child .conversation-status,
.thread-ancestor:last-child .thread-ancestor-dive-box,
&.-expanded .thread-tree .conversation-status {
border-bottom: none;
}
&.-expanded {
.conversation-status {
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
border-left-color: $fallback--cRed;
border-left-color: var(--cRed, $fallback--cRed);
}
.thread-ancestors + .thread-tree > .conversation-status {
border-top-width: 1px;
border-top-style: solid;
border-top-color: var(--border, $fallback--border);
}
/* expanded conversation in timeline */
&.status-fadein.-expanded .thread-body {
border-left-width: 4px;
border-left-style: solid;
border-left-color: $fallback--cRed;
border-left-color: var(--cRed, $fallback--cRed);
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
border-bottom: 1px solid var(--border, $fallback--border);
.conversation-status:last-child {
border-bottom: none;
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
}
}
}
</style>

View file

@ -5,10 +5,6 @@
width: 100%;
position: fixed;
a {
color: var(--topBarLink, $fallback--link);
}
.inner-nav {
display: grid;
grid-template-rows: 50px;
@ -25,7 +21,7 @@
grid-template-areas: "logo sitename actions";
}
.button-default {
button {
&, svg {
color: $fallback--text;
color: var(--btnTopBarText, $fallback--text);
@ -84,13 +80,12 @@
.nav-icon {
margin-left: 0.2em;
width: 2em;
height: 100%;
text-align: center;
}
.svg-inline--fa {
color: $fallback--link;
color: var(--topBarLink, $fallback--link);
}
a, a svg {
color: $fallback--link;
color: var(--topBarLink, $fallback--link);
}
.sitename {

View file

@ -34,10 +34,11 @@
<search-bar
v-if="currentUser || !privateMode"
@toggled="onSearchBarToggled"
@click.stop
@click.stop.native
/>
<button
class="button-unstyled nav-icon"
<a
href="#"
class="nav-icon"
@click.stop="openSettingsModal"
>
<FAIcon
@ -46,33 +47,29 @@
icon="cog"
:title="$t('nav.preferences')"
/>
</button>
</a>
<a
v-if="currentUser && currentUser.role === 'admin'"
href="/pleroma/admin/#/login-pleroma"
class="nav-icon"
target="_blank"
@click.stop
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="tachometer-alt"
:title="$t('nav.administration')"
/>
</a>
<button
><FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="tachometer-alt"
:title="$t('nav.administration')"
/></a>
<a
v-if="currentUser"
class="button-unstyled nav-icon"
href="#"
class="nav-icon"
@click.prevent="logout"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="sign-out-alt"
:title="$t('login.logout')"
/>
</button>
><FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="sign-out-alt"
:title="$t('login.logout')"
/></a>
</div>
</div>
</nav>

View file

@ -6,20 +6,20 @@
<ProgressButton
v-if="muted"
:click="unmuteDomain"
class="btn button-default"
class="btn btn-default"
>
{{ $t('domain_mute_card.unmute') }}
<template v-slot:progress>
<template slot="progress">
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-else
:click="muteDomain"
class="btn button-default"
class="btn btn-default"
>
{{ $t('domain_mute_card.mute') }}
<template v-slot:progress>
<template slot="progress">
{{ $t('domain_mute_card.mute_progress') }}
</template>
</ProgressButton>

View file

@ -31,7 +31,6 @@ library.add(
*/
const EmojiInput = {
emits: ['update:modelValue', 'shown'],
props: {
suggest: {
/**
@ -58,7 +57,7 @@ const EmojiInput = {
required: true,
type: Function
},
modelValue: {
value: {
/**
* Used for v-model
*/
@ -115,8 +114,7 @@ const EmojiInput = {
showPicker: false,
temporarilyHideSuggestions: false,
keepOpen: false,
disableClickOutside: false,
suggestions: []
disableClickOutside: false
}
},
components: {
@ -126,6 +124,21 @@ const EmojiInput = {
padEmoji () {
return this.$store.getters.mergedConfig.padEmoji
},
suggestions () {
const firstchar = this.textAtCaret.charAt(0)
if (this.textAtCaret === firstchar) { return [] }
const matchedSuggestions = this.suggest(this.textAtCaret)
if (matchedSuggestions.length <= 0) {
return []
}
return take(matchedSuggestions, 5)
.map(({ imageUrl, ...rest }, index) => ({
...rest,
// eslint-disable-next-line camelcase
img: imageUrl || '',
highlighted: index === this.highlighted
}))
},
showSuggestions () {
return this.focused &&
this.suggestions &&
@ -137,78 +150,52 @@ const EmojiInput = {
return (this.wordAtCaret || {}).word || ''
},
wordAtCaret () {
if (this.modelValue && this.caret) {
const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
if (this.value && this.caret) {
const word = Completion.wordAtPosition(this.value, this.caret - 1) || {}
return word
}
}
},
mounted () {
const { root } = this.$refs
const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea')
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.addEventListener('blur', this.onBlur)
input.addEventListener('focus', this.onFocus)
input.addEventListener('paste', this.onPaste)
input.addEventListener('keyup', this.onKeyUp)
input.addEventListener('keydown', this.onKeyDown)
input.addEventListener('click', this.onClickInput)
input.addEventListener('transitionend', this.onTransition)
input.addEventListener('input', this.onInput)
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('click', this.onClickInput)
input.elm.addEventListener('transitionend', this.onTransition)
input.elm.addEventListener('input', this.onInput)
},
unmounted () {
const { input } = this
if (input) {
input.removeEventListener('blur', this.onBlur)
input.removeEventListener('focus', this.onFocus)
input.removeEventListener('paste', this.onPaste)
input.removeEventListener('keyup', this.onKeyUp)
input.removeEventListener('keydown', this.onKeyDown)
input.removeEventListener('click', this.onClickInput)
input.removeEventListener('transitionend', this.onTransition)
input.removeEventListener('input', this.onInput)
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('click', this.onClickInput)
input.elm.removeEventListener('transitionend', this.onTransition)
input.elm.removeEventListener('input', this.onInput)
}
},
watch: {
showSuggestions: function (newValue) {
this.$emit('shown', newValue)
},
textAtCaret: async function (newWord) {
const firstchar = newWord.charAt(0)
this.suggestions = []
if (newWord === firstchar) return
const matchedSuggestions = await this.suggest(newWord)
// Async: cancel if textAtCaret has changed during wait
if (this.textAtCaret !== newWord) return
if (matchedSuggestions.length <= 0) return
this.suggestions = take(matchedSuggestions, 5)
.map(({ imageUrl, ...rest }) => ({
...rest,
img: imageUrl || ''
}))
},
suggestions: {
handler (newValue) {
this.$nextTick(this.resize)
},
deep: true
}
},
methods: {
focusPickerInput () {
const pickerEl = this.$refs.picker.$el
if (!pickerEl) return
const pickerInput = pickerEl.querySelector('input')
if (pickerInput) pickerInput.focus()
},
triggerShowPicker () {
this.showPicker = true
this.$refs.picker.startEmojiLoad()
this.$nextTick(() => {
this.scrollIntoView()
this.focusPickerInput()
})
// This temporarily disables "click outside" handler
// since external trigger also means click originates
@ -219,22 +206,21 @@ const EmojiInput = {
}, 0)
},
togglePicker () {
this.input.focus()
this.input.elm.focus()
this.showPicker = !this.showPicker
if (this.showPicker) {
this.scrollIntoView()
this.$refs.picker.startEmojiLoad()
this.$nextTick(this.focusPickerInput)
}
},
replace (replacement) {
const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement)
this.$emit('update:modelValue', newValue)
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
this.caret = 0
},
insert ({ insertion, keepOpen, surroundingSpace = true }) {
const before = this.modelValue.substring(0, this.caret) || ''
const after = this.modelValue.substring(this.caret) || ''
const before = this.value.substring(0, this.caret) || ''
const after = this.value.substring(this.caret) || ''
/* Using a bit more smart approach to padding emojis with spaces:
* - put a space before cursor if there isn't one already, unless we
@ -262,16 +248,16 @@ const EmojiInput = {
after
].join('')
this.keepOpen = keepOpen
this.$emit('update:modelValue', newValue)
this.$emit('input', newValue)
const position = this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) {
this.input.focus()
this.input.elm.focus()
}
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
// Set selection right after the replacement instead of the very end
this.input.setSelectionRange(position, position)
this.input.elm.setSelectionRange(position, position)
this.caret = position
})
},
@ -281,16 +267,16 @@ const EmojiInput = {
if (len > 0 || suggestion) {
const chosenSuggestion = suggestion || this.suggestions[this.highlighted]
const replacement = chosenSuggestion.replacement
const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement)
this.$emit('update:modelValue', newValue)
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
this.highlighted = 0
const position = this.wordAtCaret.start + replacement.length
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
this.input.focus()
this.input.elm.focus()
// Set selection right after the replacement instead of the very end
this.input.setSelectionRange(position, position)
this.input.elm.setSelectionRange(position, position)
this.caret = position
})
e.preventDefault()
@ -352,7 +338,7 @@ const EmojiInput = {
}
this.$nextTick(() => {
const { offsetHeight } = this.input
const { offsetHeight } = this.input.elm
const { picker } = this.$refs
const pickerBottom = picker.$el.getBoundingClientRect().bottom
if (pickerBottom > window.innerHeight) {
@ -417,8 +403,8 @@ const EmojiInput = {
// Scroll the input element to the position of the cursor
this.$nextTick(() => {
this.input.blur()
this.input.focus()
this.input.elm.blur()
this.input.elm.focus()
})
}
// Disable suggestions hotkeys if suggestions are hidden
@ -447,7 +433,7 @@ const EmojiInput = {
// de-focuses the element (i.e. default browser behavior)
if (key === 'Escape') {
if (!this.temporarilyHideSuggestions) {
this.input.focus()
this.input.elm.focus()
}
}
@ -458,7 +444,7 @@ const EmojiInput = {
this.showPicker = false
this.setCaret(e)
this.resize()
this.$emit('update:modelValue', e.target.value)
this.$emit('input', e.target.value)
},
onClickInput (e) {
this.showPicker = false
@ -483,7 +469,7 @@ const EmojiInput = {
if (!panel) return
const picker = this.$refs.picker.$el
const panelBody = this.$refs['panel-body']
const { offsetHeight, offsetTop } = this.input
const { offsetHeight, offsetTop } = this.input.elm
const offsetBottom = offsetTop + offsetHeight
this.setPlacement(panelBody, panel, offsetBottom)
@ -497,7 +483,7 @@ const EmojiInput = {
if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) {
target.style.top = 'auto'
target.style.bottom = this.input.offsetHeight + 'px'
target.style.bottom = this.input.elm.offsetHeight + 'px'
}
},
overflowsBottom (el) {

View file

@ -1,20 +1,18 @@
<template>
<div
ref="root"
v-click-outside="onClickOutside"
class="emoji-input"
:class="{ 'with-picker': !hideEmojiButton }"
>
<slot />
<template v-if="enableEmojiPicker">
<button
<div
v-if="!hideEmojiButton"
class="button-unstyled emoji-picker-icon"
type="button"
class="emoji-picker-icon"
@click.prevent="togglePicker"
>
<FAIcon :icon="['far', 'smile-beam']" />
</button>
</div>
<EmojiPicker
v-if="enableEmojiPicker"
ref="picker"
@ -39,7 +37,7 @@
v-for="(suggestion, index) in suggestions"
:key="index"
class="autocomplete-item"
:class="{ highlighted: index === highlighted }"
:class="{ highlighted: suggestion.highlighted }"
@click.stop.prevent="onClick($event, suggestion)"
>
<span class="image">

View file

@ -1,3 +1,4 @@
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:
@ -10,19 +11,19 @@
* doesn't support user linking you can just provide only emoji.
*/
export default data => {
const emojiCurry = suggestEmoji(data.emoji)
const usersCurry = data.store && suggestUsers(data.store)
return input => {
const firstChar = input[0]
if (firstChar === ':' && data.emoji) {
return emojiCurry(input)
}
if (firstChar === '@' && usersCurry) {
return usersCurry(input)
}
return []
const debounceUserSearch = debounce((data, input) => {
data.updateUsersList(input)
}, 500)
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 => {
@ -56,75 +57,50 @@ export const suggestEmoji = emojis => input => {
})
}
export const suggestUsers = ({ dispatch, state }) => {
// Keep some persistent values in closure, most importantly for the
// custom debounce to work. Lodash debounce does not return a promise.
let suggestions = []
let previousQuery = ''
let timeout = null
let cancelUserSearch = null
export const suggestUsers = data => input => {
const noPrefix = input.toLowerCase().substr(1)
const users = data.users
const userSearch = (query) => dispatch('searchUsers', { query })
const debounceUserSearch = (query) => {
cancelUserSearch && cancelUserSearch()
return new Promise((resolve, reject) => {
timeout = setTimeout(() => {
userSearch(query).then(resolve).catch(reject)
}, 300)
cancelUserSearch = () => {
clearTimeout(timeout)
resolve([])
}
})
}
return async input => {
const noPrefix = input.toLowerCase().substr(1)
if (previousQuery === noPrefix) return suggestions
suggestions = []
previousQuery = noPrefix
// Fetch more and wait, don't fetch if there's the 2nd @ because
// the backend user search can't deal with it.
// Reference semantics make it so that we get the updated data after
// the await.
if (!noPrefix.includes('@')) {
await debounceUserSearch(noPrefix)
}
const newSuggestions = state.users.users.filter(
user =>
user.screen_name.toLowerCase().startsWith(noPrefix) ||
user.name.toLowerCase().startsWith(noPrefix)
).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, screen_name_ui, name, profile_image_url_original }) => ({
displayText: screen_name_ui,
detailText: name,
imageUrl: profile_image_url_original,
replacement: '@' + screen_name + ' '
}))
/* eslint-enable camelcase */
suggestions = newSuggestions || []
return suggestions
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 to get more comprehensive results
if (data.updateUsersList) {
debounceUserSearch(data, noPrefix)
}
return newUsers
/* eslint-enable camelcase */
}

View file

@ -1,4 +1,3 @@
import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@ -58,7 +57,7 @@ const EmojiPicker = {
}
},
components: {
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
StickerPicker: () => import('../sticker_picker/sticker_picker.vue'),
Checkbox
},
methods: {
@ -80,7 +79,7 @@ const EmojiPicker = {
},
highlight (key) {
const ref = this.$refs['group-' + key]
const top = ref.offsetTop
const top = ref[0].offsetTop
this.setShowStickers(false)
this.activeGroup = key
this.$nextTick(() => {
@ -97,7 +96,7 @@ const EmojiPicker = {
}
},
triggerLoadMore (target) {
const ref = this.$refs['group-end-custom']
const ref = this.$refs['group-end-custom'][0]
if (!ref) return
const bottom = ref.offsetTop + ref.offsetHeight
@ -120,7 +119,7 @@ const EmojiPicker = {
this.$nextTick(() => {
this.emojisView.forEach(group => {
const ref = this.$refs['group-' + group.id]
if (ref.offsetTop <= top) {
if (ref[0].offsetTop <= top) {
this.activeGroup = group.id
}
})

View file

@ -6,7 +6,7 @@
:users="accountsForEmoji[reaction.name]"
>
<button
class="emoji-reaction btn button-default"
class="emoji-reaction btn btn-default"
:class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
@click="emojiOnClick(reaction.name, $event)"
@mouseenter="fetchEmojiReactionsByIfMissing()"

View file

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

View file

@ -15,8 +15,18 @@ const Exporter = {
type: String,
default: 'export.csv'
},
exportButtonLabel: { type: String },
processingMessage: { type: String }
exportButtonLabel: {
type: String,
default () {
return this.$t('exporter.export')
}
},
processingMessage: {
type: String,
default () {
return this.$t('exporter.processing')
}
}
},
data () {
return {

View file

@ -7,14 +7,14 @@
spin
/>
<span>{{ processingMessage || $t('exporter.processing') }}</span>
<span>{{ processingMessage }}</span>
</div>
<button
v-else
class="btn button-default"
class="btn btn-default"
@click="process"
>
{{ exportButtonLabel || $t('exporter.export') }}
{{ exportButtonLabel }}
</button>
</div>
</template>

View file

@ -5,12 +5,10 @@ import {
faBookmark,
faEyeSlash,
faThumbtack,
faShareAlt,
faExternalLinkAlt
faShareAlt
} from '@fortawesome/free-solid-svg-icons'
import {
faBookmark as faBookmarkReg,
faFlag
faBookmark as faBookmarkReg
} from '@fortawesome/free-regular-svg-icons'
library.add(
@ -19,9 +17,7 @@ library.add(
faBookmarkReg,
faEyeSlash,
faThumbtack,
faShareAlt,
faExternalLinkAlt,
faFlag
faShareAlt
)
const ExtraButtons = {
@ -68,9 +64,6 @@ const ExtraButtons = {
this.$store.dispatch('unbookmark', { id: this.status.id })
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
reportStatus () {
this.$store.dispatch('openUserReportingModal', { userId: this.status.user.id, statusIds: [this.status.id] })
}
},
computed: {

View file

@ -1,17 +1,18 @@
<template>
<Popover
class="ExtraButtons"
trigger="click"
placement="top"
:offset="{ y: 5 }"
class="extra-button-popover"
:bound-to="{ x: 'container' }"
remove-padding
>
<template v-slot:content="{close}">
<div
slot="content"
slot-scope="{close}"
>
<div class="dropdown-menu">
<button
v-if="canMute && !status.thread_muted"
class="button-default dropdown-item dropdown-item-icon"
class="dropdown-item dropdown-item-icon"
@click.prevent="muteConversation"
>
<FAIcon
@ -21,7 +22,7 @@
</button>
<button
v-if="canMute && status.thread_muted"
class="button-default dropdown-item dropdown-item-icon"
class="dropdown-item dropdown-item-icon"
@click.prevent="unmuteConversation"
>
<FAIcon
@ -31,7 +32,7 @@
</button>
<button
v-if="!status.pinned && canPin"
class="button-default dropdown-item dropdown-item-icon"
class="dropdown-item dropdown-item-icon"
@click.prevent="pinStatus"
@click="close"
>
@ -42,7 +43,7 @@
</button>
<button
v-if="status.pinned && canPin"
class="button-default dropdown-item dropdown-item-icon"
class="dropdown-item dropdown-item-icon"
@click.prevent="unpinStatus"
@click="close"
>
@ -53,7 +54,7 @@
</button>
<button
v-if="!status.bookmarked"
class="button-default dropdown-item dropdown-item-icon"
class="dropdown-item dropdown-item-icon"
@click.prevent="bookmarkStatus"
@click="close"
>
@ -64,7 +65,7 @@
</button>
<button
v-if="status.bookmarked"
class="button-default dropdown-item dropdown-item-icon"
class="dropdown-item dropdown-item-icon"
@click.prevent="unbookmarkStatus"
@click="close"
>
@ -75,7 +76,7 @@
</button>
<button
v-if="canDelete"
class="button-default dropdown-item dropdown-item-icon"
class="dropdown-item dropdown-item-icon"
@click.prevent="deleteStatus"
@click="close"
>
@ -85,7 +86,7 @@
/><span>{{ $t("status.delete") }}</span>
</button>
<button
class="button-default dropdown-item dropdown-item-icon"
class="dropdown-item dropdown-item-icon"
@click.prevent="copyLink"
@click="close"
>
@ -94,38 +95,14 @@
icon="share-alt"
/><span>{{ $t("status.copy_link") }}</span>
</button>
<a
v-if="!status.is_local"
class="button-default dropdown-item dropdown-item-icon"
title="Source"
:href="status.external_url"
target="_blank"
>
<FAIcon
fixed-width
icon="external-link-alt"
/><span>{{ $t("status.external_source") }}</span>
</a>
<button
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="reportStatus"
@click="close"
>
<FAIcon
fixed-width
:icon="['far', 'flag']"
/><span>{{ $t("user_card.report") }}</span>
</button>
</div>
</template>
<template v-slot:trigger>
<button class="button-unstyled popover-trigger">
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="ellipsis-h"
/>
</button>
</template>
</div>
<span slot="trigger">
<FAIcon
class="ExtraButtons fa-scale-110 fa-old-padding"
icon="ellipsis-h"
/>
</span>
</Popover>
</template>
@ -135,20 +112,13 @@
@import '../../_variables.scss';
.ExtraButtons {
/* override of popover internal stuff */
.popover-trigger-button {
width: auto;
}
cursor: pointer;
position: static;
.popover-trigger {
position: static;
padding: 10px;
margin: -10px;
&:hover .svg-inline--fa {
color: $fallback--text;
color: var(--text, $fallback--text);
}
&:hover,
.extra-button-popover.open & {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
</style>

View file

@ -31,6 +31,11 @@ const FavoriteButton = {
}
},
computed: {
classes () {
return {
'-favorited': this.status.favorited
}
},
...mapGetters(['mergedConfig'])
}
}

View file

@ -1,31 +1,23 @@
<template>
<div class="FavoriteButton">
<button
v-if="loggedIn"
class="button-unstyled interactive"
:class="status.favorited && '-favorited'"
<div v-if="loggedIn">
<FAIcon
:class="classes"
class="FavoriteButton fa-scale-110 fa-old-padding -interactive"
:title="$t('tool_tip.favorite')"
:icon="[status.favorited ? 'fas' : 'far', 'star']"
:spin="animated"
@click.prevent="favorite()"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
:icon="[status.favorited ? 'fas' : 'far', 'star']"
:spin="animated"
/>
</button>
<span v-else>
<FAIcon
class="fa-scale-110 fa-old-padding"
:title="$t('tool_tip.favorite')"
:icon="['far', 'star']"
/>
</span>
<span
v-if="!mergedConfig.hidePostStats && status.fave_num > 0"
class="action-counter"
>
{{ status.fave_num }}
</span>
/>
<span v-if="!mergedConfig.hidePostStats && status.fave_num > 0">{{ status.fave_num }}</span>
</div>
<div v-else>
<FAIcon
:class="classes"
class="FavoriteButton fa-scale-110 fa-old-padding"
:title="$t('tool_tip.favorite')"
:icon="['far', 'star']"
/>
<span v-if="!mergedConfig.hidePostStats && status.fave_num > 0">{{ status.fave_num }}</span>
</div>
</template>
@ -35,28 +27,19 @@
@import '../../_variables.scss';
.FavoriteButton {
display: flex;
&.-interactive {
cursor: pointer;
animation-duration: 0.6s;
> :first-child {
padding: 10px;
margin: -10px -8px -10px -10px;
}
.action-counter {
pointer-events: none;
user-select: none;
}
.interactive {
.svg-inline--fa {
animation-duration: 0.6s;
}
&:hover .svg-inline--fa,
&.-favorited .svg-inline--fa {
&:hover {
color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange);
}
}
&.-favorited {
color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange);
}
}
</style>

View file

@ -1,15 +1,12 @@
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
const FeaturesPanel = {
computed: {
shout: function () { return this.$store.state.instance.shoutAvailable },
chat: function () { return this.$store.state.instance.chatAvailable },
pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable },
gopher: function () { return this.$store.state.instance.gopherAvailable },
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },
minimalScopesMode: function () { return this.$store.state.instance.minimalScopesMode },
textlimit: function () { return this.$store.state.instance.textlimit },
uploadlimit: function () { return fileSizeFormatService.fileSizeFormat(this.$store.state.instance.uploadlimit) }
textlimit: function () { return this.$store.state.instance.textlimit }
}
}

View file

@ -8,8 +8,8 @@
</div>
<div class="panel-body features-panel">
<ul>
<li v-if="shout">
{{ $t('features_panel.shout') }}
<li v-if="chat">
{{ $t('features_panel.chat') }}
</li>
<li v-if="pleromaChatMessages">
{{ $t('features_panel.pleroma_chat_messages') }}
@ -25,7 +25,6 @@
</li>
<li>{{ $t('features_panel.scope_options') }}</li>
<li>{{ $t('features_panel.text_limit') }} = {{ textlimit }}</li>
<li>{{ $t('features_panel.upload_limit') }} = {{ uploadlimit.num }} {{ $t('upload.file_size_units.' + uploadlimit.unit) }}</li>
</ul>
</div>
</div>

View file

@ -1,53 +0,0 @@
import RuffleService from '../../services/ruffle_service/ruffle_service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faStop,
faExclamationTriangle
} from '@fortawesome/free-solid-svg-icons'
library.add(
faStop,
faExclamationTriangle
)
const Flash = {
props: [ 'src' ],
data () {
return {
player: false, // can be true, "hidden", false. hidden = element exists
loaded: false,
ruffleInstance: null
}
},
methods: {
openPlayer () {
if (this.player) return // prevent double-loading, or re-loading on failure
this.player = 'hidden'
RuffleService.getRuffle().then((ruffle) => {
const player = ruffle.newest().createPlayer()
player.config = {
letterbox: 'on'
}
const container = this.$refs.container
container.appendChild(player)
player.style.width = '100%'
player.style.height = '100%'
player.load(this.src).then(() => {
this.player = true
}).catch((e) => {
console.error('Error loading ruffle', e)
this.player = 'error'
})
this.ruffleInstance = player
this.$emit('playerOpened')
})
},
closePlayer () {
this.ruffleInstance && this.ruffleInstance.remove()
this.player = false
this.$emit('playerClosed')
}
}
}
export default Flash

View file

@ -1,84 +0,0 @@
<template>
<div class="Flash">
<div
v-if="player === true || player === 'hidden'"
ref="container"
class="player"
:class="{ hidden: player === 'hidden' }"
/>
<button
v-if="player !== true"
class="button-unstyled placeholder"
@click="openPlayer"
>
<span
v-if="player === 'hidden'"
class="label"
>
{{ $t('general.loading') }}
</span>
<span
v-if="player === 'error'"
class="label"
>
{{ $t('general.flash_fail') }}
</span>
<span
v-else
class="label"
>
<p>
{{ $t('general.flash_content') }}
</p>
<p>
<FAIcon icon="exclamation-triangle" />
{{ $t('general.flash_security') }}
</p>
</span>
</button>
</div>
</template>
<script src="./flash.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.Flash {
display: inline-block;
width: 100%;
height: 100%;
position: relative;
.player {
height: 100%;
width: 100%;
}
.placeholder {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
color: var(--link);
}
.hider {
top: 0;
}
.label {
text-align: center;
flex: 1 1 0;
line-height: 1.2;
white-space: normal;
word-wrap: normal;
}
.hidden {
display: none;
visibility: 'hidden';
}
}
</style>

View file

@ -1,6 +1,6 @@
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
export default {
props: ['relationship', 'user', 'labelFollowing', 'buttonClass'],
props: ['relationship', 'labelFollowing', 'buttonClass'],
data () {
return {
inProgress: false
@ -14,7 +14,7 @@ export default {
if (this.inProgress || this.relationship.following) {
return this.$t('user_card.follow_unfollow')
} else if (this.relationship.requested) {
return this.$t('user_card.follow_cancel')
return this.$t('user_card.follow_again')
} else {
return this.$t('user_card.follow')
}
@ -29,14 +29,11 @@ export default {
} else {
return this.$t('user_card.follow')
}
},
disabled () {
return this.inProgress || this.user.deactivated
}
},
methods: {
onClick () {
this.relationship.following || this.relationship.requested ? this.unfollow() : this.follow()
this.relationship.following ? this.unfollow() : this.follow()
},
follow () {
this.inProgress = true

View file

@ -1,8 +1,8 @@
<template>
<button
class="btn button-default follow-button"
class="btn btn-default follow-button"
:class="{ toggled: isPressed }"
:disabled="disabled"
:disabled="inProgress"
:title="title"
@click="onClick"
>

View file

@ -20,7 +20,6 @@
:relationship="relationship"
:label-following="$t('user_card.follow_unfollow')"
class="follow-card-follow-button"
:user="user"
/>
</template>
</div>

View file

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

View file

@ -1,17 +1,20 @@
import { set } from 'lodash'
import Select from '../select/select.vue'
import { set } from 'vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronDown
} from '@fortawesome/free-solid-svg-icons'
library.add(
faChevronDown
)
export default {
components: {
Select
},
props: [
'name', 'label', 'modelValue', 'fallback', 'options', 'no-inherit'
'name', 'label', 'value', 'fallback', 'options', 'no-inherit'
],
emits: ['update:modelValue'],
data () {
return {
lValue: this.modelValue,
lValue: this.value,
availableOptions: [
this.noInherit ? '' : 'inherit',
'custom',
@ -23,7 +26,7 @@ export default {
}
},
beforeUpdate () {
this.lValue = this.modelValue
this.lValue = this.value
},
computed: {
present () {
@ -38,7 +41,7 @@ export default {
},
set (v) {
set(this.lValue, 'family', v)
this.$emit('update:modelValue', this.lValue)
this.$emit('input', this.lValue)
}
},
isCustom () {

View file

@ -15,28 +15,37 @@
class="opt exlcude-disabled"
type="checkbox"
:checked="present"
@change="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
@input="$emit('input', typeof value === 'undefined' ? fallback : undefined)"
>
<label
v-if="typeof fallback !== 'undefined'"
class="opt-l"
:for="name + '-o'"
/>
{{ ' ' }}
<Select
:id="name + '-font-switcher'"
v-model="preset"
<label
:for="name + '-font-switcher'"
class="select"
:disabled="!present"
class="font-switcher"
>
<option
v-for="option in availableOptions"
:key="option"
:value="option"
<select
:id="name + '-font-switcher'"
v-model="preset"
:disabled="!present"
class="font-switcher"
>
{{ option === 'custom' ? $t('settings.style.fonts.custom') : option }}
</option>
</Select>
<option
v-for="option in availableOptions"
:key="option"
:value="option"
>
{{ option === 'custom' ? $t('settings.style.fonts.custom') : option }}
</option>
</select>
<FAIcon
class="select-down-icon"
icon="chevron-down"
/>
</label>
<input
v-if="isCustom"
:id="name"
@ -56,8 +65,7 @@
min-width: 10em;
}
&.custom {
/* TODO Should make proper joiners... */
.font-switcher {
.select {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}

View file

@ -1,26 +1,15 @@
import Attachment from '../attachment/attachment.vue'
import { sumBy, set } from 'lodash'
import { chunk, last, dropRight, sumBy } from 'lodash'
const Gallery = {
props: [
'attachments',
'limitRows',
'descriptions',
'limit',
'nsfw',
'setMedia',
'size',
'editable',
'removeAttachment',
'shiftUpAttachment',
'shiftDnAttachment',
'editAttachment',
'grid'
'setMedia'
],
data () {
return {
sizes: {},
hidingLong: true
sizes: {}
}
},
components: { Attachment },
@ -29,70 +18,26 @@ const Gallery = {
if (!this.attachments) {
return []
}
const attachments = this.limit > 0
? this.attachments.slice(0, this.limit)
: this.attachments
if (this.size === 'hide') {
return attachments.map(item => ({ minimal: true, items: [item] }))
const rows = chunk(this.attachments, 3)
if (last(rows).length === 1 && rows.length > 1) {
// if 1 attachment on last row -> add it to the previous row instead
const lastAttachment = last(rows)[0]
const allButLastRow = dropRight(rows)
last(allButLastRow).push(lastAttachment)
return allButLastRow
}
const rows = this.grid
? [{ grid: true, items: attachments }]
: attachments.reduce((acc, attachment, i) => {
if (attachment.mimetype.includes('audio')) {
return [...acc, { audio: true, items: [attachment] }, { items: [] }]
}
if (!(
attachment.mimetype.includes('image') ||
attachment.mimetype.includes('video') ||
attachment.mimetype.includes('flash')
)) {
return [...acc, { minimal: true, items: [attachment] }, { items: [] }]
}
const maxPerRow = 3
const attachmentsRemaining = this.attachments.length - i + 1
const currentRow = acc[acc.length - 1].items
currentRow.push(attachment)
if (currentRow.length >= maxPerRow && attachmentsRemaining > maxPerRow) {
return [...acc, { items: [] }]
} else {
return acc
}
}, [{ items: [] }]).filter(_ => _.items.length > 0)
return rows
},
attachmentsDimensionalScore () {
return this.rows.reduce((acc, row) => {
let size = 0
if (row.minimal) {
size += 1 / 8
} else if (row.audio) {
size += 1 / 4
} else {
size += 1 / (row.items.length + 0.6)
}
return acc + size
}, 0)
},
tooManyAttachments () {
if (this.editable || this.size === 'small') {
return false
} else if (this.size === 'hide') {
return this.attachments.length > 8
} else {
return this.attachmentsDimensionalScore > 1
}
useContainFit () {
return this.$store.getters.mergedConfig.useContainFit
}
},
methods: {
onNaturalSizeLoad ({ id, width, height }) {
set(this.sizes, id, { width, height })
onNaturalSizeLoad (id, size) {
this.$set(this.sizes, id, size)
},
rowStyle (row) {
if (row.audio) {
return { 'padding-bottom': '25%' } // fixed reduced height for audio
} else if (!row.minimal && !row.grid) {
return { 'padding-bottom': `${(100 / (row.items.length + 0.6))}%` }
}
rowStyle (itemsPerRow) {
return { 'padding-bottom': `${(100 / (itemsPerRow + 0.6))}%` }
},
itemStyle (id, row) {
const total = sumBy(row, item => this.getAspectRatio(item.id))
@ -101,16 +46,6 @@ const Gallery = {
getAspectRatio (id) {
const size = this.sizes[id]
return size ? size.width / size.height : 1
},
toggleHidingLong (event) {
this.hidingLong = event
},
openGallery () {
this.$store.dispatch('setMedia', this.attachments)
this.$store.dispatch('setCurrentMedia', this.attachments[0])
},
onMedia () {
this.$store.dispatch('setMedia', this.attachments)
}
}
}

View file

@ -1,83 +1,26 @@
<template>
<div
ref="galleryContainer"
class="Gallery"
:class="{ '-long': tooManyAttachments && hidingLong }"
style="width: 100%;"
>
<div class="gallery-rows">
<div
v-for="(row, rowIndex) in rows"
:key="rowIndex"
class="gallery-row"
:style="rowStyle(row)"
:class="{ '-audio': row.audio, '-minimal': row.minimal, '-grid': grid }"
>
<div
class="gallery-row-inner"
:class="{ '-grid': grid }"
>
<Attachment
v-for="(attachment, attachmentIndex) in row.items"
:key="attachment.id"
class="gallery-item"
:nsfw="nsfw"
:attachment="attachment"
:size="size"
:editable="editable"
:remove="removeAttachment"
:shift-up="!(attachmentIndex === 0 && rowIndex === 0) && shiftUpAttachment"
:shift-dn="!(attachmentIndex === row.items.length - 1 && rowIndex === rows.length - 1) && shiftDnAttachment"
:edit="editAttachment"
:description="descriptions && descriptions[attachment.id]"
:hide-description="size === 'small' || tooManyAttachments && hidingLong"
:style="itemStyle(attachment.id, row.items)"
@setMedia="onMedia"
@naturalSizeLoad="onNaturalSizeLoad"
/>
</div>
</div>
</div>
<div
v-if="tooManyAttachments"
class="many-attachments"
v-for="(row, index) in rows"
:key="index"
class="gallery-row"
:style="rowStyle(row.length)"
:class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }"
>
<div class="many-attachments-text">
{{ $t("status.many_attachments", { number: attachments.length }) }}
</div>
<div class="many-attachments-buttons">
<span
v-if="!hidingLong"
class="many-attachments-button"
>
<button
class="button-unstyled -link"
@click="toggleHidingLong(true)"
>
{{ $t("status.collapse_attachments") }}
</button>
</span>
<span
v-if="hidingLong"
class="many-attachments-button"
>
<button
class="button-unstyled -link"
@click="toggleHidingLong(false)"
>
{{ $t("status.show_all_attachments") }}
</button>
</span>
<span
v-if="hidingLong"
class="many-attachments-button"
>
<button
class="button-unstyled -link"
@click="openGallery"
>
{{ $t("status.open_gallery") }}
</button>
</span>
<div class="gallery-row-inner">
<attachment
v-for="attachment in row"
:key="attachment.id"
:set-media="setMedia"
:nsfw="nsfw"
:attachment="attachment"
:allow-play="false"
:natural-size-load="onNaturalSizeLoad.bind(null, attachment.id)"
:style="itemStyle(attachment.id, row)"
/>
</div>
</div>
</div>
@ -88,66 +31,12 @@
<style lang="scss">
@import '../../_variables.scss';
.Gallery {
.gallery-rows {
display: flex;
flex-direction: column;
}
.gallery-row {
position: relative;
height: 0;
width: 100%;
flex-grow: 1;
&:not(:first-child) {
margin-top: 0.5em;
}
}
&.-long {
.gallery-rows {
max-height: 25em;
overflow: hidden;
mask:
linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */
-webkit-mask-composite: xor;
mask-composite: exclude;
}
}
.many-attachments-text {
text-align: center;
line-height: 2;
}
.many-attachments-buttons {
display: flex;
}
.many-attachments-button {
display: flex;
flex: 1;
justify-content: center;
line-height: 2;
button {
padding: 0 2em;
}
}
.gallery-row {
&.-grid,
&.-minimal {
height: auto;
.gallery-row-inner {
position: relative;
}
}
}
.gallery-row {
position: relative;
height: 0;
width: 100%;
flex-grow: 1;
margin-top: 0.5em;
.gallery-row-inner {
position: absolute;
@ -159,24 +48,9 @@
flex-direction: row;
flex-wrap: nowrap;
align-content: stretch;
&.-grid {
width: 100%;
height: auto;
position: relative;
display: grid;
grid-column-gap: 0.5em;
grid-row-gap: 0.5em;
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
.gallery-item {
margin: 0;
height: 200px;
}
}
}
.gallery-item {
.gallery-row-inner .attachment {
margin: 0 0.5em 0 0;
flex-grow: 1;
height: 100%;
@ -187,5 +61,32 @@
margin: 0;
}
}
.image-attachment {
width: 100%;
height: 100%;
}
.video-container {
height: 100%;
}
&.contain-fit {
img,
video,
canvas {
object-fit: contain;
height: 100%;
}
}
&.cover-fit {
img,
video,
canvas {
object-fit: cover;
}
}
}
</style>

View file

@ -9,15 +9,11 @@
<div class="notice-message">
{{ $t(notice.messageKey, notice.messageArgs) }}
</div>
<button
class="button-unstyled close-notice"
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
@click="closeNotice(notice)"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
/>
</button>
/>
</div>
</div>
</template>
@ -58,7 +54,7 @@
.global-error {
background-color: var(--alertPopupError, $fallback--cRed);
color: var(--alertPopupErrorText, $fallback--text);
.svg-inline--fa {
i {
color: var(--alertPopupErrorText, $fallback--text);
}
}
@ -66,32 +62,17 @@
.global-warning {
background-color: var(--alertPopupWarning, $fallback--cOrange);
color: var(--alertPopupWarningText, $fallback--text);
.svg-inline--fa {
i {
color: var(--alertPopupWarningText, $fallback--text);
}
}
.global-success {
background-color: var(--alertPopupSuccess, $fallback--cGreen);
color: var(--alertPopupSuccessText, $fallback--text);
.svg-inline--fa {
color: var(--alertPopupSuccessText, $fallback--text);
}
}
.global-info {
background-color: var(--alertPopupNeutral, $fallback--fg);
color: var(--alertPopupNeutralText, $fallback--text);
.svg-inline--fa {
i {
color: var(--alertPopupNeutralText, $fallback--text);
}
}
.close-notice {
padding-right: 0.2em;
.svg-inline--fa:hover {
opacity: 0.6;
}
}
}
</style>

View file

@ -1,36 +0,0 @@
import { extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
const HashtagLink = {
name: 'HashtagLink',
props: {
url: {
required: true,
type: String
},
content: {
required: true,
type: String
},
tag: {
required: false,
type: String,
default: ''
}
},
methods: {
onClick () {
const tag = this.tag || extractTagFromUrl(this.url)
if (tag) {
const link = this.generateTagLink(tag)
this.$router.push(link)
} else {
window.open(this.url, '_blank')
}
},
generateTagLink (tag) {
return `/tag/${tag}`
}
}
}
export default HashtagLink

View file

@ -1,6 +0,0 @@
.HashtagLink {
position: relative;
white-space: normal;
display: inline-block;
color: var(--link);
}

View file

@ -1,19 +0,0 @@
<template>
<span
class="HashtagLink"
>
<!-- eslint-disable vue/no-v-html -->
<a
:href="url"
class="original"
target="_blank"
@click.prevent="onClick"
v-html="content"
/>
<!-- eslint-enable vue/no-v-html -->
</span>
</template>
<script src="./hashtag_link.js"/>
<style lang="scss" src="./hashtag_link.scss"/>

View file

@ -2,10 +2,12 @@ import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes,
faCircleNotch
} from '@fortawesome/free-solid-svg-icons'
library.add(
faTimes,
faCircleNotch
)
@ -51,7 +53,8 @@ const ImageCropper = {
cropper: undefined,
dataUrl: undefined,
filename: undefined,
submitting: false
submitting: false,
submitError: null
}
},
computed: {
@ -63,6 +66,9 @@ const ImageCropper = {
},
cancelText () {
return this.cancelButtonLabel || this.$t('image_cropper.cancel')
},
submitErrorMsg () {
return this.submitError && this.submitError instanceof Error ? this.submitError.toString() : this.submitError
}
},
methods: {
@ -76,8 +82,12 @@ const ImageCropper = {
},
submit (cropping = true) {
this.submitting = true
this.avatarUploadError = null
this.submitHandler(cropping && this.cropper, this.file)
.then(() => this.destroy())
.catch((err) => {
this.submitError = err
})
.finally(() => {
this.submitting = false
})
@ -103,6 +113,9 @@ const ImageCropper = {
reader.readAsDataURL(this.file)
this.$emit('changed', this.file, reader)
}
},
clearError () {
this.submitError = null
}
},
mounted () {
@ -117,7 +130,7 @@ const ImageCropper = {
const fileInput = this.$refs.input
fileInput.addEventListener('change', this.readFile)
},
beforeUnmount: function () {
beforeDestroy: function () {
// remove the event listeners
const trigger = this.getTriggerDOM()
if (trigger) {

View file

@ -11,21 +11,21 @@
</div>
<div class="image-cropper-buttons-wrapper">
<button
class="button-default btn"
class="btn"
type="button"
:disabled="submitting"
@click="submit()"
v-text="saveText"
/>
<button
class="button-default btn"
class="btn"
type="button"
:disabled="submitting"
@click="destroy"
v-text="cancelText"
/>
<button
class="button-default btn"
class="btn"
type="button"
:disabled="submitting"
@click="submit(false)"
@ -37,6 +37,17 @@
icon="circle-notch"
/>
</div>
<div
v-if="submitError"
class="alert error"
>
{{ submitErrorMsg }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
@click="clearError"
/>
</div>
</div>
<input
ref="input"

View file

@ -15,9 +15,24 @@ const Importer = {
type: Function,
required: true
},
submitButtonLabel: { type: String },
successMessage: { type: String },
errorMessage: { type: String }
submitButtonLabel: {
type: String,
default () {
return this.$t('importer.submit')
}
},
successMessage: {
type: String,
default () {
return this.$t('importer.success')
}
},
errorMessage: {
type: String,
default () {
return this.$t('importer.error')
}
}
},
data () {
return {

View file

@ -15,34 +15,24 @@
/>
<button
v-else
class="btn button-default"
class="btn btn-default"
@click="submit"
>
{{ submitButtonLabel || $t('importer.submit') }}
{{ submitButtonLabel }}
</button>
<div v-if="success">
<button
class="button-unstyled"
<FAIcon
icon="times"
@click="dismiss"
>
<FAIcon
icon="times"
/>
</button>
{{ ' ' }}
<span>{{ successMessage || $t('importer.success') }}</span>
/>
<p>{{ successMessage }}</p>
</div>
<div v-else-if="error">
<button
class="button-unstyled"
<FAIcon
icon="times"
@click="dismiss"
>
<FAIcon
icon="times"
/>
</button>
{{ ' ' }}
<span>{{ errorMessage || $t('importer.error') }}</span>
/>
<p>{{ errorMessage }}</p>
</div>
</div>
</template>

View file

@ -1,5 +1,4 @@
import Notifications from '../notifications/notifications.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
const tabModeDict = {
mentions: ['mention'],
@ -21,8 +20,7 @@ const Interactions = {
}
},
components: {
Notifications,
TabSwitcher
Notifications
}
}

View file

@ -3,36 +3,51 @@
<label for="interface-language-switcher">
{{ $t('settings.interfaceLanguage') }}
</label>
{{ ' ' }}
<Select
id="interface-language-switcher"
v-model="language"
<label
for="interface-language-switcher"
class="select"
>
<option
v-for="lang in languages"
:key="lang.code"
:value="lang.code"
<select
id="interface-language-switcher"
v-model="language"
>
{{ lang.name }}
</option>
</Select>
<option
v-for="(langCode, i) in languageCodes"
:key="langCode"
:value="langCode"
>
{{ languageNames[i] }}
</option>
</select>
<FAIcon
class="select-down-icon"
icon="chevron-down"
/>
</label>
</div>
</template>
<script>
import languagesObject from '../../i18n/messages'
import localeService from '../../services/locale/locale.service.js'
import ISO6391 from 'iso-639-1'
import _ from 'lodash'
import Select from '../select/select.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronDown
} from '@fortawesome/free-solid-svg-icons'
library.add(
faChevronDown
)
export default {
components: {
Select
},
computed: {
languages () {
return _.map(languagesObject.languages, (code) => ({ code: code, name: this.getLanguageName(code) })).sort((a, b) => a.name.localeCompare(b.name))
languageCodes () {
return languagesObject.languages
},
languageNames () {
return _.map(this.languageCodes, this.getLanguageName)
},
language: {
@ -46,13 +61,11 @@ export default {
methods: {
getLanguageName (code) {
const specialLanguageNames = {
'ja_easy': 'やさしいにほんご',
'zh': '简体中文',
'zh_Hant': '繁體中文'
'ja': 'Japanese (日本語)',
'ja_easy': 'Japanese (やさしいにほんご)',
'zh': 'Chinese (简体中文)'
}
const languageName = specialLanguageNames[code] || ISO6391.getNativeName(code)
const browserLocale = localeService.internalToBrowserLocale(code)
return languageName.charAt(0).toLocaleUpperCase(browserLocale) + languageName.slice(1)
return specialLanguageNames[code] || ISO6391.getName(code)
}
}
}

View file

@ -1,5 +1,3 @@
import { mapGetters } from 'vuex'
const LinkPreview = {
name: 'LinkPreview',
props: [
@ -17,20 +15,11 @@ const LinkPreview = {
// Currently BE shoudn't give cards if tagged NSFW, this is a bit paranoid
// as it makes sure to hide the image if somehow NSFW tagged preview can
// exist.
return this.card.image && !this.censored && this.size !== 'hide'
},
censored () {
return this.nsfw && this.hideNsfwConfig
return this.card.image && !this.nsfw && this.size !== 'hide'
},
useDescription () {
return this.card.description && /\S/.test(this.card.description)
},
hideNsfwConfig () {
return this.mergedConfig.hideNsfw
},
...mapGetters([
'mergedConfig'
])
}
},
created () {
if (this.useImage) {

View file

@ -9,17 +9,12 @@
<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">
<span
v-if="censored"
class="nsfw-alert alert warning"
>{{ $t('status.nsfw') }}</span>
{{ card.provider_name }}
</span>
<span class="card-host faint">{{ card.provider_name }}</span>
<h4 class="card-title">{{ card.title }}</h4>
<p
v-if="useDescription"
@ -55,6 +50,10 @@
}
}
.small-image {
width: 80px;
}
.card-content {
max-height: 100%;
margin: 0.5em;
@ -77,10 +76,6 @@
max-height: calc(1.2em * 3 - 1px);
}
.nsfw-alert {
margin: 2em 0;
}
color: $fallback--text;
color: var(--text, $fallback--text);
border-style: solid;

View file

@ -61,7 +61,7 @@
<button
:disabled="loggingIn"
type="submit"
class="btn button-default"
class="btn btn-default"
>
{{ $t('login.login') }}
</button>
@ -76,15 +76,11 @@
>
<div class="alert error">
{{ error }}
<button
class="button-unstyled"
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
@click="clearError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
/>
</button>
/>
</div>
</div>
</div>

View file

@ -1,46 +1,24 @@
import StillImage from '../still-image/still-image.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import Modal from '../modal/modal.vue'
import PinchZoom from '../pinch_zoom/pinch_zoom.vue'
import SwipeClick from '../swipe_click/swipe_click.vue'
import GestureService from '../../services/gesture_service/gesture_service'
import Flash from 'src/components/flash/flash.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import GestureService from '../../services/gesture_service/gesture_service'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronLeft,
faChevronRight,
faCircleNotch,
faTimes
faChevronRight
} from '@fortawesome/free-solid-svg-icons'
library.add(
faChevronLeft,
faChevronRight,
faCircleNotch,
faTimes
faChevronRight
)
const MediaModal = {
components: {
StillImage,
VideoAttachment,
PinchZoom,
SwipeClick,
Modal,
Flash
},
data () {
return {
loading: false,
swipeDirection: GestureService.DIRECTION_LEFT,
swipeThreshold: () => {
const considerableMoveRatio = 1 / 4
return window.innerWidth * considerableMoveRatio
},
pinchZoomMinScale: 1,
pinchZoomScaleResetLimit: 1.2
}
Modal
},
computed: {
showing () {
@ -49,9 +27,6 @@ const MediaModal = {
media () {
return this.$store.state.mediaViewer.media
},
description () {
return this.currentMedia.description
},
currentIndex () {
return this.$store.state.mediaViewer.currentIndex
},
@ -62,62 +37,43 @@ const MediaModal = {
return this.media.length > 1
},
type () {
return this.currentMedia ? this.getType(this.currentMedia) : null
return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null
}
},
created () {
this.mediaSwipeGestureRight = GestureService.swipeGesture(
GestureService.DIRECTION_RIGHT,
this.goPrev,
50
)
this.mediaSwipeGestureLeft = GestureService.swipeGesture(
GestureService.DIRECTION_LEFT,
this.goNext,
50
)
},
methods: {
getType (media) {
return fileTypeService.fileType(media.mimetype)
mediaTouchStart (e) {
GestureService.beginSwipe(e, this.mediaSwipeGestureRight)
GestureService.beginSwipe(e, this.mediaSwipeGestureLeft)
},
mediaTouchMove (e) {
GestureService.updateSwipe(e, this.mediaSwipeGestureRight)
GestureService.updateSwipe(e, this.mediaSwipeGestureLeft)
},
hide () {
// HACK: Closing immediately via a touch will cause the click
// to be processed on the content below the overlay
const transitionTime = 100 // ms
setTimeout(() => {
this.$store.dispatch('closeMediaViewer')
}, transitionTime)
},
hideIfNotSwiped (event) {
// If we have swiped over SwipeClick, do not trigger hide
const comp = this.$refs.swipeClick
if (!comp) {
this.hide()
} else {
comp.$gesture.click(event)
}
this.$store.dispatch('closeMediaViewer')
},
goPrev () {
if (this.canNavigate) {
const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1)
const newMedia = this.media[prevIndex]
if (this.getType(newMedia) === 'image') {
this.loading = true
}
this.$store.dispatch('setCurrentMedia', newMedia)
this.$store.dispatch('setCurrent', this.media[prevIndex])
}
},
goNext () {
if (this.canNavigate) {
const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1)
const newMedia = this.media[nextIndex]
if (this.getType(newMedia) === 'image') {
this.loading = true
}
this.$store.dispatch('setCurrentMedia', newMedia)
}
},
onImageLoaded () {
this.loading = false
},
handleSwipePreview (offsets) {
this.$refs.pinchZoom.setTransform({ scale: 1, x: offsets[0], y: 0 })
},
handleSwipeEnd (sign) {
this.$refs.pinchZoom.setTransform({ scale: 1, x: 0, y: 0 })
if (sign > 0) {
this.goNext()
} else if (sign < 0) {
this.goPrev()
this.$store.dispatch('setCurrent', this.media[nextIndex])
}
},
handleKeyupEvent (e) {
@ -142,7 +98,7 @@ const MediaModal = {
document.addEventListener('keyup', this.handleKeyupEvent)
document.addEventListener('keydown', this.handleKeydownEvent)
},
unmounted () {
destroyed () {
window.removeEventListener('popstate', this.hide)
document.removeEventListener('keyup', this.handleKeyupEvent)
document.removeEventListener('keydown', this.handleKeydownEvent)

View file

@ -2,38 +2,18 @@
<Modal
v-if="showing"
class="media-modal-view"
@backdropClicked="hideIfNotSwiped"
@backdropClicked="hide"
>
<SwipeClick
<img
v-if="type === 'image'"
ref="swipeClick"
class="modal-image-container"
:direction="swipeDirection"
:threshold="swipeThreshold"
@preview-requested="handleSwipePreview"
@swipe-finished="handleSwipeEnd"
@swipeless-clicked="hide"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
@touchstart.stop="mediaTouchStart"
@touchmove.stop="mediaTouchMove"
@click="hide"
>
<PinchZoom
ref="pinchZoom"
class="modal-image-container-inner"
selector=".modal-image"
reach-min-scale-strategy="reset"
stop-propagate-handled="stop-propgate-handled"
:allow-pan-min-scale="pinchZoomMinScale"
:min-scale="pinchZoomMinScale"
:reset-to-min-scale-limit="pinchZoomScaleResetLimit"
>
<img
:class="{ loading }"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
@load="onImageLoaded"
>
</PinchZoom>
</SwipeClick>
<VideoAttachment
v-if="type === 'video'"
class="modal-image"
@ -48,84 +28,38 @@
:title="currentMedia.description"
controls
/>
<Flash
v-if="type === 'flash'"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
/>
<button
v-if="canNavigate"
:title="$t('media_modal.previous')"
class="modal-view-button modal-view-button-arrow modal-view-button-arrow--prev"
class="modal-view-button-arrow modal-view-button-arrow--prev"
@click.stop.prevent="goPrev"
>
<FAIcon
class="button-icon arrow-icon"
class="arrow-icon"
icon="chevron-left"
/>
</button>
<button
v-if="canNavigate"
:title="$t('media_modal.next')"
class="modal-view-button modal-view-button-arrow modal-view-button-arrow--next"
class="modal-view-button-arrow modal-view-button-arrow--next"
@click.stop.prevent="goNext"
>
<FAIcon
class="button-icon arrow-icon"
class="arrow-icon"
icon="chevron-right"
/>
</button>
<button
class="modal-view-button modal-view-button-hide"
:title="$t('media_modal.hide')"
@click.stop.prevent="hide"
>
<FAIcon
class="button-icon"
icon="times"
/>
</button>
<span
v-if="description"
class="description"
>
{{ description }}
</span>
<span
class="counter"
>
{{ $tc('media_modal.counter', currentIndex + 1, { current: currentIndex + 1, total: media.length }) }}
</span>
<span
v-if="loading"
class="loading-spinner"
>
<FAIcon
spin
icon="circle-notch"
size="5x"
/>
</span>
</Modal>
</template>
<script src="./media_modal.js"></script>
<style lang="scss">
$modal-view-button-icon-height: 3em;
$modal-view-button-icon-half-height: calc(#{$modal-view-button-icon-height} / 2);
$modal-view-button-icon-width: 3em;
$modal-view-button-icon-margin: 0.5em;
.modal-view.media-modal-view {
z-index: 1001;
flex-direction: column;
.modal-view-button-arrow,
.modal-view-button-hide {
.modal-view-button-arrow {
opacity: 0.75;
&:focus,
@ -133,154 +67,59 @@ $modal-view-button-icon-margin: 0.5em;
outline: none;
box-shadow: none;
}
&:hover {
opacity: 1;
}
}
overflow: hidden;
}
.media-modal-view {
@keyframes media-fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-image {
max-width: 90%;
max-height: 90%;
box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
image-orientation: from-image; // NOTE: only FF supports this
}
.modal-image-container {
display: flex;
overflow: hidden;
align-items: center;
flex-direction: column;
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
flex-grow: 1;
justify-content: center;
.modal-view-button-arrow {
position: absolute;
display: block;
top: 50%;
margin-top: -50px;
width: 70px;
height: 100px;
border: 0;
padding: 0;
opacity: 0;
box-shadow: none;
background: none;
appearance: none;
overflow: visible;
cursor: pointer;
transition: opacity 333ms cubic-bezier(.4,0,.22,1);
&-inner {
width: 100%;
height: 100%;
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
}
.description,
.counter {
/* Hardcoded since background is also hardcoded */
color: white;
margin-top: 1em;
text-shadow: 0 0 10px black, 0 0 10px black;
padding: 0.2em 2em;
}
.description {
flex: 0 0 auto;
overflow-y: auto;
min-height: 1em;
max-width: 500px;
max-height: 9.5em;
word-break: break-all;
}
.modal-image {
max-width: 100%;
max-height: 100%;
image-orientation: from-image; // NOTE: only FF supports this
animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein;
&.loading {
opacity: 0.5;
}
}
.loading-spinner {
width: 100%;
height: 100%;
.arrow-icon {
position: absolute;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
svg {
color: white;
}
top: 35px;
height: 30px;
width: 32px;
font-size: 14px;
line-height: 30px;
color: #FFF;
text-align: center;
background-color: rgba(0,0,0,.3);
}
.modal-view-button {
border: 0;
padding: 0;
opacity: 0;
box-shadow: none;
background: none;
appearance: none;
overflow: visible;
cursor: pointer;
transition: opacity 333ms cubic-bezier(.4,0,.22,1);
height: $modal-view-button-icon-height;
width: $modal-view-button-icon-width;
.button-icon {
position: absolute;
height: $modal-view-button-icon-height;
width: $modal-view-button-icon-width;
font-size: 14px;
line-height: $modal-view-button-icon-height;
color: #FFF;
text-align: center;
background-color: rgba(0,0,0,.3);
}
}
.modal-view-button-arrow {
position: absolute;
display: block;
top: 50%;
margin-top: $modal-view-button-icon-half-height;
width: $modal-view-button-icon-width;
height: $modal-view-button-icon-height;
&--prev {
left: 0;
.arrow-icon {
position: absolute;
top: 0;
line-height: $modal-view-button-icon-height;
color: #FFF;
text-align: center;
background-color: rgba(0,0,0,.3);
}
&--prev {
left: 0;
.arrow-icon {
left: $modal-view-button-icon-margin;
}
}
&--next {
right: 0;
.arrow-icon {
right: $modal-view-button-icon-margin;
}
left: 6px;
}
}
.modal-view-button-hide {
position: absolute;
top: 0;
&--next {
right: 0;
.button-icon {
top: $modal-view-button-icon-margin;
right: $modal-view-button-icon-margin;
.arrow-icon {
right: 6px;
}
}
}

View file

@ -1,29 +1,33 @@
<template>
<label
<div
class="media-upload"
:class="{ disabled: disabled }"
:title="$t('tool_tip.media_upload')"
>
<FAIcon
v-if="uploading"
class="progress-icon"
icon="circle-notch"
spin
/>
<FAIcon
v-if="!uploading"
class="new-icon"
icon="upload"
/>
<input
v-if="uploadReady"
:disabled="disabled"
type="file"
style="position: fixed; top: -100em"
multiple="true"
@change="change"
<label
class="label"
:title="$t('tool_tip.media_upload')"
>
</label>
<FAIcon
v-if="uploading"
class="progress-icon"
icon="circle-notch"
spin
/>
<FAIcon
v-if="!uploading"
class="new-icon"
icon="upload"
/>
<input
v-if="uploadReady"
:disabled="disabled"
type="file"
style="position: fixed; top: -100em"
multiple="true"
@change="change"
>
</label>
</div>
</template>
<script src="./media_upload.js" ></script>
@ -32,6 +36,12 @@
@import '../../_variables.scss';
.media-upload {
cursor: pointer;
.label {
display: inline-block;
}
.new-icon {
cursor: pointer;
}
}
</style>

View file

@ -1,134 +0,0 @@
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters, mapState } from 'vuex'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import UserAvatar from '../user_avatar/user_avatar.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAt
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAt
)
const MentionLink = {
name: 'MentionLink',
components: {
UserAvatar
},
props: {
url: {
required: true,
type: String
},
content: {
required: true,
type: String
},
userId: {
required: false,
type: String
},
userScreenName: {
required: false,
type: String
}
},
methods: {
onClick () {
const link = generateProfileLink(
this.userId || this.user.id,
this.userScreenName || this.user.screen_name
)
this.$router.push(link)
}
},
computed: {
user () {
return this.url && this.$store && this.$store.getters.findUserByUrl(this.url)
},
isYou () {
// FIXME why user !== currentUser???
return this.user && this.user.id === this.currentUser.id
},
userName () {
return this.user && this.userNameFullUi.split('@')[0]
},
serverName () {
// XXX assumed that domain does not contain @
return this.user && (this.userNameFullUi.split('@')[1] || this.$store.getters.instanceDomain)
},
userNameFull () {
return this.user && this.user.screen_name
},
userNameFullUi () {
return this.user && this.user.screen_name_ui
},
highlight () {
return this.user && this.mergedConfig.highlight[this.user.screen_name]
},
highlightType () {
return this.highlight && ('-' + this.highlight.type)
},
highlightClass () {
if (this.highlight) return highlightClass(this.user)
},
style () {
if (this.highlight) {
const {
backgroundColor,
backgroundPosition,
backgroundImage,
...rest
} = highlightStyle(this.highlight)
return rest
}
},
classnames () {
return [
{
'-you': this.isYou && this.shouldBoldenYou,
'-highlighted': this.highlight
},
this.highlightType
]
},
useAtIcon () {
return this.mergedConfig.useAtIcon
},
isRemote () {
return this.userName !== this.userNameFull
},
shouldShowFullUserName () {
const conf = this.mergedConfig.mentionLinkDisplay
if (conf === 'short') {
return false
} else if (conf === 'full') {
return true
} else { // full_for_remote
return this.isRemote
}
},
shouldShowTooltip () {
return this.mergedConfig.mentionLinkShowTooltip && this.mergedConfig.mentionLinkDisplay === 'short' && this.isRemote
},
shouldShowAvatar () {
return this.mergedConfig.mentionLinkShowAvatar
},
shouldShowYous () {
return this.mergedConfig.mentionLinkShowYous
},
shouldBoldenYou () {
return this.mergedConfig.mentionLinkBoldenYou
},
shouldFadeDomain () {
return this.mergedConfig.mentionLinkFadeDomain
},
...mapGetters(['mergedConfig']),
...mapState({
currentUser: state => state.users.currentUser
})
}
}
export default MentionLink

View file

@ -1,115 +0,0 @@
@import '../../_variables.scss';
.MentionLink {
position: relative;
white-space: normal;
display: inline;
color: var(--link);
word-break: normal;
& .new,
& .original {
display: inline;
border-radius: 2px;
}
.mention-avatar {
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
width: 1.5em;
height: 1.5em;
vertical-align: middle;
user-select: none;
margin-right: 0.2em;
}
.full {
position: absolute;
display: inline-block;
pointer-events: none;
opacity: 0;
top: 100%;
left: 0;
height: 100%;
word-wrap: normal;
white-space: nowrap;
transition: opacity 0.2s ease;
z-index: 1;
margin-top: 0.25em;
padding: 0.5em;
user-select: all;
}
& .short.-with-tooltip,
& .you {
user-select: none;
}
& .short,
& .full {
white-space: nowrap;
}
.shortName {
white-space: normal;
}
.new {
&.-you {
& .shortName,
& .full {
font-weight: 600;
}
}
.at {
color: var(--link);
opacity: 0.8;
display: inline-block;
line-height: 1;
padding: 0 0.1em;
vertical-align: -25%;
margin: 0;
}
&.-striped {
& .shortName,
& .full {
background-image:
repeating-linear-gradient(
135deg,
var(--____highlight-tintColor),
var(--____highlight-tintColor) 5px,
var(--____highlight-tintColor2) 5px,
var(--____highlight-tintColor2) 10px
);
}
}
&.-solid {
& .shortName,
& .full {
background-image: linear-gradient(var(--____highlight-tintColor2), var(--____highlight-tintColor2));
}
}
&.-side {
& .shortName,
& .userNameFull {
box-shadow: 0 -5px 3px -4px inset var(--____highlight-solidColor);
}
}
}
&:hover .new .full {
opacity: 1;
pointer-events: initial;
}
.serverName.-faded {
color: var(--faintLink, $fallback--link);
}
.full .-faded {
color: var(--faint, $fallback--faint);
}
}

View file

@ -1,77 +0,0 @@
<template>
<span
class="MentionLink"
>
<!-- eslint-disable vue/no-v-html -->
<a
v-if="!user"
:href="url"
class="original"
target="_blank"
v-html="content"
/><!-- eslint-enable vue/no-v-html --><span
v-if="user"
class="new"
:style="style"
:class="classnames"
>
<a
class="short button-unstyled"
:class="{ '-with-tooltip': shouldShowTooltip }"
:href="url"
@click.prevent="onClick"
>
<!-- eslint-disable vue/no-v-html -->
<UserAvatar
v-if="shouldShowAvatar"
class="mention-avatar"
:user="user"
/><span
class="shortName"
><FAIcon
v-if="useAtIcon"
size="sm"
icon="at"
class="at"
/>{{ !useAtIcon ? '@' : '' }}<span
class="userName"
v-html="userName"
/><span
v-if="shouldShowFullUserName"
class="serverName"
:class="{ '-faded': shouldFadeDomain }"
v-html="'@' + serverName"
/>
</span>
<span
v-if="isYou && shouldShowYous"
:class="{ '-you': shouldBoldenYou }"
> {{ ' ' + $t('status.you') }}</span>
<!-- eslint-enable vue/no-v-html -->
</a><span
v-if="shouldShowTooltip"
class="full popover-default"
:class="[highlightType]"
>
<span
class="userNameFull"
>
<!-- eslint-disable vue/no-v-html -->
@<span
class="userName"
v-html="userName"
/><span
class="serverName"
:class="{ '-faded': shouldFadeDomain }"
v-html="'@' + serverName"
/>
<!-- eslint-enable vue/no-v-html -->
</span>
</span>
</span>
</span>
</template>
<script src="./mention_link.js"/>
<style lang="scss" src="./mention_link.scss"/>

View file

@ -1,37 +0,0 @@
import MentionLink from 'src/components/mention_link/mention_link.vue'
import { mapGetters } from 'vuex'
export const MENTIONS_LIMIT = 5
const MentionsLine = {
name: 'MentionsLine',
props: {
mentions: {
required: true,
type: Array
}
},
data: () => ({ expanded: false }),
components: {
MentionLink
},
computed: {
mentionsComputed () {
return this.mentions.slice(0, MENTIONS_LIMIT)
},
extraMentions () {
return this.mentions.slice(MENTIONS_LIMIT)
},
manyMentions () {
return this.extraMentions.length > 0
},
...mapGetters(['mergedConfig'])
},
methods: {
toggleShowMore () {
this.expanded = !this.expanded
}
}
}
export default MentionsLine

View file

@ -1,13 +0,0 @@
.MentionsLine {
word-break: break-all;
.mention-link:not(:first-child)::before {
content: ' ';
}
.showMoreLess {
margin-left: 0.5em;
white-space: normal;
color: var(--link);
}
}

View file

@ -1,41 +0,0 @@
<template>
<span class="MentionsLine">
<MentionLink
v-for="mention in mentionsComputed"
:key="mention.index"
class="mention-link"
:content="mention.content"
:url="mention.url"
/><span
v-if="manyMentions"
class="extraMentions"
>
<span
v-if="expanded"
class="fullExtraMentions"
>
<MentionLink
v-for="mention in extraMentions"
:key="mention.index"
class="mention-link"
:content="mention.content"
:url="mention.url"
/>
</span><button
v-if="!expanded"
class="button-unstyled showMoreLess"
@click="toggleShowMore"
>
{{ $t('status.plus_more', { number: extraMentions.length }) }}
</button><button
v-if="expanded"
class="button-unstyled showMoreLess"
@click="toggleShowMore"
>
{{ $t('general.show_less') }}
</button>
</span>
</span>
</template>
<script src="./mentions_line.js" ></script>
<style lang="scss" src="./mentions_line.scss" />

View file

@ -23,25 +23,23 @@
<div class="form-group">
<div class="login-bottom">
<div>
<button
class="button-unstyled -link"
type="button"
<a
href="#"
@click.prevent="requireTOTP"
>
{{ $t('login.enter_two_factor_code') }}
</button>
</a>
<br>
<button
class="button-unstyled -link"
type="button"
<a
href="#"
@click.prevent="abortMFA"
>
{{ $t('general.cancel') }}
</button>
</a>
</div>
<button
type="submit"
class="btn button-default"
class="btn btn-default"
>
{{ $t('general.verify') }}
</button>
@ -56,15 +54,11 @@
>
<div class="alert error">
{{ error }}
<button
class="button-unstyled"
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
@click="clearError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
/>
</button>
/>
</div>
</div>
</div>

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