Compare commits

..

7 commits

584 changed files with 18220 additions and 68183 deletions

View file

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

View file

@ -1,26 +1,22 @@
module.exports = { module.exports = {
root: true, root: true,
parser: 'babel-eslint',
parserOptions: { parserOptions: {
parser: 'babel-eslint',
sourceType: 'module' sourceType: 'module'
}, },
// https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style
extends: [ extends: 'standard',
'standard',
'plugin:vue/recommended'
],
// required to lint *.vue files // required to lint *.vue files
plugins: [ plugins: [
'vue' 'html'
], ],
// add your custom rules here // add your custom rules here
rules: { 'rules': {
// allow paren-less arrow functions // allow paren-less arrow functions
'arrow-parens': 0, 'arrow-parens': 0,
// allow async-await // allow async-await
'generator-star-spacing': 0, 'generator-star-spacing': 0,
// allow debugger during development // allow debugger during development
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0
'vue/require-prop-types': 0
} }
} }

View file

@ -1,31 +1,24 @@
# This file is a template, and might need editing before it works on your project. # 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: # Official framework image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/node/tags/ # https://hub.docker.com/r/library/node/tags/
image: node:12 image: node:7
stages: stages:
- lint - lint
- build - build
- test - test
- deploy
lint: lint:
stage: lint stage: lint
script: script:
- yarn - yarn
- npm run lint - npm run lint
- npm run stylelint
test: test:
stage: test stage: test
variables:
APT_CACHE_DIR: apt-cache
script: script:
- mkdir -pv $APT_CACHE_DIR && apt-get -qq update
- apt install firefox-esr -y --no-install-recommends
- firefox --version
- yarn - yarn
- yarn unit - npm run unit
build: build:
stage: build stage: build
@ -35,13 +28,3 @@ build:
artifacts: artifacts:
paths: paths:
- dist/ - dist/
docs-deploy:
stage: deploy
image: alpine:latest
only:
- develop@pleroma/pleroma-fe
before_script:
- apk add curl
script:
- curl -X POST -F"token=$DOCS_PIPELINE_TRIGGER" -F'ref=master' https://git.pleroma.social/api/v4/projects/673/trigger/pipeline

View file

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

View file

@ -1,19 +0,0 @@
{
"extends": [
"stylelint-rscss/config",
"stylelint-config-recommended",
"stylelint-config-standard"
],
"rules": {
"declaration-no-important": true,
"rscss/no-descendant-combinator": false,
"rscss/class-format": [
true,
{
"component": "pascal-case",
"variant": "^-[a-z]\\w+",
"element": "^[a-z]\\w+"
}
]
}
}

View file

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

35
CHANGELOG Normal file
View file

@ -0,0 +1,35 @@
## 2017-02-20
- Overall CSS styling fixes
- Current theme is displayed in theme selector
- Theme selector is moved to the settings page
- Oembed attachments will now display correctly
- Styling changes to the user info cards
- Notification count in title
- Better Notification handling (persistance, mark as read)
- Post statuses with ctrl+enter
- Links in statuses open in a new tab
- Optimized mobile view
- Fix crash on persistance failure
- Compress persisted state
- Sync mutes with backend (SEE NOTE BELOW)
Pleroma will now try to get the current mutes from the backend. Sadly, a bug in
Qvitter will not allow getting the mutes from the endpoint, because it will
ignore HTTP Basic authentication. Mutes will still persist in Pleroma through
localstorage, but the mutes from Qvitter won't be picked up if the call fails.
The patch for Qvitter:
--- a/actions/apiqvittermutes.php
+++ b/actions/apiqvittermutes.php
@@ -74,7 +74,7 @@ class ApiQvitterMutesAction extends ApiPrivateAuthAction
{
parent::handle();
- $this->target = Profile::current();
+ $this->target = $this->scoped;
if(!$this->target instanceof Profile) {
$this->clientError(_('You have to be logged in to view your mutes.'), 403);

View file

@ -1,305 +0,0 @@
# Changelog
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
### 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 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
- Polls will be hidden with status content if "Collapse posts with subjects" is enabled and the post is collapsed.
### Fixed
- Network fetches don't pile up anymore but wait for previous ones to finish to reduce throttling.
- Autocomplete won't stop at the second @, so it'll still work with "@lain@l" and not start over.
- Fixed weird autocomplete behavior when you write ":custom_emoji: ?"
## [2.1.0] - 2020-08-28
### Added
- Autocomplete domains from list of known instances
- 'Bot' settings option and badge
- Added profile meta data fields that can be set in profile settings
- Added option to reset avatar/banner in profile settings
- Descriptions can be set on uploaded files before posting
- Added status preview option to preview your statuses before posting
- When a post is a reply to an unavailable post, the 'Reply to'-text has a strike-through style
- Added ability to see all favoriting or repeating users when hovering the number on highlighted statuses
- Bookmarks
### Changed
- Change heart to thumbs up in reaction picker
- Close the media modal on navigation events
- Add colons to the emoji alt text, to make them copyable
- Add better visual indication for drag-and-drop for files
- When disabling attachments, the placeholder links now show an icon and the description instead of just IMAGE or VIDEO etc
- Remove unnecessary options for 'automatic loading when loading older' and 'reply previews'
- Greentext now has separate color slot for it
- Removed the use of with_move parameters when fetching notifications
- Push notifications now are the same as normal notfication, and are localized.
- Updated Notification Settings to match new BE API
### Fixed
- Custom Emoji will display in poll options now.
- Status ellipsis menu closes properly when selecting certain options
- Cropped images look correct in Chrome
- Newlines in the muted words settings work again
- Clicking on non-latin hashtags won't open a new window
- Uploading and drag-dropping multiple files works correctly now.
- Subject field now appears disabled when posting
- Fix status ellipsis menu being cut off in notifications column
- Fixed autocomplete sometimes not returning the right user when there's already some results
- Videos and audio and misc files show description as alt/title properly now
- Clicking on non-image/video files no longer opens an empty modal
- Audio files can now be played back in the frontend with hidden attachments
- Videos are not cropped awkwardly in the uploads section anymore
- Reply filtering options in Settings -> Filtering now work again using filtering on server
- Don't show just blank-screen when cookies are disabled
- Add status idempotency to prevent accidental double posting when posting returns an error
- Weird bug related to post being sent seemingly after pasting with keyboard (hopefully)
- Multiple issues with muted statuses/notifications
## [2.0.5] - 2020-05-12
### Added
- Added private notifications option for push notifications
- 'Copy link' button for statuses (in the ellipsis menu)
### Changed
- Registration page no longer requires email if the server is configured not to require it
### Fixed
- Status ellipsis menu closes properly when selecting certain options
## [2.0.3] - 2020-05-02
### Fixed
- Show more/less works correctly with auto-collapsed subjects and long posts
- RTL characters won't look messed up in notifications
### Changed
- Emoji autocomplete will match any part of the word and not just start, for example :drool will now helpfully suggest :blobcatdrool: and :blobcatdroolreach:
### Added
- Follow request notification support
## [2.0.2] - 2020-04-08
### Fixed
- Favorite/Repeat avatars not showing up on private instances/non-public posts
- Autocorrect getting triggered in the captcha field
- Overflow on long domains in follow/move notifications
### Changed
- Polish translation updated
## [2.0.0] - 2020-02-28
### Added
- Tons of color slots including ones for hover/pressed/toggled buttons
- Experimental `--variable[,mod]` syntax support for color slots in themes. the `mod` makes color brighter/darker depending on background color (makes darker color brighter/darker depending on background color)
- Paper theme by Shpuld
- Icons in nav panel
- Private mode support
- Support for 'Move' type notifications
- Pleroma AMOLED dark theme
- User level domain mutes, under User Settings -> Mutes
- Emoji reactions for statuses
- MRF keyword policy disclosure
### Changed
- Updated Pleroma default themes
- theme engine update to 3 (themes v2.1 introduction)
- massive internal changes in theme engine - slowly away from "generate things separately with spaghetti code" towards "feed all data into single 'generateTheme' function and declare slot inheritance and all in a separate file"
- Breezy theme updates to make it closer to actual Breeze in some aspects
- when using `--variable` in shadows it no longer uses the actual CSS3 variable, instead it generates color from other slots
- theme doesn't get saved to local storage when opening FE anonymously
- Captcha now resets on failed registrations
- Notifications column now cleans itself up to optimize performance when tab is left open for a long time
- 403 messaging
### Fixed
- Fixed loader-spinner not disappearing when a status preview fails to load
- anon viewers won't get theme data saved to local storage, so admin changing default theme will have an effect for users coming back to instance.
- Single notifications left unread when hitting read on another device/tab
- Registration fixed
- Deactivation of remote accounts from frontend
- Fixed NSFW unhiding not working with videos when using one-click unhiding/displaying
- Improved performance of anything that uses popovers (most notably statuses)
## [1.1.7 and earlier] - 2019-12-14
### Added
- Ability to hide/show repeats from user
- User profile button clutter organized into a menu
- Emoji picker
- Started changelog anew
- Ability to change user's email
- About page
- Added remote user redirect
### 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 - Constance Variable (lambadalambda@social.heldscal.la): Code
- Coco Snuss (cocosnuss@social.heldscal.la): Code - Coco Snuss (cocosnuss@social.heldscal.la): Code
- wakarimasen (wakarimasen@shitposter.club): NSFW hiding image - wakarimasen (wakarimasen@shitposter.club): NSFW hiding image
- eris (eris@disqordia.space): Code
- dtluna (dtluna@social.heldscal.la): Code - dtluna (dtluna@social.heldscal.la): Code
- sonyam (sonyam@social.heldscal.la): Background images - sonyam (sonyam@social.heldscal.la): Background images
- hakui (hakui@freezepeach.xyz): CSS and styling - hakui (hakui@freezepeach.xyz): CSS and styling

View file

@ -1,8 +1,8 @@
# Pleroma-FE # pleroma_fe
> A single column frontend designed for Pleroma. > A single column frontend for both Pleroma and GS servers.
![screenshot](/uploads/796c5ecf985ed1e2b0943ee0df131ed0/DJVqSJ0.png) ![screenshot](https://i.imgur.com/DJVqSJ0.png)
# For Translators # For Translators
@ -11,6 +11,7 @@ To translate Pleroma-FE, add your language to [src/i18n/messages.js](https://git
# FOR ADMINS # FOR ADMINS
You don't need to build Pleroma-FE yourself. Those using the Pleroma backend will be able to use it out of the box. You don't need to build Pleroma-FE yourself. Those using the Pleroma backend will be able to use it out of the box.
For the GNU social backend, check out https://git.pleroma.social/pleroma/pleroma-fe/wikis/dual-boot-with-qvitter to see how to run Pleroma-FE and Qvitter at the same time.
## Build Setup ## Build Setup
@ -40,7 +41,7 @@ FE Build process also leaves current commit hash in global variable `___pleromaf
# Configuration # Configuration
Edit config.json for configuration. Edit config.json for configuration. scopeOptionsEnabled gives you input fields for CWs and the scope settings.
## Options ## Options

View file

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

View file

@ -1,63 +1,61 @@
var path = require('path') var path = require('path')
var config = require('../config') var config = require('../config')
var sass = require('sass') var ExtractTextPlugin = require('extract-text-webpack-plugin')
var MiniCssExtractPlugin = require('mini-css-extract-plugin')
exports.assetsPath = function (_path) { exports.assetsPath = function (_path) {
var assetsSubDirectory = process.env.NODE_ENV === 'production' var assetsSubDirectory = process.env.NODE_ENV === 'production'
? config.build.assetsSubDirectory ? config.build.assetsSubDirectory
: config.dev.assetsSubDirectory : config.dev.assetsSubDirectory
return path.posix.join(assetsSubDirectory, _path) return path.posix.join(assetsSubDirectory, _path)
} }
exports.cssLoaders = function (options) { exports.cssLoaders = function (options) {
options = options || {} options = options || {}
// generate loader string to be used with extract text plugin
function generateLoaders (loaders) { function generateLoaders (loaders) {
var sourceLoader = loaders.map(function (loader) {
var extraParamChar
if (/\?/.test(loader)) {
loader = loader.replace(/\?/, '-loader?')
extraParamChar = '&'
} else {
loader = loader + '-loader'
extraParamChar = '?'
}
return loader + (options.sourceMap ? extraParamChar + 'sourceMap' : '')
}).join('!')
// Extract CSS when that option is specified // Extract CSS when that option is specified
// (which is the case during production build) // (which is the case during production build)
if (options.extract) { if (options.extract) {
return [MiniCssExtractPlugin.loader].concat(loaders) return ExtractTextPlugin.extract('vue-style-loader', sourceLoader)
} else { } else {
return ['vue-style-loader'].concat(loaders) return ['vue-style-loader', sourceLoader].join('!')
} }
} }
// http://vuejs.github.io/vue-loader/configurations/extract-css.html // http://vuejs.github.io/vue-loader/configurations/extract-css.html
return [ return {
{ css: generateLoaders(['css']),
test: /\.(post)?css$/, postcss: generateLoaders(['css']),
use: generateLoaders(['css-loader', 'postcss-loader']), less: generateLoaders(['css', 'less']),
}, sass: generateLoaders(['css', 'sass?indentedSyntax']),
{ scss: generateLoaders(['css', 'sass']),
test: /\.less$/, stylus: generateLoaders(['css', 'stylus']),
use: generateLoaders(['css-loader', 'postcss-loader', 'less-loader']), styl: generateLoaders(['css', 'stylus'])
}, }
{
test: /\.sass$/,
use: generateLoaders([
'css-loader',
'postcss-loader',
{
loader: 'sass-loader',
options: {
indentedSyntax: true
}
}
])
},
{
test: /\.scss$/,
use: generateLoaders(['css-loader', 'postcss-loader', 'sass-loader'])
},
{
test: /\.styl(us)?$/,
use: generateLoaders(['css-loader', 'postcss-loader', 'stylus-loader']),
},
]
} }
// Generate loaders for standalone style files (outside of .vue) // Generate loaders for standalone style files (outside of .vue)
exports.styleLoaders = function (options) { exports.styleLoaders = function (options) {
return exports.cssLoaders(options) var output = []
var loaders = exports.cssLoaders(options)
for (var extension in loaders) {
var loader = loaders[extension]
output.push({
test: new RegExp('\\.' + extension + '$'),
loader: loader
})
}
return output
} }

View file

@ -3,8 +3,6 @@ var config = require('../config')
var utils = require('./utils') var utils = require('./utils')
var projectRoot = path.resolve(__dirname, '../') var projectRoot = path.resolve(__dirname, '../')
var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin') var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin')
var CopyPlugin = require('copy-webpack-plugin');
var { VueLoaderPlugin } = require('vue-loader')
var env = process.env.NODE_ENV var env = process.env.NODE_ENV
// check env & config/index.js to decide weither to enable CSS Sourcemaps for the // check env & config/index.js to decide weither to enable CSS Sourcemaps for the
@ -13,8 +11,6 @@ var cssSourceMapDev = (env === 'development' && config.dev.cssSourceMap)
var cssSourceMapProd = (env === 'production' && config.build.productionSourceMap) var cssSourceMapProd = (env === 'production' && config.build.productionSourceMap)
var useCssSourceMap = cssSourceMapDev || cssSourceMapProd var useCssSourceMap = cssSourceMapDev || cssSourceMapProd
var now = Date.now()
module.exports = { module.exports = {
entry: { entry: {
app: './src/main.js' app: './src/main.js'
@ -24,110 +20,83 @@ module.exports = {
publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath, publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath,
filename: '[name].js' filename: '[name].js'
}, },
optimization: {
splitChunks: {
chunks: 'all'
}
},
resolve: { resolve: {
extensions: ['.js', '.jsx', '.vue'], extensions: ['', '.js', '.vue'],
modules: [ fallback: [path.join(__dirname, '../node_modules')],
path.join(__dirname, '../node_modules')
],
alias: { alias: {
'static': path.resolve(__dirname, '../static'), 'vue$': 'vue/dist/vue.runtime.common',
'src': path.resolve(__dirname, '../src'), 'src': path.resolve(__dirname, '../src'),
'assets': path.resolve(__dirname, '../src/assets'), 'assets': path.resolve(__dirname, '../src/assets'),
'components': path.resolve(__dirname, '../src/components'), 'components': path.resolve(__dirname, '../src/components')
'vue-i18n': 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js'
} }
}, },
resolveLoader: {
fallback: [path.join(__dirname, '../node_modules')]
},
module: { module: {
noParse: /node_modules\/localforage\/dist\/localforage.js/, noParse: /node_modules\/localforage\/dist\/localforage.js/,
rules: [ preLoaders: [
{
enforce: 'pre',
test: /\.(js|vue)$/,
include: projectRoot,
exclude: /node_modules/,
use: {
loader: 'eslint-loader',
options: {
formatter: require('eslint-friendly-formatter'),
sourceMap: config.build.productionSourceMap,
extract: true
}
}
},
{
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$/, test: /\.vue$/,
loader: 'vue-loader', loader: 'eslint',
options: { include: projectRoot,
compilerOptions: { exclude: /node_modules/
isCustomElement(tag) { },
if (tag === 'pinch-zoom') { {
return true test: /\.js$/,
} loader: 'eslint',
return false include: projectRoot,
} exclude: /node_modules/
} }
} ],
loaders: [
{
test: /\.vue$/,
loader: 'vue'
}, },
{ {
test: /\.jsx?$/, test: /\.jsx?$/,
loader: 'babel',
include: projectRoot, include: projectRoot,
exclude: /node_modules\/(?!tributejs)/, exclude: /node_modules\/(?!tributejs)/
use: 'babel-loader' },
{
test: /\.json$/,
loader: 'json'
}, },
{ {
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
use: { loader: 'url',
loader: 'url-loader', query: {
options: { limit: 10000,
limit: 10000, name: utils.assetsPath('img/[name].[hash:7].[ext]')
name: utils.assetsPath('img/[name].[hash:7].[ext]')
}
} }
}, },
{ {
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
use: { loader: 'url',
loader: 'url-loader', query: {
options: { limit: 10000,
limit: 10000, name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
}
} }
}, }
]
},
eslint: {
formatter: require('eslint-friendly-formatter')
},
vue: {
loaders: utils.cssLoaders({ sourceMap: useCssSourceMap }),
postcss: [
require('autoprefixer')({
browsers: ['last 2 versions']
})
] ]
}, },
plugins: [ plugins: [
new ServiceWorkerWebpackPlugin({ new ServiceWorkerWebpackPlugin({
entry: path.join(__dirname, '..', 'src/sw.js'), entry: path.join(__dirname, '..', 'src/sw.js'),
filename: 'sw-pleroma.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

@ -12,21 +12,20 @@ Object.keys(baseWebpackConfig.entry).forEach(function (name) {
module.exports = merge(baseWebpackConfig, { module.exports = merge(baseWebpackConfig, {
module: { module: {
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap }) loaders: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap })
}, },
mode: 'development',
// eval-source-map is faster for development // eval-source-map is faster for development
devtool: '#eval-source-map', devtool: '#eval-source-map',
plugins: [ plugins: [
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env': config.dev.env, 'process.env': config.dev.env,
'COMMIT_HASH': JSON.stringify('DEV'), 'COMMIT_HASH': JSON.stringify('DEV'),
'DEV_OVERRIDES': JSON.stringify(config.dev.settings), 'DEV_OVERRIDES': JSON.stringify(config.dev.settings)
'__VUE_OPTIONS_API__': true,
'__VUE_PROD_DEVTOOLS__': false
}), }),
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage // https://github.com/glenjamin/webpack-hot-middleware#installation--usage
new webpack.optimize.OccurenceOrderPlugin(),
new webpack.HotModuleReplacementPlugin(), new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin(),
// https://github.com/ampedandwired/html-webpack-plugin // https://github.com/ampedandwired/html-webpack-plugin
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
filename: 'index.html', filename: 'index.html',

View file

@ -4,7 +4,7 @@ var utils = require('./utils')
var webpack = require('webpack') var webpack = require('webpack')
var merge = require('webpack-merge') var merge = require('webpack-merge')
var baseWebpackConfig = require('./webpack.base.conf') var baseWebpackConfig = require('./webpack.base.conf')
var MiniCssExtractPlugin = require('mini-css-extract-plugin') var ExtractTextPlugin = require('extract-text-webpack-plugin')
var HtmlWebpackPlugin = require('html-webpack-plugin') var HtmlWebpackPlugin = require('html-webpack-plugin')
var env = process.env.NODE_ENV === 'testing' var env = process.env.NODE_ENV === 'testing'
? require('../config/test.env') ? require('../config/test.env')
@ -13,37 +13,39 @@ var env = process.env.NODE_ENV === 'testing'
let commitHash = require('child_process') let commitHash = require('child_process')
.execSync('git rev-parse --short HEAD') .execSync('git rev-parse --short HEAD')
.toString(); .toString();
console.log(commitHash)
var webpackConfig = merge(baseWebpackConfig, { var webpackConfig = merge(baseWebpackConfig, {
mode: 'production',
module: { module: {
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, extract: true }) loaders: utils.styleLoaders({ sourceMap: config.build.productionSourceMap, extract: true })
}, },
devtool: config.build.productionSourceMap ? '#source-map' : false, devtool: config.build.productionSourceMap ? '#source-map' : false,
optimization: {
minimize: true,
splitChunks: {
chunks: 'all'
}
},
output: { output: {
path: config.build.assetsRoot, path: config.build.assetsRoot,
filename: utils.assetsPath('js/[name].[chunkhash].js'), filename: utils.assetsPath('js/[name].[chunkhash].js'),
chunkFilename: utils.assetsPath('js/[name].[chunkhash].js') chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
},
vue: {
loaders: utils.cssLoaders({
sourceMap: config.build.productionSourceMap,
extract: true
})
}, },
plugins: [ plugins: [
// http://vuejs.github.io/vue-loader/workflow/production.html // http://vuejs.github.io/vue-loader/workflow/production.html
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env': env, 'process.env': env,
'COMMIT_HASH': JSON.stringify(commitHash), 'COMMIT_HASH': JSON.stringify(commitHash),
'DEV_OVERRIDES': JSON.stringify(undefined), 'DEV_OVERRIDES': JSON.stringify(undefined)
'__VUE_OPTIONS_API__': true,
'__VUE_PROD_DEVTOOLS__': false
}), }),
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false
}
}),
new webpack.optimize.OccurenceOrderPlugin(),
// extract css into its own file // extract css into its own file
new MiniCssExtractPlugin({ new ExtractTextPlugin(utils.assetsPath('css/[name].[contenthash].css')),
filename: utils.assetsPath('css/[name].[contenthash].css')
}),
// generate dist index.html with correct asset hash for caching. // generate dist index.html with correct asset hash for caching.
// you can customize output by editing /index.html // you can customize output by editing /index.html
// see https://github.com/ampedandwired/html-webpack-plugin // see https://github.com/ampedandwired/html-webpack-plugin
@ -65,11 +67,25 @@ var webpackConfig = merge(baseWebpackConfig, {
chunksSortMode: 'dependency' chunksSortMode: 'dependency'
}), }),
// split vendor js into its own file // split vendor js into its own file
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module, count) {
// any required modules inside node_modules are extracted to vendor
return (
module.resource &&
/\.js$/.test(module.resource) &&
module.resource.indexOf(
path.join(__dirname, '../node_modules')
) === 0
)
}
}),
// extract webpack runtime and module manifest to its own file in order to // extract webpack runtime and module manifest to its own file in order to
// prevent vendor hash from being updated whenever app bundle is updated // prevent vendor hash from being updated whenever app bundle is updated
// new webpack.optimize.SplitChunksPlugin({ new webpack.optimize.CommonsChunkPlugin({
// name: ['app', 'vendor'] name: 'manifest',
// }), chunks: ['vendor']
})
] ]
}) })

View file

@ -3,11 +3,6 @@ const path = require('path')
let settings = {} let settings = {}
try { try {
settings = require('./local.json') 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('Using local dev server settings (/config/local.json):')
console.log(JSON.stringify(settings, null, 2)) console.log(JSON.stringify(settings, null, 2))
} catch (e) { } catch (e) {
@ -52,15 +47,7 @@ module.exports = {
target, target,
changeOrigin: true, changeOrigin: true,
cookieDomainRewrite: 'localhost', cookieDomainRewrite: 'localhost',
ws: true, ws: true
headers: {
'Origin': target
}
},
'/oauth/revoke': {
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost'
} }
}, },
// CSS Sourcemaps off by default because relative paths are "buggy" // CSS Sourcemaps off by default because relative paths are "buggy"

View file

@ -1,114 +0,0 @@
# Pleroma-FE configuration and customization for instance administrators
* *For user configuration, see [Pleroma-FE user guide](../user_guide)*
* *For local development server configuration, see [Hacking, tweaking, contributing](HACKING.md)*
## Where configuration is stored
PleromaFE gets its configuration from several sources, in order of preference (the one above overrides ones below it)
1. `/api/statusnet/config.json` - this is generated on Backend and contains multiple things including instance name, char limit etc. It also contains FE/Client-specific data, PleromaFE uses `pleromafe` field of it. For more info on changing config on BE, look [here](../backend/configuration/cheatsheet.md#frontend_configurations)
2. `/static/config.json` - this is a static FE-provided file, containing only FE specific configuration. This file is completely optional and could be removed but is useful as a fallback if some configuration JSON property isn't present in BE-provided config. It's also a reference point to check what default configuration are and what JSON properties even exist. In local dev mode it could be used to override BE configuration, more about that in HACKING.md. File is located [here](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/static/config.json).
3. Built-in defaults. Those are hard-coded defaults that are used when `/static/config.json` is not available and BE-provided configuration JSON is missing some JSON properties. ( [Code](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/modules/instance.js) )
## Instance-defaults
Important note that some configurations are treated as "instance default" - it means user is able to change this configuration for themselves. Currently, defaults are only applied for new visitors and people who haven't changed the option in question. If you change some instance default option, there is a chance it won't affect some users.
There's currently no mechanism for user-settings synchronization across several browsers, *user* essentially means *visitor*, most user settings are stored in local storage/IndexedDB and not tied to an account in any way.
## Options
### `alwaysShowSubjectInput`
`true` - will always show subject line input, `false` - only show when it's not empty (i.e. replying). To hide subject line input completely, set it to `false` and `subjectLineBehavior` to `"noop"`
### `background`
Default image background. Be aware of using too big images as they may take longer to load. Currently image is fitted with `background-size: cover` which means "scaled and cropped", currently left-aligned. De-facto instance default, user can choose their own background, if they remove their own background, instance default will be used instead.
### `collapseMessageWithSubject`
Collapse post content when post has a subject line (content warning). Instance-default.
### `disableChat`
hides the chat (TODO: even if it's enabled on backend)
### `greentext`
Changes lines prefixed with the `>` character to have a green text color
### `hideFilteredStatuses`
Removes filtered statuses from timelines.
### `hideMutedPosts`
Removes muted statuses from timelines.
### `hidePostStats`
Hide repeats/favorites counters for posts.
### `hideSitename`
Hide instance name in header.
### `hideUserStats`
Hide followers/friends counters for users.
### `loginMethod`
`"password"` - show simple password field
`"token"` - show button to log in with external method (will redirect to login form, more details in BE documentation)
### `logo`, `logoMask`, `logoMargin`
Instance `logo`, could be any image, including svg. By default it assumes logo used will be monochrome-with-alpha one, this is done to be compatible with both light and dark themes, so that white logo designed with dark theme in mind won't be invisible over light theme, this is done via [CSS3 Masking](https://www.html5rocks.com/en/tutorials/masking/adobe/). Basically - it will take alpha channel of the image and fill non-transparent areas of it with solid color. If you really want colorful logo - it can be done by setting `logoMask` to `false`.
`logoMargin` allows you to adjust vertical margins between logo boundary and navbar borders. The idea is that to have logo's image without any extra margins and instead adjust them to your need in layout.
### `minimalScopesMode`
Limit scope selection to *Direct*, *User default* and *Scope of post replying to*. This also makes it impossible to reply to a DM with a non-DM post from PleromaFE.
### `nsfwCensorImage`
Use custom image for NSFW'd images
### `postContentType`
Default post formatting option (markdown/bbcode/plaintext/etc...)
### `redirectRootNoLogin`, `redirectRootLogin`
These two settings should point to where FE should redirect visitor when they login/open up website root
### `scopeCopy`
Copy post scope (visibility) when replying to a post. Instance-default.
### `sidebarRight`
Change alignment of sidebar and panels to the right. Defaults to `false`.
### `showFeaturesPanel`
Show panel showcasing instance features/settings to logged-out visitors
### `showInstanceSpecificPanel`
This allows you to include arbitrary HTML content in a panel below navigation menu. PleromaFE looks for an html page `instance/panel.html`, by default it's not provided in FE, but BE bundles some [default one](https://git.pleroma.social/pleroma/pleroma/blob/develop/priv/static/instance/panel.html). De-facto instance-defaults, since user can hide instance-specific panel.
### `subjectLineBehavior`
How to handle subject line (CW) when replying to a post.
* `"email"` - like EMail - prepend `re: ` to subject line if it doesn't already start with it.
* `"masto"` - like Mastodon - copy it as is.
* `"noop"` - do not copy
Instance-default.
### `theme`
Default theme used for new users. De-facto instance-default, user can change theme.
### `webPushNotifications`
Enables [PushAPI](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) - based notifications for users. Instance-default.
## Indirect configuration
Some features are configured depending on how backend is configured. In general the approach is "if backend allows it there's no need to hide it, if backend doesn't allow it there's no need to show it.
### Chat
**TODO somewhat broken, see: disableChat** chat can be disabled by disabling it in backend
### Private Mode
If the `private` instance setting is enabled in the backend, features that are not accessible without authentication, such as the timelines and search will be disabled for unauthenticated users.
### Rich text formatting in post formatting
Rich text formatting options are displayed depending on how many formatting options are enabled on backend, if you don't want your users to use rich text at all you can only allow "text/plain" one, frontend then will only display post text format as a label instead of dropdown (just so that users know for example if you only allow Markdown, only BBCode or only Plain text)
### Who to follow
This is a panel intended for users to find people to follow based on randomness or on post contents. Being potentially privacy unfriendly feature it needs to be enabled and configured in backend to be enabled.

View file

@ -1,100 +0,0 @@
# Hacking, tweaking, contributing
## What PleromaFE even is, how it works
PleromaFE is an SPA (Single-Page Application) backed by [Vue](https://vuejs.org/) framework. It means that it's just a nearly-empty HTML page with bunch of JavaScript that actually generates and controls DOM (i.e. html elements) in Runtime. Currently, there's no way around it - you have to have Javascript enabled in the browser to make it work, there is a theoretical possibility to generate some HTML server-side but it's not implemented yet.
You can serve static html page and everything from any HTTP(S) server but currently it will try to access /api/ path at same domain it's running on, meaning that as of right now you cannot put it on one domain and access the other without proxying requests.
Development server does exactly that - it serves static html page with javascript and all other assets, adds some code to automatically reload when changes to code are made and proxies requests to some other server.
## Setting up develop server
Setting up development server is fairly straight-forward.
1. On your system you must have **[Node.js](https://nodejs.org/) version 8** and newer installed. For older systems or systems that do not package node you can try [NodeSource](https://github.com/nodesource/distributions) repositories. *Windows support theoretically possible but isn't tested.*
2. For fetching dependencies and running basic tasks you will *[Yarn](https://yarnpkg.com/)* installed.
3. Clone the repository, `cd` into it and run `yarn` to fetch dependencies.
4. If you want to point development server at some instance you will need to copy `config/local.example.json` to `config/local.json` and change the `target` to point at instance you want, otherwise it will point to `localhost:4000` which is default address for locally-run Pleroma Backend
5. Run `yarn dev` - it will start the server.
6. Open `localhost:8080` in your browser, it might take a while initially until everything is built first time.
## Setting up production build
This could be a bit trickier, you basically need steps 1-4 from *develop build* instructions, and run `yarn build` which will compile and copy eveything needed for production into `dist` folder. As said before, this technically could be used anywhere with some details.
### Replacing your instance's frontend with custom FE build
This is the most easiest way to use and test FE build: you just need to copy or symlink contents of `dist` folder into backend's [static directory](../backend/configuration/static_dir.md), by default it is located in `instance/static`, or in `/var/lib/pleroma/static` for OTP release installations, create it if it doesn't exist already. Be aware that running `yarn build` wipes the contents of `dist` folder.
### Running production build locally or on a separate server
This is **highly experimental** and only tried once, with no actual simple solution available yet
You will need an HTTP server that can proxy requests for `/api`, `/instance`, `/nodeinfo` and show index.html for every 404 page.
For nginx you'll probably need something like this:
```nginx
server {
listen 80 default_server;
index index.html index.htm index.nginx-debian.html;
root /var/www/html
location /api {
proxy_pass https://example.tld;
}
location /instance {
proxy_pass https://example.tld;
}
location /nodeinfo {
proxy_pass https://example.tld;
}
location / {
try_files $uri $uri/ /index.html;
}
}
```
(ed. note: this is close to what i used last time i had to do it, it may not work and need additions, i basically adjusted default nginx server in debian)
## Basic architecture
### API, Data, Operations
In 99% cases PleromaFE uses [MastoAPI](https://docs.joinmastodon.org/api/) with [Pleroma Extensions](../backend/API/differences_in_mastoapi_responses.md) to fetch the data. The rest is either QvitterAPI leftovers or pleroma-exclusive APIs. QvitterAPI doesn't exactly have documentation and uses different JSON structure and sometimes different parameters and workflows, [this](https://twitter-api.readthedocs.io/en/latest/index.html) could be a good reference though. Some pleroma-exclusive API may still be using QvitterAPI JSON structure.
PleromaFE supports both formats by transforming them into internal format which is basically QvitterAPI one with some additions and renaming. All data is passed trough [Entity Normalizer](https://git.pleroma.social/pleroma/pleroma-fe/-/blob/develop/src/services/entity_normalizer/entity_normalizer.service.js) which can serve as a reference of API and what's actually used, it's also a host for all the hacks and data transformation.
For most part, PleromaFE tries to store all the info it can get in global vuex store - every user and post are passed trough updating mechanism where data is either added or merged with existing data, reactively updating the information throughout UI, so if in newest request user's post counter increased, it will be instantly updated in open user profile cards. This is also used to find users, posts and sometimes to build timelines and/or request parameters.
PleromaFE also tries to persist this store, however only stable data is stored, such as user authentication and preferences, user highlights. Persistence is performed by saving and loading chunk of vuex store in browser's LocalStorage/IndexedDB.
TODO: Refactor API code and document it here
### Themes
PleromaFE uses custom theme "framework" which is pretty much just a style tag rendered by vue which only contains CSS3 variables. Every color used in UI should be derived from theme. Theme is stored in a JSON object containing color, opacity, shadow and font information, with most of it being optional.
The most basic theme can consist of 4 to 8 "basic colors", which is also what previous version of themes allowed, with all other colors being derived from those basic colors, i.e. "light background" will be "background" color lightened/darkened, "panel header" will be same as "foreground". The idea is that you can specify just basic color palette and everything else will be generated automatically, but if you really need to tweak some specific color - you can.
As said before - older version only allowed 4 to 8 colors, it also used arrays instead of objects, we still support that. The basic colors are: background, foreground, text, links, red, orange, blue, green. First 4 are mandatory, last 4 have default fallbacks since ever more ancient theme formats only had 4 colors.
Note that with older version themes used different internal naming when persisting state.
Themes are meant to be backwards and somewhat forwards compatible - new colors should properly inherit from some existing one, making it compatible with older versions. When loading newer version of theme all unrecognized colors will be ignored, which for most part should be fine, however adding new features (gradients, masks, whatever it might be) might be breaky.
Lastly, pleroma provides some contrast information and generates readable text color automatically, which is done by tracking background/text color pairs and their contrast - if contrast too low it will try to use background color with inverted lightness, if it's still unacceptable it will fall back to pure black/white.
### Still Image
Most images are wrapped in a component called StillImage, which does one simple thing - tries to detect if image is a GIF and if it is (and user has enabled relevant setting) it will show `<canvas>` with that image instead of actual image. It uses standard method to render an image into canvas which renders first frame of a GIF if it's animated (obviously because canvas by itself isn't animated and you'd need to animate it yourself in JS), it will show actual image on hover. Statuses also allow playing animated avatars when you hover over a post, not just image itself.
## Contributing
Feel free to contribute, most preferred way is by starting a Merge Request in GitLab. Please try to use descriptive names for your branches and merge requests, avoid naming them "fix-issue-777" "777" and so on.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1,8 +0,0 @@
# Introduction to Pleroma-FE
## What is Pleroma-FE?
Pleroma-FE is the default user-facing frontend for Pleroma. It's user interface is modeled after Qvitter which is modeled after an older Twitter design. It provides a simple 2-column interface for microblogging. While being simple by default it also provides many powerful customization options.
## How can I use it?
If your instance uses Pleroma-FE, you can acces it by going to your instance (e.g. <https://pleroma.soykaf.com>). You can read more about it's basic functionality in the [Pleroma-FE User Guide](./user_guide/). We also have [a guide for administrators](./CONFIGURATION.md) and for [hackers/contributors](./HACKING.md).

View file

@ -1,44 +0,0 @@
# General overview
> Be prepared for breaking changes, unexpected behavior and this user guide becoming obsolete and wrong.
> If there was no insanity
>
> it would be necessary to create it.
>
> --Catbag
Pleroma-FE is the default user-facing frontend for Pleroma. If your instance uses Pleroma-FE, you can access it by going to your instance (e.g. <https://pleroma.soykaf.com>). After logging in you will have two columns in front of you. Here we're going to keep it to the default behaviour, but some instances swap the left and right columns. If you're on such an instance what we refer to as the left column will be on your right and vice versa.
### Left column
- first block: This section is dedicated to [posting](posting_reading_basic_functions.md)
- second block: Here you can switch between the different views for the right column.
- Optional third block: This is the Instance panel that can be activated, but is deactivated by default. It's fully customisable by instance admins and by default has links to the Pleroma-FE and Mastodon-FE.
- fourth block: This is the Notifications block, here you will get notified whenever somebody mentions you, follows you, repeats or favorites one of your statuses
### Right column
This is where the interesting stuff happens! There are different views depending on what you choose in the second block of the left panel.
- **Timelines** Depending on the [timeline](timelines.md) you will see different statuses, but each status has a standard structure:
- Profile pic, name and link to profile. An optional left-arrow if it's a reply to another status (hovering will reveal the reply-to status). Clicking on the profile pic will uncollapse the user's profile where you can find information about the account and can [follow, mute or block the account](users_follow_mute_block.md).
- An arrow icon on the right side allows you to open the status on the instance where it's originating from.
- A `+` button on the rightmost side allows you to Expand/Collapse an entire discussion thread.
- The text of the status, including mentions and attachments. If you click on a mention, it will automatically open the profile page of that person.
- Four buttons (left to right): Reply, Repeat, Favorite and Add Reaction. The three dots next to it are a dropdown menu for extra options including simple moderation, bookmarking, deleting posts, pinning your own posts to your profile and more.
- **Interactions** shows all interactions you've had with people on the network, basically same as notifications except grouped in convenient way.
- **Chats** is the chat feature. You can find your friends and start chatting with them. At the moment chat are only one-on-one, but once groups are introduced groupchats will also be possible.
- **About** is the about-page and lists the staff, the TOS, activated MRF's, and enabled features
### Top right
- The magnifier icon opens the search screen
- You can search for statuses, people and hashtags.
- You can import statuses from remote servers by pasting the url to the post in the search field.
- If you want to search for users that your instance doesn't know about yet, you can search for them using the full `name@instance.tld` handle. You can also use the full url from their remote profile.
- The gear icon gives you [settings](settings.md)
- If you have admin rights, you'll see an icon that opens the admin interface
- The last icon is to log out
### Bottom right
On the bottom right you have the Shoutbox. Here you can communicate with people on the same instance in realtime. It is local-only, very basic and will most probably be removed once the Chats functionality allows group chats.

View file

@ -1,76 +0,0 @@
# Posting, reading, basic functions.
!!! warning
Depending on your instance some of the options might not be available or have different defaults
After registering and logging in you're presented with your timeline in right column and new post form with timeline list and notifications in the left column.
Posts will contain the text you are posting, but some content will be modified:
1. Mentions: Mentions have the form of @user or @user<span></span>@instance.tld. These will become links to the user's profile. In addition, the mentioned user will always get a notification about the post they have been mentioned in, so only mention users that you want to receive this message.
2. URLs: URLs like `http://example.com` will be automatically be turned into a clickable links.
3. Hashtags: Hashtags like #cofe will also be turned into links.
4. There is a default character limit of 5000 characters.
Let's clear up some basic stuff. When you post something it's called a **post** or it could be called a **status** or even a **toot** or a **prööt** depending on whom you ask. Post has body/content but it also has some other stuff in it - from attachments, visibility scope, subject line...
**Emoji** are small images embedded in text, there are two major types of emoji: [unicode emoji](https://en.wikipedia.org/wiki/Emoji) and custom emoji. While unicode emoji are universal and standardized, they can appear differently depending on where you are using them or may not appear at all on older systems. Custom emoji are a more *fun* kind - instance administrator can define many images as *custom emoji* for their users. This works very simple - custom emoji is defined by its *shortcode* and an image, so that any shortcode enclosed in colons get replaced with image if such shortcode exist.
Let's say there's a `:pleroma:` emoji defined on an instance. That means
> First time using :pleroma: pleroma!
will become
> First time using ![pleroma](../assets/example_emoji.png) pleroma!
Note that you can only use emoji defined on your instance, you cannot "copy" someone else's emoji, and will have to ask your administrator to copy emoji from other instance to yours.
Lastly, there's two convenience options for emoji: an emoji picker (smiley face to the right of "submit" button) and autocomplete suggestions - when you start typing :shortcode: it will automatically try to suggest you emoji and complete the shortcode for you if you select one. If emoji doesn't show up in suggestions nor in emoji picker it means there's no such emoji on your instance, if shortcode doesn't match any defined emoji it will appear as text.
**Attachments** are fairly simple - you can attach any file to a post as long as the file is within maximum size limits. If you're uploading explicit material you can mark all of your attachments as sensitive (or add the `#nsfw` tag) - it will hide the images and videos behind a warning so that it won't be displayed instantly.
**Subject line** also known as **CW** (Content Warning) could be used as a header to the post and/or to warn others about contents of the post having something that might upset somebody or something among those lines. Several applications allow to hide post content leaving only subject line visible. Using a subject line will not mark your images as sensitive, you will have to do that explicitly (see above).
**Visiblity scope** controls who will be able to see your posts. There are four scopes available:
1. `Public`: This is the default, and some fediverse software, like GNU Social, only supports this. This means that your post is accessible by anyone and will be shown in the public timelines.
2. `Unlisted`: This is the same as public, but your post won't appear in the public timelines. The post will still be accessible by anyone who comes across it (for example, by looking at your profile) or by direct linking. They will also appear in public searches.
3. `Followers only`: This will show your post only to your followers. Only they will be able to interact with it. Be careful: When somebody follows you, they will be able to see all your previous `followers only` posts as well! If you want to restrict who can follow you, consider [locking your account down to only approved followers](../settings#profile).
4. `Direct`: This will only send the message to the people explicitly mentioned in the post.
A few things to consider about the security and usage of these scopes:
- None of these options will change the fact that the messages are all saved in the database unencrypted. They will be visible to your server admin and to any other admin of a server who receives this post. Do not share information that you would consider secret or dangerous. Use encrypted messaging systems for these things.
- Follower-only posts can lead to fragmented conversations. If you post a follower-only post and somebody else replies to it with a follower-only post, only people following both of you will see the whole conversation thread. Everybody else will only see half of it. Keep this in mind and keep conversations public if possible.
- Changing scopes during a thread or adding people to a direct message will not retroactively make them see the whole conversation. If you add someone to a direct message conversation, they will not see the post that happened before they were mentioned.
* **Reply-to** if you are replying to someone, your post will also contain a note that your post is referring to the post you're replying to. Person you're replying to will receive a notification *even* if you remove them from mentioned people. You won't receive notifications when replying to your own posts, but it's useful to reply to your own posts to provide people some context if it's a follow-up to a previous post. There's a small "Reply to ..." label under post author's name which you can hover on to see what post it's referring to.
Sometimes you may encounter posts that seem different than what they are supposed to. For example, you might see a direct message without any mentions in the text. This can happen because internally, the Fediverse has a different addressing mechanism similar to email, with `to` and `cc` fields. While these are not directly accessible in PleromaFE, other software in the Fediverse might generate those posts. Do not worry in these cases, these are normal and not a bug.
## Rich text
By default new posts you make are plaintext, meaning you can't make text **bold** or add custom links or make lists or anything like that. However if your instance allows it you can use Markdown or BBCode or HTML to spice up your text, however there are certain limitations to what HTML tags and what features of Markdown you can use.
Here is a small example of some text in markdown.
```
This is an example of markdown text using **bold** and *cursive* text.
To get a newline we add two spaces at the end of the previous line.
Let's also add a list
* with
* some
* items
```
If you set the input-method to Markdown, and post this, it will look something like
![example_markdown](../assets/example_markdown.png)
## Other actions
In addition to posting you can also *favorite* posts also known as *liking* them and *repeat* posts (also known as *retweeting*, *boosting* and even *reprööting*). Favoriting a post increments a counter on it, notifies the post author of your affection towards that post and also adds that post to your "favorited" posts list (in your own profile, "Favorites" tab). Reprööting a post does all that and also repeats this post to your followers and your profile page with a note "*user* repeated post".
Your own posts can be deleted, but this will only reliably delete the post from your own instance. Other instances will receive a deletion notice, but there's no way to force them to actually delete a post. In addition, not all instances that contain the message might even receive the deletion notice, because they might be offline or not known to have the post because they received it through a repeat. Lastly, deletion notice might not reach certain frontends and clients - post will be visible for them until page refresh or cache clear, they probably won't be able to interact with it apart from replying to it (which will have reply-to mark missing).
If you are a moderator, you can also delete posts by other people. If those people are on your instance, it will delete the post and send out the deletion notice to other servers. If they are not on your instance, it will just remove the post from your local instance.
There's also an option to report a user's post which can be used to notify your (and optionally the other instance's) admin that someone is being naughty.

View file

@ -1,116 +0,0 @@
# Settings
On the top-right you will see a gear icon. Click it to open the settings.
## General
### Interface
- **Interface language** is where you can set the interface language. The default language is the one that you set in your browser settings.
- **Hide instance-specific panel** hides the panel in the lower left that usually contains general information about the server. This will only be visible if your admin has activated this panel and is deactivated by default.
### Timeline
- **Hide posts of muted users** If this is set, 'muting' a user will completely hide their posts instead of collapsing them.
- **Collapse posts with subjects** This will collapse posts that contain a subject, hiding their content. Subjects are also sometimes called content warnings.
- **Enable automatic streaming of new posts when scrolled to the top** With this enabled, new posts will automatically stream in when you are scrolled to the top. Otherwise, you will see a button on the timeline that will let you display the new posts.
- **Pause streaming when tab is not focused** This pauses the automatic streaming that the previous option enables when the tab is out of focus. This is useful if you don't want to miss any new posts.
- **Enable automatic loading when scrolled to the bottom** When this is disabled, a button will be shown on the bottom of the timeline that will let you load older posts.
- **Enable reply-link preview on hover** Status posts in the timeline and notifications contain links to replies and to the post they are a reply to. If this setting is enabled, hovering over that link will display that linked post in a small hovering overlay.
### Composing
- **Copy scope when replying** makes the scope of a reply be the same as the scope of the post it is replying to. This is useful to prevent accidentally moving private discussions to public, or vice versa.
- **Always show subject field** Whether or not to display the 'subject' input field in the post form. If you do not want to use subjects, you can deactivate this.
- **Copy subject when replying** controls if the subject of a post will be copied from the post it is replying to.
- **Post status content type** selects the default content type of your post. The options are: Plain text, HTML, BBCode and Markdown.
- **Minimize scope selection options** will reduce the visibility scopes to 'direct', your default post scope and post scope of post you're replying to.
- **Automatically hide New Post button** hides the floating "New post" button when scrolling on mobile view.
- **Pad emoji with spaces when adding from picker** Will add spaces around emoji you select it from the picker.
### Attachments
- **Hide attachments in timeline** Do not display attachments in timelines. They will still display in expanded conversations. This is useful to save bandwidth and for browsing in public.
- **Hide attachments in conversations** Also hide attachments in expanded conversations.
- **Maximum amount of thumbnails per post** Exactly that :)
- **Enable clickthrough NSFW attachment hiding** Hide attachments that are marked as NSFW/sensitive behind a click-through image.`
- **Preload images** This will preload the hidden images so that they display faster when clicking through.
- **Open NSFW attachments with just one click** Directly open NSFW attachments in a maximised state instead of revealing the image thumbnail.
- **Play-on-hover GIFs** With this activated, GIFs images and avatars will only be animated on mouse hover. Otherwise, they will be always animated. This is very useful if your timeline looks too flashy from people's animated avatars and eases the CPU load.
- **Loop videos** Whether to loop videos indefinitely.
- **Loop only videos without sound** Some instances will use videos without sounds instead of GIFs. This will make only those videos autoplay.
- **Play videos directly in the media viewer** Play videos right in the timeline instead of opening it in a modal
- **Don't crop the attachment in thumbnails** if enabled, images in attachments will be fit entirely inside the container instead of being zoomed in and cropped.
### Notifications
- **Enable web push notifications** this enables Web Push notifications, to allow receiving notifications even when the page isn't opened, doesn't affect regular notifications.
### Fun
- **Meme arrows** will make `> greentext` be shown in green (using the "green" from the theme that is used).
## Profile
Here you can set up how you appear to other users among with some other settings:
- **Name** is text that displays next to your avatar in posts. Please note that you **cannot** change your *@handle*
- **Bio** will be displayed under your profile - you can put anything you want there you want for everyone to see.
- **Restrict your account to approved followers only** makes your account "locked", when people follow you - you have to approve or deny their follow requests, this gives more control over who sees your followers only posts.
- **Default visibility scope** is your default post scope for new posts
- **Strip rich text from all posts** strips rich text formatting (bold/italics/lists etc) from all incoming posts. This will only affect newly fetched posts.
If you're admin or moderator on your instance you also get **Show [role] badge in my profile** - this controls whether to show "Admin" or "Moderator** label on your profile page.
**For all options mentioned above you have to click "Submit" button for changes to take place**
- **Avatar** this changes picture next to your posts. Your avatar shouldn't exceed 2 MiB (2097152 bytes) or it could cause problems with certain instances.
- **Banner** this changes background on your profile card. Same as avatar it shouldn't exceed 2 MiB limit.
- **Profile Background** this changes background picture for UI. It isn't shown to anyone else *yet*, but some time later it will be shown when viewing your profisle.
## Security
Here you can change your password, revoke access tokens, configure 2-factor authentication (if available).
## Filtering
- **Types of notifications to show** This controls what kind of notifications will appear in notification column and which notifications to get in your system outside the web page
- **Replies in timeline** You may know that other social networks like Twitter will often not display replies to other people in your timeline, even if you are following the poster. Pleroma usually will show these posts to you to encourage conversation. If you do not like this behavior, you can change it here.
- **Hide post statistics** This hides the number of favorites, number of replies, etc.
- **Hide user statistics** This hides the number of followers, friends, etc.
- **Muted words** allows a list of words that will be muted (i.e. displayed in a collapsed state) on the timeline and in notifications. An easy way to tune down noise in your timeline. By default posts can be expanded if you want to see them.
- **Hide filtered statuses** will hide the filtered / muted posts completely instead of collapsing them.
## Theme
Here you can change the look and feel of Pleroma-FE. You can choose from several instance-provided presets and you can load one from file and save current theme to file. Before you apply new theme you can see what it will look like approximately in preview section.
The themes engine was made to be easy to use while giving an option for powerful in-depth customization - you can just tweak colors on "Common" tab and leave everything else as is.
If there's a little check box next to a color picker it means that color is optional and unless checked will be automatically picked based on some other color or defaults.
For some features you can also adjust transparency of it by changing its opacity, you just need to tick checkbox next to it, otherwise it will be using default opacity.
Contrast information is also provided - you can see how readable text is based on contrast between text color and background, icons under color pickers represent contrast rating based on [WCAG](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#visual-audio-contrast-contrast) - thumbs up means AAA rating (good), half-filled circle means AA rating (acceptable) and warning icon means it doesn't pass the minimal contrast requirement and probably will be less readable, especially for vision-challenged people, you can hover over icon to see more detailed information. *Please note* that if background is not opaque (opacity != 1) contrast will be measured based on "worst case scenario", i.e. behind semi-transparent background lies some solid color that makes text harder to read, this however is still inaccurate because it doesn't account that background can be noisy/busy, making text even harder to read.
Apart from colors you can also tweak shadow and lighting, which is used mostly to give buttons proper relief based on their state, give panes their shade, make things glow etc. It's quite powerful, and basically provides somewhat convenient interface for [CSS Shadows](https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow).
Another thing you can tweak is theme's roundness - some people like sharp edges, some want things more rounded. This is also used if you want circled or square avatars.
Lastly, you can redefine fonts used in UI without changing fonts in your browser or system, this however requires you to enter font's full name and having that font installed on your system.
## Notifications
This screen allows more fine-grained control over what notifications to show to you based on whom it comes from.
## Data Import/Export
This allows you to export and import a list of people you follow and block, in case instance's database gets reverted or if you want to move to another server. Note that you **CANNOT export/import list of people who *follow you***, they'll need to follow you back themselves.
## Mutes and Blocks
These screens give access to full list of people you block/mute, useful for *un*blocking/*un*muting people because blocking/muting them most likely removes them out of your sight completely.
## Version
Just displays the backend and frontend version. Useful to mention in bug reports.

View file

@ -1,13 +0,0 @@
# Timelines
You have several timelines to browse trough
- **Timeline** aka Home Timeline - this timeline contains all posts by people you follow and your own posts, as well as posts mentioning you directly.
- **Bookmarks** all the posts you've bookmarked. You can bookmark a post by clicking the three dots on the bottom right of the post and choose Bookmark.
- **Direct Messages** all posts with `direct` scope addressed to you or mentioning you.
- **Public Timelines** all public posts made by users on the instance you're on
- **The Whole Known Network** also known as **TWKN** or **Federated Timeline** - all public posts known by your instance. Due to nature of the network your instance may not know *all* the posts on the network, so only posts known by your instance are shown there.
Note that by default you will see all posts made by other users on your Home Timeline, this contrast behavior of Twitter and Mastodon, which shows you only non-reply posts and replies to people you follow. You can change said behavior in the [settings](settings.md#filtering).
By default instances will try to send activities (e.g. posts, favorites, etc.) up to 7 days or until the target server received them. For this reason posts that are up to 7 days old and your server didn't know about yet can pop up on your timeline. This is the default behaviour and can be changed by your admin.

View file

@ -1,11 +0,0 @@
# Users: follow, mute, block
When you see someone, you can click on their user picture to view their profile, and click on the userpic in that to see *full* profile. You can **follow** them, **mute** and **block** them.
**Following** is self-explanatory, it adds them to your Home Timeline, lists you as a follower and gives you access to follower-only posts if they have any.
**Muting** collapses posts and notifications made by them, giving you an option to see the post if you're curious. Clients other than PleromaFE may completely remove their posts.
**Blocking** a user removes them from your timeline and notifications and prevents them from following you (automatically unfollows them from you).
Please note that some users can be "locked", meaning instead of following them you send a follow request they need to approve for you to become their follower.

View file

@ -2,12 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pleroma</title>
<!--server-generated-meta--> <!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.png"> <link rel="icon" type="image/png" href="/favicon.png">
<link rel="stylesheet" href="/static/font/css/fontello.css">
<link rel="stylesheet" href="/static/font/css/animation.css">
</head> </head>
<body class="hidden"> <body style="display: none">
<noscript>To use Pleroma, please enable JavaScript.</noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->
</body> </body>

View file

@ -11,116 +11,98 @@
"unit:watch": "karma start test/unit/karma.conf.js --single-run=false", "unit:watch": "karma start test/unit/karma.conf.js --single-run=false",
"e2e": "node test/e2e/runner.js", "e2e": "node test/e2e/runner.js",
"test": "npm run unit && npm run e2e", "test": "npm run unit && npm run e2e",
"stylelint": "npx stylelint src/components/status/status.scss", "lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs"
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs",
"lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.17.8", "babel-plugin-add-module-exports": "^0.2.1",
"@chenfengyuan/vue-qrcode": "2.0.0", "babel-plugin-lodash": "^3.2.11",
"@fortawesome/fontawesome-svg-core": "1.3.0", "chromatism": "^3.0.0",
"@fortawesome/free-regular-svg-icons": "5.15.4", "diff": "^3.0.1",
"@fortawesome/free-solid-svg-icons": "5.15.4", "karma-mocha-reporter": "^2.2.1",
"@fortawesome/vue-fontawesome": "3.0.0-5", "localforage": "^1.5.0",
"@kazvmoe-infra/pinch-zoom-element": "1.2.0", "node-sass": "^3.10.1",
"@vuelidate/core": "2.0.0-alpha.35", "object-path": "^0.11.3",
"@vuelidate/validators": "2.0.0-alpha.27", "phoenix": "^1.3.0",
"body-scroll-lock": "2.7.1", "sanitize-html": "^1.13.0",
"chromatism": "3.0.0", "sass-loader": "^4.0.2",
"click-outside-vue3": "4.0.1", "vue": "^2.5.13",
"cropperjs": "1.5.12", "vue-chat-scroll": "^1.2.1",
"diff": "3.5.0", "vue-i18n": "^7.3.2",
"escape-html": "1.0.3", "vue-router": "^3.0.1",
"localforage": "1.10.0", "vue-template-compiler": "^2.3.4",
"parse-link-header": "1.0.1", "vue-timeago": "^3.1.2",
"phoenix": "1.6.2", "vuelidate": "^0.7.4",
"punycode.js": "2.1.0", "vuex": "^3.0.1",
"qrcode": "1", "whatwg-fetch": "^2.0.3"
"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"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.17.8", "@babel/polyfill": "^7.0.0",
"@babel/plugin-transform-runtime": "7.17.0", "@vue/test-utils": "^1.0.0-beta.26",
"@babel/preset-env": "7.16.11", "autoprefixer": "^6.4.0",
"@babel/register": "7.17.7", "babel-core": "^6.0.0",
"@intlify/vue-i18n-loader": "^5.0.0", "babel-eslint": "^7.0.0",
"@ungap/event-target": "0.2.3", "babel-helper-vue-jsx-merge-props": "^2.0.3",
"@vue/babel-helper-vue-jsx-merge-props": "1.2.1", "babel-loader": "^6.0.0",
"@vue/babel-plugin-jsx": "1.1.1", "babel-plugin-syntax-jsx": "^6.18.0",
"@vue/compiler-sfc": "^3.1.0", "babel-plugin-transform-runtime": "^6.0.0",
"@vue/test-utils": "2.0.0-rc.17", "babel-plugin-transform-vue-jsx": "3",
"autoprefixer": "6.7.7", "babel-preset-env": "^1.7.0",
"babel-eslint": "7.2.3", "babel-preset-es2015": "^6.0.0",
"babel-loader": "8.2.4", "babel-preset-stage-2": "^6.0.0",
"babel-plugin-lodash": "3.3.4", "babel-register": "^6.0.0",
"chai": "3.5.0", "chai": "^3.5.0",
"chalk": "1.1.3", "chalk": "^1.1.3",
"chromedriver": "87.0.7", "chromedriver": "^2.21.2",
"connect-history-api-fallback": "1.6.0", "connect-history-api-fallback": "^1.1.0",
"copy-webpack-plugin": "6.4.1", "cross-spawn": "^4.0.2",
"cross-spawn": "4.0.2", "css-loader": "^0.25.0",
"css-loader": "0.28.11", "eslint": "^3.7.1",
"custom-event-polyfill": "1.0.7", "eslint-config-standard": "^6.1.0",
"eslint": "5.16.0", "eslint-friendly-formatter": "^2.0.5",
"eslint-config-standard": "12.0.0", "eslint-loader": "^1.5.0",
"eslint-friendly-formatter": "2.0.7", "eslint-plugin-html": "^1.5.5",
"eslint-loader": "2.2.1", "eslint-plugin-promise": "^2.0.1",
"eslint-plugin-import": "2.25.4", "eslint-plugin-standard": "^2.0.1",
"eslint-plugin-node": "7.0.1", "eventsource-polyfill": "^0.9.6",
"eslint-plugin-promise": "4.3.1", "express": "^4.13.3",
"eslint-plugin-standard": "4.1.0", "extract-text-webpack-plugin": "^1.0.1",
"eslint-plugin-vue": "5.2.3", "file-loader": "^0.9.0",
"eventsource-polyfill": "0.9.6", "function-bind": "^1.0.2",
"express": "4.17.3", "html-webpack-plugin": "^2.8.1",
"file-loader": "3.0.1", "http-proxy-middleware": "^0.17.2",
"function-bind": "1.1.1", "inject-loader": "^2.0.1",
"html-webpack-plugin": "3.2.0", "iso-639-1": "^2.0.3",
"http-proxy-middleware": "0.21.0", "isparta-loader": "^2.0.0",
"inject-loader": "2.0.1", "json-loader": "^0.5.4",
"iso-639-1": "2.1.13", "karma": "^1.3.0",
"isparta-loader": "2.0.0", "karma-coverage": "^1.1.1",
"json-loader": "0.5.7", "karma-mocha": "^1.2.0",
"karma": "6.3.17", "karma-phantomjs-launcher": "^1.0.0",
"karma-coverage": "1.1.2", "karma-sinon-chai": "^1.2.0",
"karma-firefox-launcher": "1.3.0", "karma-sourcemap-loader": "^0.3.7",
"karma-mocha": "2.0.1", "karma-spec-reporter": "0.0.26",
"karma-mocha-reporter": "2.2.5", "karma-webpack": "^1.7.0",
"karma-sinon-chai": "2.0.2", "lodash": "^4.16.4",
"karma-sourcemap-loader": "0.3.8", "lolex": "^1.4.0",
"karma-spec-reporter": "0.0.33", "mocha": "^3.1.0",
"karma-webpack": "4.0.2", "nightwatch": "^0.9.8",
"lodash": "4.17.21", "opn": "^4.0.2",
"lolex": "1.6.0", "ora": "^0.3.0",
"mini-css-extract-plugin": "0.12.0", "phantomjs-prebuilt": "^2.1.3",
"mocha": "3.5.3", "raw-loader": "^0.5.1",
"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",
"selenium-server": "2.53.1", "selenium-server": "2.53.1",
"semver": "5.6.0", "semver": "^5.3.0",
"serviceworker-webpack-plugin": "1.0.1", "serviceworker-webpack-plugin": "0.2.3",
"shelljs": "0.8.5", "shelljs": "^0.7.4",
"sinon": "2.4.1", "sinon": "^1.17.3",
"sinon-chai": "2.14.0", "sinon-chai": "^2.8.0",
"stylelint": "13.6.1", "url-loader": "^0.5.7",
"stylelint-config-standard": "20.0.0", "vue-loader": "^11.1.0",
"stylelint-rscss": "0.4.0", "vue-style-loader": "^2.0.0",
"url-loader": "1.1.2", "webpack": "^1.13.2",
"vue-loader": "^16.0.0", "webpack-dev-middleware": "^1.8.3",
"vue-style-loader": "4.1.2", "webpack-hot-middleware": "^2.12.2",
"webpack": "4.46.0", "webpack-merge": "^0.14.1"
"webpack-dev-middleware": "3.7.3",
"webpack-hot-middleware": "2.24.3",
"webpack-merge": "0.14.1"
}, },
"engines": { "engines": {
"node": ">= 4.0.0", "node": ">= 4.0.0",

View file

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

View file

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

View file

@ -1,21 +1,14 @@
import UserPanel from './components/user_panel/user_panel.vue' import UserPanel from './components/user_panel/user_panel.vue'
import NavPanel from './components/nav_panel/nav_panel.vue' import NavPanel from './components/nav_panel/nav_panel.vue'
import Notifications from './components/notifications/notifications.vue' import Notifications from './components/notifications/notifications.vue'
import UserFinder from './components/user_finder/user_finder.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue' import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue' import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue' import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
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 MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue' import SideDrawer from './components/side_drawer/side_drawer.vue'
import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue' import { unseenNotificationsFromStore } from './services/notification_utils/notification_utils'
import MobileNav from './components/mobile_nav/mobile_nav.vue'
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'
export default { export default {
name: 'app', name: 'app',
@ -23,80 +16,91 @@ export default {
UserPanel, UserPanel,
NavPanel, NavPanel,
Notifications, Notifications,
UserFinder,
InstanceSpecificPanel, InstanceSpecificPanel,
FeaturesPanel, FeaturesPanel,
WhoToFollowPanel, WhoToFollowPanel,
ShoutPanel, ChatPanel,
MediaModal, MediaModal,
SideDrawer, SideDrawer
MobilePostStatusButton,
MobileNav,
DesktopNav,
SettingsModal,
UserReportingModal,
PostStatusModal,
GlobalNoticeList
}, },
data: () => ({ data: () => ({
mobileActivePanel: 'timeline' mobileActivePanel: 'timeline',
finderHidden: true,
supportsMask: window.CSS && window.CSS.supports && (
window.CSS.supports('mask-size', 'contain') ||
window.CSS.supports('-webkit-mask-size', 'contain') ||
window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain')
)
}), }),
created () { created () {
// Load the locale from the storage // Load the locale from the storage
const val = this.$store.getters.mergedConfig.interfaceLanguage this.$i18n.locale = this.$store.state.config.interfaceLanguage
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
window.addEventListener('resize', this.updateMobileState)
},
unmounted () {
window.removeEventListener('resize', this.updateMobileState)
}, },
computed: { computed: {
currentUser () { return this.$store.state.users.currentUser }, currentUser () { return this.$store.state.users.currentUser },
userBackground () { return this.currentUser.background_image }, background () {
instanceBackground () { return this.currentUser.background_image || this.$store.state.instance.background
return this.mergedConfig.hideInstanceWallpaper
? null
: this.$store.state.instance.background
}, },
background () { return this.userBackground || this.instanceBackground }, enableMask () { return this.supportsMask && this.$store.state.instance.logoMask },
bgStyle () { logoStyle () {
if (this.background) {
return {
'--body-background-image': `url(${this.background})`
}
}
},
shout () { return this.$store.state.shout.joined },
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
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 },
privateMode () { return this.$store.state.instance.private },
sidebarAlign () {
return { return {
'order': this.$store.getters.mergedConfig.sidebarRight ? 99 : 0 'visibility': this.enableMask ? 'hidden' : 'visible'
} }
}, },
...mapGetters(['mergedConfig']) logoMaskStyle () {
return this.enableMask ? {
'mask-image': `url(${this.$store.state.instance.logo})`
} : {
'background-color': this.enableMask ? '' : 'transparent'
}
},
logoBgStyle () {
return Object.assign({
'margin': `${this.$store.state.instance.logoMargin} 0`,
opacity: this.finderHidden ? 1 : 0
}, this.enableMask ? {} : {
'background-color': this.enableMask ? '' : 'transparent'
})
},
logo () { return this.$store.state.instance.logo },
bgStyle () {
return {
'background-image': `url(${this.background})`
}
},
bgAppStyle () {
return {
'--body-background-image': `url(${this.background})`
}
},
sitename () { return this.$store.state.instance.name },
chat () { return this.$store.state.chat.channel.state === 'joined' },
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () { return this.$store.state.instance.showInstanceSpecificPanel },
unseenNotifications () {
return unseenNotificationsFromStore(this.$store)
},
unseenNotificationsCount () {
return this.unseenNotifications.length
},
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }
}, },
methods: { methods: {
updateMobileState () { scrollToTop () {
const mobileLayout = windowWidth() <= 800 window.scrollTo(0, 0)
const layoutHeight = windowHeight() },
const changed = mobileLayout !== this.isMobileLayout logout () {
if (changed) { this.$router.replace('/main/public')
this.$store.dispatch('setMobileLayout', mobileLayout) this.$store.dispatch('logout')
} },
this.$store.dispatch('setLayoutHeight', layoutHeight) onFinderToggled (hidden) {
this.finderHidden = hidden
},
toggleMobileSidebar () {
this.$refs.sideDrawer.toggleDrawer()
} }
} }
} }

View file

@ -10,16 +10,13 @@
position: fixed; position: fixed;
z-index: -1; z-index: -1;
height: 100%; height: 100%;
left: 0; width: 100%;
right: -20px;
background-size: cover; background-size: cover;
background-repeat: no-repeat; background-repeat: no-repeat;
background-color: var(--wallpaper); background-position: 0 50%;
background-image: var(--body-background-image);
background-position: 50% 50px;
} }
i[class^='icon-'] { i {
user-select: none; user-select: none;
} }
@ -33,37 +30,23 @@ h4 {
margin: auto; margin: auto;
min-height: 100vh; min-height: 100vh;
max-width: 980px; max-width: 980px;
align-content: flex-start;
}
.underlay {
background-color: rgba(0,0,0,0.15); background-color: rgba(0,0,0,0.15);
background-color: var(--underlay, rgba(0,0,0,0.15)); align-content: flex-start;
} }
.text-center { .text-center {
text-align: center; text-align: center;
} }
html {
font-size: 14px;
}
body { body {
overscroll-behavior-y: none;
font-family: sans-serif; font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif); font-family: var(--interfaceFont, sans-serif);
font-size: 14px;
margin: 0; margin: 0;
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
max-width: 100vw; max-width: 100vw;
overflow-x: hidden; overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
&.hidden {
display: none;
}
} }
a { a {
@ -72,7 +55,7 @@ a {
color: var(--link, $fallback--link); color: var(--link, $fallback--link);
} }
.button-default { button {
user-select: none; user-select: none;
color: $fallback--text; color: $fallback--text;
color: var(--btnText, $fallback--text); color: var(--btnText, $fallback--text);
@ -82,18 +65,13 @@ a {
border-radius: $fallback--btnRadius; border-radius: $fallback--btnRadius;
border-radius: var(--btnRadius, $fallback--btnRadius); border-radius: var(--btnRadius, $fallback--btnRadius);
cursor: pointer; cursor: pointer;
box-shadow: $fallback--buttonShadow; box-shadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
box-shadow: var(--buttonShadow); box-shadow: var(--buttonShadow);
font-size: 14px; font-size: 14px;
font-family: sans-serif; font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif); font-family: var(--interfaceFont, sans-serif);
&.-sublime { i[class*=icon-] {
background: transparent;
}
i[class*=icon-],
.svg-inline--fa {
color: $fallback--text; color: $fallback--text;
color: var(--btnText, $fallback--text); color: var(--btnText, $fallback--text);
} }
@ -110,96 +88,27 @@ a {
&:active { &:active {
box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset; box-shadow: 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); box-shadow: var(--buttonPressedShadow);
color: $fallback--text;
color: var(--btnPressedText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--btnPressed, $fallback--fg);
svg,
i {
color: $fallback--text;
color: var(--btnPressedText, $fallback--text);
}
} }
&:disabled { &:disabled {
cursor: not-allowed; cursor: not-allowed;
color: $fallback--text; opacity: 0.5;
color: var(--btnDisabledText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--btnDisabled, $fallback--fg);
svg,
i {
color: $fallback--text;
color: var(--btnDisabledText, $fallback--text);
}
} }
&.toggled { &.pressed {
color: $fallback--text; color: $fallback--faint;
color: var(--btnToggledText, $fallback--text); color: var(--faint, $fallback--faint);
background-color: $fallback--fg; background-color: $fallback--bg;
background-color: var(--btnToggled, $fallback--fg); background-color: var(--bg, $fallback--bg)
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 {
color: $fallback--text;
color: var(--btnToggledText, $fallback--text);
}
}
&.danger {
// TODO: add better color variable
color: $fallback--text;
color: var(--alertErrorPanelText, $fallback--text);
background-color: $fallback--alertError;
background-color: var(--alertError, $fallback--alertError);
} }
} }
.button-unstyled { label.select {
background: none;
border: none;
outline: none;
display: inline;
text-align: initial;
font-size: 100%;
font-family: inherit;
padding: 0; 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 {
&.unstyled {
border-radius: 0;
background: none;
box-shadow: none;
height: unset;
}
border: none; border: none;
border-radius: $fallback--inputRadius; border-radius: $fallback--inputRadius;
border-radius: var(--inputRadius, $fallback--inputRadius); border-radius: var(--inputRadius, $fallback--inputRadius);
@ -212,20 +121,51 @@ input, textarea, .input {
font-family: sans-serif; font-family: sans-serif;
font-family: var(--inputFont, sans-serif); font-family: var(--inputFont, sans-serif);
font-size: 14px; font-size: 14px;
margin: 0; padding: 8px .5em;
box-sizing: border-box; box-sizing: border-box;
display: inline-block; display: inline-block;
position: relative; position: relative;
height: 28px; height: 28px;
line-height: 16px; line-height: 16px;
hyphens: none; hyphens: none;
padding: 8px .5em;
&:disabled, &[disabled=disabled], &.disabled { &:disabled, &[disabled=disabled] {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
} }
.icon-down-open {
position: absolute;
top: 0;
bottom: 0;
right: 5px;
height: 100%;
color: $fallback--text;
color: var(--text, $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(--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] { &[type=range] {
background: none; background: none;
border: none; border: none;
@ -234,51 +174,15 @@ input, textarea, .input {
flex: 1; flex: 1;
} }
&[type=radio] { &[type=radio],
display: none;
&:checked + label::before {
box-shadow: 0px 0px 2px black inset, 0px 0px 0px 4px $fallback--fg inset;
box-shadow: var(--inputShadow), 0px 0px 0px 4px var(--fg, $fallback--fg) inset;
background-color: var(--accent, $fallback--link);
}
&:disabled {
&,
& + label,
& + label::before {
opacity: .5;
}
}
+ label::before {
flex-shrink: 0;
display: inline-block;
content: '';
transition: box-shadow 200ms;
width: 1.1em;
height: 1.1em;
border-radius: 100%; // Radio buttons should always be circle
box-shadow: 0px 0px 2px black inset;
box-shadow: var(--inputShadow);
margin-right: .5em;
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
vertical-align: top;
text-align: center;
line-height: 1.1em;
font-size: 1.1em;
box-sizing: border-box;
color: transparent;
overflow: hidden;
box-sizing: border-box;
}
}
&[type=checkbox] { &[type=checkbox] {
display: none; display: none;
&:checked + label::before { &:checked + label::before {
color: $fallback--text; color: $fallback--text;
color: var(--inputText, $fallback--text); color: var(--text, $fallback--text);
} }
&:disabled { &:disabled,
{
&, &,
& + label, & + label,
& + label::before { & + label::before {
@ -286,9 +190,8 @@ input, textarea, .input {
} }
} }
+ label::before { + label::before {
flex-shrink: 0;
display: inline-block; display: inline-block;
content: ''; content: '';
transition: color 200ms; transition: color 200ms;
width: 1.1em; width: 1.1em;
height: 1.1em; height: 1.1em;
@ -309,10 +212,6 @@ input, textarea, .input {
box-sizing: border-box; box-sizing: border-box;
} }
} }
&.resize-height {
resize: vertical;
}
} }
option { option {
@ -322,45 +221,11 @@ option {
background-color: var(--bg, $fallback--bg); background-color: var(--bg, $fallback--bg);
} }
.hide-number-spinner { i[class*=icon-] {
-moz-appearance: textfield;
&[type=number]::-webkit-inner-spin-button,
&[type=number]::-webkit-outer-spin-button {
opacity: 0;
display: none;
}
}
i[class*=icon-], .svg-inline--fa {
color: $fallback--icon; color: $fallback--icon;
color: var(--icon, $fallback--icon); color: var(--icon, $fallback--icon)
} }
.btn-block {
display: block;
width: 100%;
}
.btn-group {
position: relative;
display: inline-flex;
vertical-align: middle;
button {
position: relative;
flex: 1 1 auto;
&:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
}
.container { .container {
display: flex; display: flex;
@ -369,10 +234,88 @@ i[class*=icon-], .svg-inline--fa {
padding: 0 10px 0 10px; padding: 0 10px 0 10px;
} }
.item {
flex: 1;
line-height: 50px;
height: 50px;
overflow: hidden;
display: flex;
flex-wrap: wrap;
.nav-icon {
margin-left: 0.4em;
}
&.right {
justify-content: flex-end;
}
}
.auto-size { .auto-size {
flex: 1 flex: 1
} }
.nav-bar {
padding: 0;
width: 100%;
align-items: center;
position: fixed;
height: 50px;
.logo {
display: flex;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
align-items: stretch;
justify-content: center;
flex: 0 0 auto;
z-index: -1;
transition: opacity;
transition-timing-function: ease-out;
transition-duration: 100ms;
.mask {
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
background-color: $fallback--fg;
background-color: var(--topBarText, $fallback--fg);
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
img {
height: 100%;
object-fit: contain;
display: block;
flex: 0;
}
}
.inner-nav {
margin: auto;
box-sizing: border-box;
padding-left: 10px;
padding-right: 10px;
display: flex;
align-items: center;
flex-basis: 970px;
height: 50px;
a, a i {
color: $fallback--link;
color: var(--topBarLink, $fallback--link);
}
}
}
main-router { main-router {
flex: 1; flex: 1;
} }
@ -429,7 +372,6 @@ main-router {
.panel-heading { .panel-heading {
display: flex; display: flex;
flex: none;
border-radius: $fallback--panelRadius $fallback--panelRadius 0 0; border-radius: $fallback--panelRadius $fallback--panelRadius 0 0;
border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0; border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0;
background-size: cover; background-size: cover;
@ -453,58 +395,28 @@ main-router {
color: var(--panelFaint, $fallback--faint); color: var(--panelFaint, $fallback--faint);
} }
.faint-link {
color: $fallback--faint;
color: var(--faintLink, $fallback--faint);
}
.alert { .alert {
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow-x: hidden; overflow-x: hidden;
} }
.button-default, button {
.alert { flex-shrink: 0;
}
button, .alert {
// height: 100%; // height: 100%;
line-height: 21px; line-height: 21px;
min-height: 0; min-height: 0;
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
margin-left: .5em; margin-left: .25em;
min-width: 1px; min-width: 1px;
align-self: stretch; align-self: stretch;
} }
.button-default { a {
flex-shrink: 0;
&,
i[class*=icon-] {
color: $fallback--text;
color: var(--btnPanelText, $fallback--text);
}
&:active {
background-color: $fallback--fg;
background-color: var(--btnPressedPanel, $fallback--fg);
color: $fallback--text;
color: var(--btnPressedPanelText, $fallback--text);
}
&:disabled {
color: $fallback--text;
color: var(--btnDisabledPanelText, $fallback--text);
}
&.toggled {
color: $fallback--text;
color: var(--btnToggledPanelText, $fallback--text);
}
}
a,
.-link {
color: $fallback--link; color: $fallback--link;
color: var(--panelLink, $fallback--link) color: var(--panelLink, $fallback--link)
} }
@ -515,31 +427,19 @@ main-router {
border-radius: var(--panelRadius, $fallback--panelRadius); border-radius: var(--panelRadius, $fallback--panelRadius);
} }
/* TODO Should remove timeline-footer from here when we refactor panels into .panel-footer {
* separate component and utilize slots
*/
.panel-footer, .timeline-footer {
display: flex;
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--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 { .faint {
color: $fallback--faint; color: $fallback--faint;
color: var(--panelFaint, $fallback--faint); color: var(--panelFaint, $fallback--faint);
} }
a, a {
.-link {
color: $fallback--link; color: $fallback--link;
color: var(--panelLink, $fallback--link); color: var(--panelLink, $fallback--link)
} }
} }
@ -566,13 +466,47 @@ nav {
color: var(--faint, $fallback--faint); color: var(--faint, $fallback--faint);
box-shadow: 0px 0px 4px rgba(0,0,0,.6); box-shadow: 0px 0px 4px rgba(0,0,0,.6);
box-shadow: var(--topBarShadow); box-shadow: var(--topBarShadow);
box-sizing: border-box;
.back-button {
display: block;
max-width: 99px;
transition-property: opacity, max-width;
transition-duration: 300ms;
transition-timing-function: ease-out;
i {
margin: 0 1em;
}
&.hidden {
opacity: 0;
max-width: 5px;
}
}
}
.menu-button {
display: none;
position: relative;
}
.alert-dot {
border-radius: 100%;
height: 8px;
width: 8px;
position: absolute;
left: calc(50% - 4px);
top: calc(50% - 4px);
margin-left: 6px;
margin-top: -6px;
background-color: $fallback--cRed;
background-color: var(--badgeNotification, $fallback--cRed);
} }
.fade-enter-active, .fade-leave-active { .fade-enter-active, .fade-leave-active {
transition: opacity .2s transition: opacity .2s
} }
.fade-enter-from, .fade-leave-active { .fade-enter, .fade-leave-active {
opacity: 0 opacity: 0
} }
@ -597,11 +531,31 @@ nav {
display: none; display: none;
} }
.panel-switcher {
display: none;
width: 100%;
height: 46px;
button {
display: block;
flex: 1;
max-height: 32px;
margin: 0.5em;
padding: 0.5em;
}
}
@media all and (min-width: 800px) { @media all and (min-width: 800px) {
body { body {
overflow-y: scroll; overflow-y: scroll;
} }
nav {
.back-button {
display: none;
}
}
.sidebar-bounds { .sidebar-bounds {
overflow: hidden; overflow: hidden;
max-height: 100vh; max-height: 100vh;
@ -628,24 +582,19 @@ nav {
flex-grow: 0; flex-grow: 0;
} }
} }
.badge { .badge {
box-sizing: border-box;
display: inline-block; display: inline-block;
border-radius: 99px; border-radius: 99px;
max-width: 10em; min-width: 22px;
min-width: 1.7em; max-width: 22px;
height: 1.3em; min-height: 22px;
padding: 0.15em 0.15em; max-height: 22px;
vertical-align: middle; font-size: 15px;
font-weight: normal; line-height: 22px;
font-style: normal;
font-size: 0.9em;
line-height: 1;
text-align: center; text-align: center;
vertical-align: middle;
white-space: nowrap; white-space: nowrap;
overflow: hidden; padding: 0;
text-overflow: ellipsis;
&.badge-notification { &.badge-notification {
background-color: $fallback--cRed; background-color: $fallback--cRed;
@ -674,40 +623,38 @@ nav {
color: var(--alertErrorPanelText, $fallback--text); color: var(--alertErrorPanelText, $fallback--text);
} }
} }
&.warning {
background-color: $fallback--alertWarning;
background-color: var(--alertWarning, $fallback--alertWarning);
color: $fallback--text;
color: var(--alertWarningText, $fallback--text);
.panel-heading & {
color: $fallback--text;
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 { .faint {
color: $fallback--faint; color: $fallback--faint;
color: var(--faint, $fallback--faint); color: var(--faint, $fallback--faint);
} }
@media all and (min-width: 800px) {
.logo {
opacity: 1 !important;
}
}
.faint-link { .item.right {
color: $fallback--faint; text-align: right;
color: var(--faint, $fallback--faint); }
&:hover { .visibility-tray {
text-decoration: underline; font-size: 1.2em;
padding: 3px;
cursor: pointer;
.selected {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
.text-format {
float: right;
}
div {
padding-top: 5px;
} }
} }
@ -719,31 +666,6 @@ nav {
border-radius: var(--inputRadius, $fallback--inputRadius); border-radius: var(--inputRadius, $fallback--inputRadius);
} }
.notice-dismissible {
padding-right: 4rem;
position: relative;
.dismiss {
position: absolute;
top: 0;
right: 0;
padding: .5em;
color: inherit;
}
}
.fa-scale-110 {
&.svg-inline--fa {
font-size: 1.1em;
}
}
.fa-old-padding {
&.svg-inline--fa {
padding: 0 0.3em;
}
}
@keyframes shakeError { @keyframes shakeError {
0% { 0% {
transform: translateX(0); transform: translateX(0);
@ -788,31 +710,25 @@ nav {
margin: 0.5em 0 0.5em 0; margin: 0.5em 0 0.5em 0;
} }
.button-icon {
font-size: 1.2em;
}
.status .status-actions {
div {
max-width: 4em;
}
}
.menu-button { .menu-button {
display: block; display: block;
margin-right: 0.8em; margin-right: 0.8em;
} }
.main {
margin-bottom: 7em;
}
}
.setting-list,
.option-list{
list-style-type: none;
padding-left: 2em;
li {
margin-bottom: 0.5em;
}
.suboptions {
margin-top: 0.3em
}
} }
.login-hint { .login-hint {
text-align: center; text-align: center;
@media all and (min-width: 801px) { @media all and (min-width: 801px) {
display: none; display: none;
} }
@ -823,69 +739,3 @@ nav {
width: 100%; width: 100%;
} }
} }
.btn.button-default {
min-height: 28px;
}
.animate-spin {
animation: spin 2s infinite linear;
display: inline-block;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(359deg);
}
}
.new-status-notification {
position: relative;
font-size: 1.1em;
z-index: 1;
flex: 1;
}
.chat-layout {
// Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens).
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) {
body {
height: 100%;
}
#app {
height: 100%;
overflow: hidden;
min-height: auto;
}
#app_bg_wrapper {
overflow: hidden;
}
.main {
overflow: hidden;
height: 100%;
}
#content {
padding-top: 0;
height: 100%;
overflow: visible;
}
}
}

View file

@ -1,66 +1,55 @@
<template> <template>
<div <div id="app" v-bind:style="bgAppStyle">
id="app-loaded" <div class="app-bg-wrapper" v-bind:style="bgStyle"></div>
:style="bgStyle" <nav class='nav-bar container' @click="scrollToTop()" id="nav">
> <div class='logo' :style='logoBgStyle'>
<div <div class='mask' :style='logoMaskStyle'></div>
id="app_bg_wrapper" <img :src='logo' :style='logoStyle'>
class="app-bg-wrapper" </div>
/> <div class='inner-nav'>
<MobileNav v-if="isMobileLayout" /> <div class='item'>
<DesktopNav v-else /> <a href="#" class="menu-button" @click.stop.prevent="toggleMobileSidebar()">
<div class="app-bg-wrapper app-container-wrapper" /> <i class="button-icon icon-menu"></i>
<div <div class="alert-dot" v-if="unseenNotificationsCount"></div>
id="content" </a>
class="container underlay" <router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link>
> </div>
<div <div class='item right'>
class="sidebar-flexer mobile-hidden" <user-finder class="button-icon nav-icon mobile-hidden" @toggled="onFinderToggled"></user-finder>
:style="sidebarAlign" <router-link class="mobile-hidden" :to="{ name: 'settings'}"><i class="button-icon icon-cog nav-icon" :title="$t('nav.preferences')"></i></router-link>
> <a href="#" class="mobile-hidden" v-if="currentUser" @click.prevent="logout"><i class="button-icon icon-logout nav-icon" :title="$t('login.logout')"></i></a>
</div>
</div>
</nav>
<div v-if="" class="container" id="content">
<side-drawer ref="sideDrawer" :logout="logout"></side-drawer>
<div class="sidebar-flexer mobile-hidden">
<div class="sidebar-bounds"> <div class="sidebar-bounds">
<div class="sidebar-scroller"> <div class="sidebar-scroller">
<div class="sidebar"> <div class="sidebar">
<user-panel /> <user-panel></user-panel>
<div v-if="!isMobileLayout"> <nav-panel></nav-panel>
<nav-panel /> <instance-specific-panel v-if="showInstanceSpecificPanel"></instance-specific-panel>
<instance-specific-panel v-if="showInstanceSpecificPanel" /> <features-panel v-if="!currentUser && showFeaturesPanel"></features-panel>
<features-panel v-if="!currentUser && showFeaturesPanel" /> <who-to-follow-panel v-if="currentUser && suggestionsEnabled"></who-to-follow-panel>
<who-to-follow-panel v-if="currentUser && suggestionsEnabled" /> <notifications v-if="currentUser"></notifications>
<notifications v-if="currentUser" />
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="main"> <div class="main">
<div <div v-if="!currentUser" class="login-hint panel panel-default">
v-if="!currentUser" <router-link :to="{ name: 'login' }" class="panel-body">
class="login-hint panel panel-default"
>
<router-link
:to="{ name: 'login' }"
class="panel-body"
>
{{ $t("login.hint") }} {{ $t("login.hint") }}
</router-link> </router-link>
</div> </div>
<router-view /> <transition name="fade">
<router-view></router-view>
</transition>
</div> </div>
<media-modal /> <media-modal></media-modal>
</div> </div>
<shout-panel <chat-panel :floating="true" v-if="currentUser && chat" class="floating-chat mobile-hidden"></chat-panel>
v-if="currentUser && shout && !hideShoutbox"
:floating="true"
class="floating-shout mobile-hidden"
:class="{ 'left': shoutboxPosition }"
/>
<MobilePostStatusButton />
<UserReportingModal />
<PostStatusModal />
<SettingsModal />
<div id="modal" />
<GlobalNoticeList />
</div> </div>
</template> </template>

View file

@ -17,7 +17,6 @@ $fallback--cGreen: #0fa00f;
$fallback--cOrange: orange; $fallback--cOrange: orange;
$fallback--alertError: rgba(211,16,20,.5); $fallback--alertError: rgba(211,16,20,.5);
$fallback--alertWarning: rgba(111,111,20,.5);
$fallback--panelRadius: 10px; $fallback--panelRadius: 10px;
$fallback--checkboxRadius: 2px; $fallback--checkboxRadius: 2px;
@ -27,8 +26,3 @@ $fallback--tooltipRadius: 5px;
$fallback--avatarRadius: 4px; $fallback--avatarRadius: 4px;
$fallback--avatarAltRadius: 10px; $fallback--avatarAltRadius: 10px;
$fallback--attachmentRadius: 10px; $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,404 +1,180 @@
import { createApp } from 'vue' import Vue from 'vue'
import { createRouter, createWebHistory } from 'vue-router' import VueRouter from 'vue-router'
import vClickOutside from 'click-outside-vue3' import routes from './routes'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import App from '../App.vue' import App from '../App.vue'
import routes from './routes'
import VBodyScrollLock from 'src/directives/body_scroll_lock'
import { windowWidth } from '../services/window_utils/window_utils' const afterStoreSetup = ({ store, i18n }) => {
import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js' window.fetch('/api/statusnet/config.json')
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' .then((res) => res.json())
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js' .then((data) => {
import { applyTheme } from '../services/style_setter/style_setter.js' const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey } = data.site
import FaviconService from '../services/favicon_service/favicon_service.js'
let staticInitialResults = null store.dispatch('setInstanceOption', { name: 'name', value: name })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') })
store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) })
store.dispatch('setInstanceOption', { name: 'server', value: server })
const parsedInitialResults = () => { // TODO: default values for this stuff, added if to not make it break on
if (!document.getElementById('initial-results')) { // my dev config out of the box.
return null if (uploadlimit) {
} store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadlimit.uploadlimit) })
if (!staticInitialResults) { store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadlimit.avatarlimit) })
staticInitialResults = JSON.parse(document.getElementById('initial-results').textContent) store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadlimit.backgroundlimit) })
} store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadlimit.bannerlimit) })
return staticInitialResults }
}
const decodeUTF8Base64 = (data) => {
const rawData = atob(data)
const array = Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
const text = new TextDecoder().decode(array)
return text
}
const preloadFetch = async (request) => {
const data = parsedInitialResults()
if (!data || !data[request]) {
return window.fetch(request)
}
const decoded = decodeUTF8Base64(data[request])
const requestData = JSON.parse(decoded)
return {
ok: true,
json: () => requestData,
text: () => requestData
}
}
const getInstanceConfig = async ({ store }) => {
try {
const res = await preloadFetch('/api/v1/instance')
if (res.ok) {
const data = await res.json()
const textlimit = data.max_toot_chars
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) { if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey }) store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
} }
} else {
throw (res)
}
} catch (error) {
console.error('Could not load instance config, potentially fatal')
console.error(error)
}
}
const getBackendProvidedConfig = async ({ store }) => { var apiConfig = data.site.pleromafe
try {
const res = await window.fetch('/api/pleroma/frontend_configurations')
if (res.ok) {
const data = await res.json()
return data.pleroma_fe
} else {
throw (res)
}
} catch (error) {
console.error('Could not load backend-provided frontend config, potentially fatal')
console.error(error)
}
}
const getStaticConfig = async () => { window.fetch('/static/config.json')
try { .then((res) => res.json())
const res = await window.fetch('/static/config.json') .catch((err) => {
if (res.ok) { console.warn('Failed to load static/config.json, continuing without it.')
return res.json() console.warn(err)
} else { return {}
throw (res)
}
} catch (error) {
console.warn('Failed to load static/config.json, continuing without it.')
console.warn(error)
return {}
}
}
const setSettings = async ({ apiConfig, staticConfig, store }) => {
const overrides = window.___pleromafe_dev_overrides || {}
const env = window.___pleromafe_mode.NODE_ENV
// This takes static config and overrides properties that are present in apiConfig
let config = {}
if (overrides.staticConfigPreference && env === 'development') {
console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
config = Object.assign({}, apiConfig, staticConfig)
} else {
config = Object.assign({}, staticConfig, apiConfig)
}
const copyInstanceOption = (name) => {
store.dispatch('setInstanceOption', { name, value: config[name] })
}
copyInstanceOption('nsfwCensorImage')
copyInstanceOption('background')
copyInstanceOption('hidePostStats')
copyInstanceOption('hideBotIndication')
copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('logo')
store.dispatch('setInstanceOption', {
name: 'logoMask',
value: typeof config.logoMask === 'undefined'
? true
: config.logoMask
})
store.dispatch('setInstanceOption', {
name: 'logoMargin',
value: typeof config.logoMargin === 'undefined'
? 0
: config.logoMargin
})
copyInstanceOption('logoLeft')
store.commit('authFlow/setInitialStrategy', config.loginMethod)
copyInstanceOption('redirectRootNoLogin')
copyInstanceOption('redirectRootLogin')
copyInstanceOption('showInstanceSpecificPanel')
copyInstanceOption('minimalScopesMode')
copyInstanceOption('hideMutedPosts')
copyInstanceOption('collapseMessageWithSubject')
copyInstanceOption('scopeCopy')
copyInstanceOption('subjectLineBehavior')
copyInstanceOption('postContentType')
copyInstanceOption('alwaysShowSubjectInput')
copyInstanceOption('showFeaturesPanel')
copyInstanceOption('hideSitename')
copyInstanceOption('sidebarRight')
return store.dispatch('setTheme', config['theme'])
}
const getTOS = async ({ store }) => {
try {
const res = await window.fetch('/static/terms-of-service.html')
if (res.ok) {
const html = await res.text()
store.dispatch('setInstanceOption', { name: 'tos', value: html })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load TOS")
console.warn(e)
}
}
const getInstancePanel = async ({ store }) => {
try {
const res = await preloadFetch('/instance/panel.html')
if (res.ok) {
const html = await res.text()
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load instance panel")
console.warn(e)
}
}
const getStickers = async ({ store }) => {
try {
const res = await window.fetch('/static/stickers.json')
if (res.ok) {
const values = await res.json()
const stickers = (await Promise.all(
Object.entries(values).map(async ([name, path]) => {
const resPack = await window.fetch(path + 'pack.json')
var meta = {}
if (resPack.ok) {
meta = await resPack.json()
}
return {
pack: name,
path,
meta
}
}) })
)).sort((a, b) => { .then((staticConfig) => {
return a.meta.title.localeCompare(b.meta.title) const overrides = window.___pleromafe_dev_overrides || {}
}) const env = window.___pleromafe_mode.NODE_ENV
store.dispatch('setInstanceOption', { name: 'stickers', value: stickers })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load stickers")
console.warn(e)
}
}
const getAppSecret = async ({ store }) => { // This takes static config and overrides properties that are present in apiConfig
const { state, commit } = store let config = {}
const { oauth, instance } = state if (overrides.staticConfigPreference && env === 'development') {
return getOrCreateApp({ ...oauth, instance: instance.server, commit }) console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
.then((app) => getClientToken({ ...app, instance: instance.server })) config = Object.assign({}, apiConfig, staticConfig)
.then((token) => { } else {
commit('setAppToken', token.access_token) config = Object.assign({}, staticConfig, apiConfig)
commit('setBackendInteractor', backendInteractorService(store.getters.getToken())) }
const copyInstanceOption = (name) => {
store.dispatch('setInstanceOption', {name, value: config[name]})
}
copyInstanceOption('nsfwCensorImage')
copyInstanceOption('background')
copyInstanceOption('hidePostStats')
copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('logo')
store.dispatch('setInstanceOption', {
name: 'logoMask',
value: typeof config.logoMask === 'undefined'
? true
: config.logoMask
})
store.dispatch('setInstanceOption', {
name: 'logoMargin',
value: typeof config.logoMargin === 'undefined'
? 0
: config.logoMargin
})
copyInstanceOption('redirectRootNoLogin')
copyInstanceOption('redirectRootLogin')
copyInstanceOption('showInstanceSpecificPanel')
copyInstanceOption('scopeOptionsEnabled')
copyInstanceOption('formattingOptionsEnabled')
copyInstanceOption('collapseMessageWithSubject')
copyInstanceOption('loginMethod')
copyInstanceOption('scopeCopy')
copyInstanceOption('subjectLineBehavior')
copyInstanceOption('postContentType')
copyInstanceOption('alwaysShowSubjectInput')
copyInstanceOption('noAttachmentLinks')
copyInstanceOption('showFeaturesPanel')
if ((config.chatDisabled)) {
store.dispatch('disableChat')
} else {
store.dispatch('initializeSocket')
}
return store.dispatch('setTheme', config['theme'])
})
.then(() => {
const router = new VueRouter({
mode: 'history',
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some(m => m.meta.dontScroll)) {
return false
}
return savedPosition || { x: 0, y: 0 }
}
})
/* eslint-disable no-new */
new Vue({
router,
store,
i18n,
el: '#app',
render: h => h(App)
})
})
}) })
}
const resolveStaffAccounts = ({ store, accounts }) => { window.fetch('/static/terms-of-service.html')
const nicknames = accounts.map(uri => uri.split('/').pop()) .then((res) => res.text())
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames }) .then((html) => {
} store.dispatch('setInstanceOption', { name: 'tos', value: html })
})
const getNodeInfo = async ({ store }) => { window.fetch('/api/pleroma/emoji.json')
try { .then(
const res = await preloadFetch('/nodeinfo/2.0.json') (res) => res.json()
if (res.ok) { .then(
const data = await res.json() (values) => {
const emoji = Object.keys(values).map((key) => {
return { shortcode: key, image_url: values[key] }
})
store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true })
},
(failure) => {
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: false })
}
),
(error) => console.log(error)
)
window.fetch('/static/emoji.json')
.then((res) => res.json())
.then((values) => {
const emoji = Object.keys(values).map((key) => {
return { shortcode: key, image_url: false, 'utf': values[key] }
})
store.dispatch('setInstanceOption', { name: 'emoji', value: emoji })
})
window.fetch('/instance/panel.html')
.then((res) => res.text())
.then((html) => {
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
})
window.fetch('/nodeinfo/2.0.json')
.then((res) => res.json())
.then((data) => {
const metadata = data.metadata const metadata = data.metadata
const features = metadata.features
store.dispatch('setInstanceOption', { name: 'name', value: metadata.nodeName })
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: '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') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
const uploadLimits = metadata.uploadLimits const features = metadata.features
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) }) store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadLimits.avatar) }) store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadLimits.background) }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadLimits.banner) })
store.dispatch('setInstanceOption', { name: 'fieldsLimits', value: metadata.fieldsLimits })
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames }) store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
const suggestions = metadata.suggestions const suggestions = metadata.suggestions
store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled }) store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled })
store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web }) store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web })
})
const software = data.software
store.dispatch('setInstanceOption', { name: 'backendVersion', value: software.version })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: software.name === 'pleroma' })
const priv = metadata.private
store.dispatch('setInstanceOption', { name: 'private', value: priv })
const frontendVersion = window.___pleromafe_commit_hash
store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion })
const federation = metadata.federation
store.dispatch('setInstanceOption', {
name: 'tagPolicyAvailable',
value: typeof federation.mrf_policies === 'undefined'
? false
: metadata.federation.mrf_policies.includes('TagPolicy')
})
store.dispatch('setInstanceOption', { name: 'federationPolicy', value: federation })
store.dispatch('setInstanceOption', {
name: 'federating',
value: typeof federation.enabled === 'undefined'
? true
: federation.enabled
})
const accountActivationRequired = metadata.accountActivationRequired
store.dispatch('setInstanceOption', { name: 'accountActivationRequired', value: accountActivationRequired })
const accounts = metadata.staffAccounts
resolveStaffAccounts({ store, accounts })
} else {
throw (res)
}
} catch (e) {
console.warn('Could not load nodeinfo')
console.warn(e)
}
}
const setConfig = async ({ store }) => {
// apiConfig, staticConfig
const configInfos = await Promise.all([getBackendProvidedConfig({ store }), getStaticConfig()])
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
await setSettings({ store, apiConfig, staticConfig }).then(getAppSecret({ store }))
}
const checkOAuthToken = async ({ store }) => {
return new Promise(async (resolve, reject) => {
if (store.getters.getUserToken()) {
try {
await store.dispatch('loginUser', store.getters.getUserToken())
} catch (e) {
console.error(e)
}
}
resolve()
})
}
const afterStoreSetup = async ({ store, i18n }) => {
const width = windowWidth()
store.dispatch('setMobileLayout', width <= 800)
FaviconService.initFaviconService()
const overrides = window.___pleromafe_dev_overrides || {}
const server = (typeof overrides.target !== 'undefined') ? overrides.target : window.location.origin
store.dispatch('setInstanceOption', { name: 'server', value: server })
await setConfig({ store })
const { customTheme, customThemeSource } = store.state.config
const { theme } = store.state.instance
const customThemePresent = customThemeSource || customTheme
if (customThemePresent) {
if (customThemeSource && customThemeSource.themeEngineVersion === CURRENT_VERSION) {
applyTheme(customThemeSource)
} else {
applyTheme(customTheme)
}
} else if (theme) {
// do nothing, it will load asynchronously
} else {
console.error('Failed to load any theme!')
}
// Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized
await Promise.all([
checkOAuthToken({ store }),
getInstancePanel({ store }),
getNodeInfo({ store }),
getInstanceConfig({ store })
])
// Start fetching things that don't need to block the UI
store.dispatch('fetchMutes')
getTOS({ store })
getStickers({ store })
const router = createRouter({
history: createWebHistory(),
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some(m => m.meta.dontScroll)) {
return false
}
return savedPosition || { left: 0, top: 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
} }
export default afterStoreSetup export default afterStoreSetup

View file

@ -2,82 +2,54 @@ import PublicTimeline from 'components/public_timeline/public_timeline.vue'
import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue' import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue' import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
import TagTimeline from 'components/tag_timeline/tag_timeline.vue' import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue'
import ConversationPage from 'components/conversation-page/conversation-page.vue' import ConversationPage from 'components/conversation-page/conversation-page.vue'
import Interactions from 'components/interactions/interactions.vue' import Mentions from 'components/mentions/mentions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue' import DMs from 'components/dm_timeline/dm_timeline.vue'
import ChatList from 'components/chat_list/chat_list.vue'
import Chat from 'components/chat/chat.vue'
import UserProfile from 'components/user_profile/user_profile.vue' import UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue' import Settings from 'components/settings/settings.vue'
import Registration from 'components/registration/registration.vue' import Registration from 'components/registration/registration.vue'
import PasswordReset from 'components/password_reset/password_reset.vue' import UserSettings from 'components/user_settings/user_settings.vue'
import FollowRequests from 'components/follow_requests/follow_requests.vue' import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue' import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import UserSearch from 'components/user_search/user_search.vue'
import Notifications from 'components/notifications/notifications.vue' import Notifications from 'components/notifications/notifications.vue'
import AuthForm from 'components/auth_form/auth_form.js' import UserPanel from 'components/user_panel/user_panel.vue'
import ShoutPanel from 'components/shout_panel/shout_panel.vue' import LoginForm from 'components/login_form/login_form.vue'
import ChatPanel from 'components/chat_panel/chat_panel.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue' import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import About from 'components/about/about.vue' import About from 'components/about/about.vue'
import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
export default (store) => { export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => { return [
if (store.state.users.currentUser) {
next()
} else {
next(store.state.instance.redirectRootNoLogin || '/main/all')
}
}
let routes = [
{ name: 'root', { name: 'root',
path: '/', path: '/',
redirect: _to => { redirect: _to => {
return (store.state.users.currentUser return (store.state.users.currentUser
? store.state.instance.redirectRootLogin ? store.state.instance.redirectRootLogin
: store.state.instance.redirectRootNoLogin) || '/main/all' : store.state.instance.redirectRootNoLogin) || '/main/all'
} }
}, },
{ name: 'public-external-timeline', path: '/main/all', component: PublicAndExternalTimeline }, { name: 'public-external-timeline', path: '/main/all', component: PublicAndExternalTimeline },
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline }, { name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute }, { name: 'friends', path: '/main/friends', component: FriendsTimeline },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline }, { name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } }, { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'remote-user-profile-acct',
path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute
},
{ name: 'remote-user-profile',
path: '/remote-users/:hostname/:username',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute
},
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile }, { name: 'external-user-profile', path: '/users/:id', component: UserProfile },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute }, { name: 'mentions', path: '/users/:username/mentions', component: Mentions },
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute }, { name: 'dms', path: '/users/:username/dms', component: DMs },
{ name: 'settings', path: '/settings', component: Settings },
{ name: 'registration', path: '/registration', component: Registration }, { name: 'registration', path: '/registration', component: Registration },
{ name: 'password-reset', path: '/password-reset', component: PasswordReset, props: true },
{ name: 'registration-token', path: '/registration/:token', component: Registration }, { name: 'registration-token', path: '/registration/:token', component: Registration },
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute }, { name: 'friend-requests', path: '/friend-requests', component: FollowRequests },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute }, { name: 'user-settings', path: '/user-settings', component: UserSettings },
{ name: 'login', path: '/login', component: AuthForm }, { name: 'notifications', path: '/:username/notifications', component: Notifications },
{ name: 'shout-panel', path: '/shout-panel', component: ShoutPanel, props: () => ({ floating: false }) }, { name: 'new-status', path: '/:username/new-status', component: UserPanel },
{ name: 'login', path: '/login', component: LoginForm },
{ name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) }, { name: '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: 'user-search', path: '/user-search', component: UserSearch, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute }, { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow },
{ name: 'about', path: '/about', component: About }, { name: 'about', path: '/about', component: About },
{ name: 'user-profile', path: '/:_(users)?/:name', component: UserProfile } { name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
] ]
if (store.state.instance.pleromaChatMessagesAvailable) {
routes = routes.concat([
{ name: 'chat', path: '/users/:username/chats/:recipient_id', component: Chat, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute },
{ name: 'chats', path: '/users/:username/chats', component: ChatList, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute }
])
}
return routes
} }

View file

@ -1,24 +1,15 @@
import InstanceSpecificPanel from '../instance_specific_panel/instance_specific_panel.vue' import InstanceSpecificPanel from '../instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from '../features_panel/features_panel.vue' import FeaturesPanel from '../features_panel/features_panel.vue'
import TermsOfServicePanel from '../terms_of_service_panel/terms_of_service_panel.vue' import TermsOfServicePanel from '../terms_of_service_panel/terms_of_service_panel.vue'
import StaffPanel from '../staff_panel/staff_panel.vue'
import MRFTransparencyPanel from '../mrf_transparency_panel/mrf_transparency_panel.vue'
const About = { const About = {
components: { components: {
InstanceSpecificPanel, InstanceSpecificPanel,
FeaturesPanel, FeaturesPanel,
TermsOfServicePanel, TermsOfServicePanel
StaffPanel,
MRFTransparencyPanel
}, },
computed: { computed: {
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }
showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
this.$store.state.instance.instanceSpecificPanelContent
}
} }
} }

View file

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

View file

@ -1,54 +0,0 @@
import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEllipsisV
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEllipsisV
)
const AccountActions = {
props: [
'user', 'relationship'
],
data () {
return { }
},
components: {
ProgressButton,
Popover
},
methods: {
showRepeats () {
this.$store.dispatch('showReblogs', this.user.id)
},
hideRepeats () {
this.$store.dispatch('hideReblogs', this.user.id)
},
blockUser () {
this.$store.dispatch('blockUser', this.user.id)
},
unblockUser () {
this.$store.dispatch('unblockUser', this.user.id)
},
reportUser () {
this.$store.dispatch('openUserReportingModal', { userId: this.user.id })
},
openChat () {
this.$router.push({
name: 'chat',
params: { recipient_id: this.user.id }
})
}
},
computed: {
...mapState({
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
})
}
}
export default AccountActions

View file

@ -1,93 +0,0 @@
<template>
<div class="AccountActions">
<Popover
trigger="click"
placement="bottom"
:bound-to="{ x: 'container' }"
remove-padding
>
<template v-slot:content>
<div class="dropdown-menu">
<template v-if="relationship.following">
<button
v-if="relationship.showing_reblogs"
class="btn button-default dropdown-item"
@click="hideRepeats"
>
{{ $t('user_card.hide_repeats') }}
</button>
<button
v-if="!relationship.showing_reblogs"
class="btn button-default dropdown-item"
@click="showRepeats"
>
{{ $t('user_card.show_repeats') }}
</button>
<div
role="separator"
class="dropdown-divider"
/>
</template>
<button
v-if="relationship.blocking"
class="btn button-default btn-block dropdown-item"
@click="unblockUser"
>
{{ $t('user_card.unblock') }}
</button>
<button
v-else
class="btn button-default btn-block dropdown-item"
@click="blockUser"
>
{{ $t('user_card.block') }}
</button>
<button
class="btn button-default btn-block dropdown-item"
@click="reportUser"
>
{{ $t('user_card.report') }}
</button>
<button
v-if="pleromaChatMessagesAvailable"
class="btn button-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>
</Popover>
</div>
</template>
<script src="./account_actions.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.AccountActions {
button.dropdown-item {
margin-left: 0;
}
.ellipsis-button {
width: 2.5em;
margin: -0.5em 0;
padding: 0.5em 0;
text-align: center;
&:not(:hover) .icon {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
}
}
</style>

View file

@ -1,42 +0,0 @@
<template>
<div class="async-component-error">
<div>
<h4>
{{ $t('general.generic_error') }}
</h4>
<p>
{{ $t('general.error_retry') }}
</p>
<button
class="btn button-default"
@click="retry"
>
{{ $t('general.retry') }}
</button>
</div>
</div>
</template>
<script>
export default {
emits: ['resetAsyncComponent'],
methods: {
retry () {
this.$emit('resetAsyncComponent')
}
}
}
</script>
<style lang="scss">
.async-component-error {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
.btn {
margin: .5em;
padding: .5em 2em;
}
}
</style>

View file

@ -1,100 +1,35 @@
import StillImage from '../still-image/still-image.vue' import StillImage from '../still-image/still-image.vue'
import Flash from '../flash/flash.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue' import VideoAttachment from '../video_attachment/video_attachment.vue'
import nsfwImage from '../../assets/nsfw.png' import nsfwImage from '../../assets/nsfw.png'
import fileTypeService from '../../services/file_type/file_type.service.js' import fileTypeService from '../../services/file_type/file_type.service.js'
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faFile,
faMusic,
faImage,
faVideo,
faPlayCircle,
faTimes,
faStop,
faSearchPlus,
faTrashAlt,
faPencilAlt,
faAlignRight
} from '@fortawesome/free-solid-svg-icons'
library.add(
faFile,
faMusic,
faImage,
faVideo,
faPlayCircle,
faTimes,
faStop,
faSearchPlus,
faTrashAlt,
faPencilAlt,
faAlignRight
)
const Attachment = { const Attachment = {
props: [ props: [
'attachment', 'attachment',
'description',
'hideDescription',
'nsfw', 'nsfw',
'statusId',
'size', 'size',
'setMedia', 'allowPlay',
'remove', 'setMedia'
'shiftUp',
'shiftDn',
'edit'
], ],
data () { data () {
return { return {
localDescription: this.description || this.attachment.description,
nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage, nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage,
hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw, hideNsfwLocal: this.$store.state.config.hideNsfw,
preloadImage: this.$store.getters.mergedConfig.preloadImage, preloadImage: this.$store.state.config.preloadImage,
loading: false, loading: false,
img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'), img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
modalOpen: false, modalOpen: false,
showHidden: false, showHidden: false
flashLoaded: false,
showDescription: false
} }
}, },
components: { components: {
Flash,
StillImage, StillImage,
VideoAttachment VideoAttachment
}, },
computed: { computed: {
classNames () { usePlaceHolder () {
return [ return this.size === 'hide' || this.type === 'unknown'
{
'-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
},
placeholderName () {
if (this.attachment.description === '' || !this.attachment.description) {
return this.type.toUpperCase()
}
return this.attachment.description
},
placeholderIconClass () {
if (this.type === 'image') return 'image'
if (this.type === 'video') return 'video'
if (this.type === 'audio') return 'music'
return 'file'
}, },
referrerpolicy () { referrerpolicy () {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer' return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
@ -106,77 +41,36 @@ const Attachment = {
return this.nsfw && this.hideNsfwLocal && !this.showHidden return this.nsfw && this.hideNsfwLocal && !this.showHidden
}, },
isEmpty () { isEmpty () {
return (this.type === 'html' && !this.attachment.oembed) return (this.type === 'html' && !this.attachment.oembed) || this.type === 'unknown'
}, },
useModal () { isSmall () {
let modalTypes = [] return this.size === 'small'
switch (this.size) {
case 'hide':
case 'small':
modalTypes = ['image', 'video', 'audio', 'flash']
break
default:
modalTypes = this.mergedConfig.playVideosInModal
? ['image', 'video', 'flash']
: ['image']
break
}
return modalTypes.includes(this.type)
}, },
videoTag () { fullwidth () {
return this.useModal ? 'button' : 'span' return this.type === 'html' || this.type === 'audio'
},
...mapGetters(['mergedConfig'])
},
watch: {
localDescription (newVal) {
this.onEdit(newVal)
} }
}, },
methods: { methods: {
linkClicked ({ target }) { linkClicked ({target}) {
if (target.tagName === 'A') { if (target.tagName === 'A') {
window.open(target.href, '_blank') window.open(target.href, '_blank')
} }
}, },
openModal (event) { openModal (event) {
if (this.useModal) { const modalTypes = this.$store.state.config.playVideosInModal
this.$emit('setMedia') ? ['image', 'video']
this.$store.dispatch('setCurrentMedia', this.attachment) : ['image']
} else if (this.type === 'unknown') { if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) ||
window.open(this.attachment.url) this.usePlaceHolder
) {
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) { toggleHidden (event) {
if ( if (this.$store.state.config.useOneClickNsfw && !this.showHidden) {
(this.mergedConfig.useOneClickNsfw && !this.showHidden) &&
(this.type !== 'video' || this.mergedConfig.playVideosInModal)
) {
this.openModal(event) this.openModal(event)
return return
} }
@ -194,11 +88,6 @@ const Attachment = {
} else { } else {
this.showHidden = !this.showHidden this.showHidden = !this.showHidden
} }
},
onImageLoad (image) {
const width = image.naturalWidth
const height = image.naturalHeight
this.$emit('naturalSizeLoad', { id: this.attachment.id, 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,268 +1,239 @@
<template> <template>
<button <div v-if="usePlaceHolder" @click="openModal">
v-if="usePlaceholder" <a class="placeholder"
class="Attachment -placeholder button-unstyled"
:class="classNames"
@click="openModal"
>
<a
v-if="type !== 'html'" v-if="type !== 'html'"
class="placeholder" target="_blank" :href="attachment.url"
target="_blank"
:href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
@click.prevent
> >
<FAIcon :icon="placeholderIconClass" /> [{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}]
<b>{{ nsfw ? "NSFW / " : "" }}</b>{{ edit ? '' : placeholderName }}
</a> </a>
<div </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 v-else class="attachment"
class="Attachment" :class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
:class="classNames" v-show="!isEmpty"
> >
<div <a class="image-attachment" v-if="hidden" :href="attachment.url" @click.prevent="toggleHidden">
v-show="!isEmpty" <img class="nsfw" :key="nsfwImage" :src="nsfwImage" :class="{'small': isSmall}"/>
class="attachment-wrapper" <i v-if="type === 'video'" class="play-icon icon-play-circled"></i>
> </a>
<a <div class="hider" v-if="nsfw && hideNsfwLocal && !hidden">
v-if="hidden" <a href="#" @click.prevent="toggleHidden">Hide</a>
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>
</div> </div>
<div
v-if="size !== 'hide' && !hideDescription && (edit || (localDescription && showDescription))" <a v-if="type === 'image' && (!hidden || preloadImage)"
class="description-container" @click="openModal"
:class="{ '-static': !edit }" class="image-attachment"
:class="{'hidden': hidden && preloadImage }"
:href="attachment.url" target="_blank"
> >
<input <StillImage :referrerpolicy="referrerpolicy" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"
v-if="edit" :alt="attachment.description"/>
v-model="localDescription" </a>
type="text"
class="description-field" <a class="video-container"
:placeholder="$t('post_status.media_description')" @click="openModal"
@keydown.enter.prevent="" v-if="type === 'video' && !hidden"
> :class="{'small': isSmall}"
<p v-else> :href="allowPlay ? undefined : attachment.url"
{{ localDescription }} >
</p> <VideoAttachment class="video" :attachment="attachment" :controls="allowPlay" />
<i v-if="!allowPlay" class="play-icon icon-play-circled"></i>
</a>
<audio v-if="type === 'audio'" :src="attachment.url" controls></audio>
<div @click.prevent="linkClicked" v-if="type === 'html' && attachment.oembed" class="oembed">
<div v-if="attachment.thumb_url" class="image">
<img :src="attachment.thumb_url"/>
</div>
<div class="text">
<h1><a :href="attachment.url">{{attachment.oembed.title}}</a></h1>
<div v-html="attachment.oembed.oembedHTML"></div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script src="./attachment.js"></script> <script src="./attachment.js"></script>
<style src="./attachment.scss" lang="scss"></style> <style lang="scss">
@import '../../_variables.scss';
.attachments {
display: flex;
flex-wrap: wrap;
.attachment.media-upload-container {
flex: 0 0 auto;
max-height: 200px;
max-width: 100%;
display: flex;
video {
max-width: 100%;
}
}
.placeholder {
margin-right: 8px;
margin-bottom: 4px;
}
.nsfw-placeholder {
cursor: pointer;
&.loading {
cursor: progress;
}
}
.attachment {
position: relative;
margin: 0.5em 0.5em 0em 0em;
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%;
}
.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;
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 {
width: 100%;
height: 100%;
&.hidden {
display: none;
}
.nsfw {
object-fit: cover;
width: 100%;
height: 100%;
}
img {
image-orientation: from-image;
}
}
}
</style>

View file

@ -1,27 +0,0 @@
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'
import { mapGetters } from 'vuex'
const AuthForm = {
name: 'AuthForm',
render () {
return h(resolveComponent(this.authForm))
},
computed: {
authForm () {
if (this.requiredTOTP) { return 'MFATOTPForm' }
if (this.requiredRecovery) { return 'MFARecoveryForm' }
return 'LoginForm'
},
...mapGetters('authFlow', ['requiredTOTP', 'requiredRecovery'])
},
components: {
MFARecoveryForm,
MFATOTPForm,
LoginForm
}
}
export default AuthForm

View file

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

View file

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

View file

@ -1,21 +0,0 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const AvatarList = {
props: ['users'],
computed: {
slicedUsers () {
return this.users ? this.users.slice(0, 15) : []
}
},
components: {
UserAvatar
},
methods: {
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}
}
}
export default AvatarList

View file

@ -1,46 +0,0 @@
<template>
<div class="avatars">
<router-link
v-for="user in slicedUsers"
:key="user.id"
:to="userProfileLink(user)"
class="avatars-item"
>
<UserAvatar
:user="user"
class="avatar-small"
/>
</router-link>
</div>
</template>
<script src="./avatar_list.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
.avatars {
display: flex;
margin: 0;
padding: 0;
// For hiding overflowing elements
flex-wrap: wrap;
height: 24px;
.avatars-item {
margin: 0 0 5px 5px;
&:first-child {
padding-left: 5px;
}
.avatar-small {
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
height: 24px;
width: 24px;
}
}
}
</style>

View file

@ -1,30 +0,0 @@
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 = {
props: [
'user'
],
data () {
return {
userExpanded: false
}
},
components: {
UserCard,
UserAvatar,
RichContent
},
methods: {
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}
}
}
export default BasicUserCard

View file

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

View file

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

View file

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

View file

@ -1,17 +0,0 @@
import Timeline from '../timeline/timeline.vue'
const Bookmarks = {
computed: {
timeline () {
return this.$store.state.statuses.timelines.bookmarks
}
},
components: {
Timeline
},
unmounted () {
this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
}
}
export default Bookmarks

View file

@ -1,9 +0,0 @@
<template>
<Timeline
:title="$t('nav.bookmarks')"
:timeline="timeline"
:timeline-name="'bookmarks'"
/>
</template>
<script src="./bookmark_timeline.js"></script>

View file

@ -1,409 +0,0 @@
import _ from 'lodash'
import { WSConnectionStatus } from '../../services/api/api.service.js'
import { mapGetters, mapState } from 'vuex'
import ChatMessage from '../chat_message/chat_message.vue'
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 { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronDown,
faChevronLeft
} from '@fortawesome/free-solid-svg-icons'
import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
library.add(
faChevronDown,
faChevronLeft
)
const BOTTOMED_OUT_OFFSET = 10
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
const SAFE_RESIZE_TIME_OFFSET = 100
const MARK_AS_READ_DELAY = 1500
const MAX_RETRIES = 10
const Chat = {
components: {
ChatMessage,
ChatTitle,
PostStatusForm
},
data () {
return {
jumpToBottomButtonVisible: false,
hoveredMessageChainId: undefined,
lastScrollPosition: {},
scrollableContainerHeight: '100%',
errorLoadingChat: false,
messageRetriers: {}
}
},
created () {
this.startFetching()
window.addEventListener('resize', this.handleLayoutChange)
},
mounted () {
window.addEventListener('scroll', this.handleScroll)
if (typeof document.hidden !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
}
this.$nextTick(() => {
this.updateScrollableContainerHeight()
this.handleResize()
})
this.setChatLayout()
},
unmounted () {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('resize', this.handleLayoutChange)
this.unsetChatLayout()
if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
this.$store.dispatch('clearCurrentChat')
},
computed: {
recipient () {
return this.currentChat && this.currentChat.account
},
recipientId () {
return this.$route.params.recipient_id
},
formPlaceholder () {
if (this.recipient) {
return this.$t('chats.message_user', { nickname: this.recipient.screen_name_ui })
} else {
return ''
}
},
chatViewItems () {
return chatService.getView(this.currentChatMessageService)
},
newMessageCount () {
return this.currentChatMessageService && this.currentChatMessageService.newMessageCount
},
streamingEnabled () {
return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
},
...mapGetters([
'currentChat',
'currentChatMessageService',
'findOpenedChatByRecipientId',
'mergedConfig'
]),
...mapState({
backendInteractor: state => state.api.backendInteractor,
mastoUserSocketStatus: state => state.api.mastoUserSocketStatus,
mobileLayout: state => state.interface.mobileLayout,
layoutHeight: state => state.interface.layoutHeight,
currentUser: state => state.users.currentUser
})
},
watch: {
chatViewItems () {
// We don't want to scroll to the bottom on a new message when the user is viewing older messages.
// Therefore we need to know whether the scroll position was at the bottom before the DOM update.
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
this.$nextTick(() => {
if (bottomedOutBeforeUpdate) {
this.scrollDown()
}
})
},
'$route': function () {
this.startFetching()
},
layoutHeight () {
this.handleResize({ expand: true })
},
mastoUserSocketStatus (newValue) {
if (newValue === WSConnectionStatus.JOINED) {
this.fetchChat({ isFirstFetch: true })
}
}
},
methods: {
// Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
onMessageHover ({ isHovered, messageChainId }) {
this.hoveredMessageChainId = isHovered ? messageChainId : undefined
},
onFilesDropped () {
this.$nextTick(() => {
this.handleResize()
this.updateScrollableContainerHeight()
})
},
handleVisibilityChange () {
this.$nextTick(() => {
if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
this.scrollDown({ forceRead: true })
}
})
},
setChatLayout () {
// This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app).
// This layout prevents empty spaces from being visible at the bottom
// of the chat on iOS Safari (`safe-area-inset`) when
// - the on-screen keyboard appears and the user starts typing
// - the user selects the text inside the input area
// - the user selects and deletes the text that is multiple lines long
// TODO: unify the chat layout with the global layout.
let html = document.querySelector('html')
if (html) {
html.classList.add('chat-layout')
}
this.$nextTick(() => {
this.updateScrollableContainerHeight()
})
},
unsetChatLayout () {
let html = document.querySelector('html')
if (html) {
html.classList.remove('chat-layout')
}
},
handleLayoutChange () {
this.$nextTick(() => {
this.updateScrollableContainerHeight()
this.scrollDown()
})
},
// Ensures the proper position of the posting form in the mobile layout (the mobile browser panel does not overlap or hide it)
updateScrollableContainerHeight () {
const header = this.$refs.header
const footer = this.$refs.footer
const inner = this.mobileLayout ? window.document.body : this.$refs.inner
this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px'
},
// Preserves the scroll position when OSK appears or the posting form changes its height.
handleResize (opts = {}) {
const { expand = false, delayed = false } = opts
if (delayed) {
setTimeout(() => {
this.handleResize({ ...opts, delayed: false })
}, SAFE_RESIZE_TIME_OFFSET)
return
}
this.$nextTick(() => {
this.updateScrollableContainerHeight()
const { offsetHeight = undefined } = this.lastScrollPosition
this.lastScrollPosition = getScrollPosition(this.$refs.scrollable)
const diff = this.lastScrollPosition.offsetHeight - offsetHeight
if (diff < 0 || (!this.bottomedOut() && expand)) {
this.$nextTick(() => {
this.updateScrollableContainerHeight()
this.$refs.scrollable.scrollTo({
top: this.$refs.scrollable.scrollTop - diff,
left: 0
})
})
}
})
},
scrollDown (options = {}) {
const { behavior = 'auto', forceRead = false } = options
const scrollable = this.$refs.scrollable
if (!scrollable) { return }
this.$nextTick(() => {
scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior })
})
if (forceRead) {
this.readChat()
}
},
readChat () {
if (!(this.currentChatMessageService && this.currentChatMessageService.maxId)) { return }
if (document.hidden) { return }
const lastReadId = this.currentChatMessageService.maxId
this.$store.dispatch('readChat', {
id: this.currentChat.id,
lastReadId
})
},
bottomedOut (offset) {
return isBottomedOut(this.$refs.scrollable, offset)
},
reachedTop () {
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 }
if (this.reachedTop()) {
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
// get to see get marked as read.
window.setTimeout(() => {
// Don't mark as read if the element doesn't exist, user has left chat view
if (this.$el) this.readChat()
}, MARK_AS_READ_DELAY)
}
} else {
this.jumpToBottomButtonVisible = true
}
}, 200),
handleScrollUp (positionBeforeLoading) {
const positionAfterLoading = getScrollPosition(this.$refs.scrollable)
this.$refs.scrollable.scrollTo({
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
left: 0
})
},
fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) {
const chatMessageService = this.currentChatMessageService
if (!chatMessageService) { return }
if (fetchLatest && this.streamingEnabled) { return }
const chatId = chatMessageService.chatId
const fetchOlderMessages = !!maxId
const sinceId = fetchLatest && chatMessageService.maxId
return this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId })
.then((messages) => {
// Clear the current chat in case we're recovering from a ws connection loss.
if (isFirstFetch) {
chatService.clear(chatMessageService)
}
const positionBeforeUpdate = getScrollPosition(this.$refs.scrollable)
this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => {
this.$nextTick(() => {
if (fetchOlderMessages) {
this.handleScrollUp(positionBeforeUpdate)
}
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 })
}
})
})
})
},
async startFetching () {
let chat = this.findOpenedChatByRecipientId(this.recipientId)
if (!chat) {
try {
chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId })
} catch (e) {
console.error('Error creating or getting a chat', e)
this.errorLoadingChat = true
}
}
if (chat) {
this.$nextTick(() => {
this.scrollDown({ forceRead: true })
})
this.$store.dispatch('addOpenedChat', { chat })
this.doStartFetching()
}
},
doStartFetching () {
this.$store.dispatch('startFetchingCurrentChat', {
fetcher: () => promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000)
})
this.fetchChat({ isFirstFetch: true })
},
handleAttachmentPosting () {
this.$nextTick(() => {
this.handleResize()
// When the posting form size changes because of a media attachment, we need an extra resize
// to account for the potential delay in the DOM update.
setTimeout(() => {
this.updateScrollableContainerHeight()
}, SAFE_RESIZE_TIME_OFFSET)
this.scrollDown({ forceRead: true })
})
},
sendMessage ({ status, media, idempotencyKey }) {
const params = {
id: this.currentChat.id,
content: status,
idempotencyKey
}
if (media[0]) {
params.mediaId = media[0].id
}
const fakeMessage = buildFakeMessage({
attachments: media,
chatId: this.currentChat.id,
content: status,
userId: this.currentUser.id,
idempotencyKey
})
this.$store.dispatch('addChatMessages', {
chatId: this.currentChat.id,
messages: [fakeMessage]
}).then(() => {
this.handleAttachmentPosting()
})
return this.doSendMessage({ params, fakeMessage, retriesLeft: MAX_RETRIES })
},
doSendMessage ({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
if (retriesLeft <= 0) return
this.backendInteractor.sendChatMessage(params)
.then(data => {
this.$store.dispatch('addChatMessages', {
chatId: this.currentChat.id,
updateMaxId: false,
messages: [{ ...data, fakeId: fakeMessage.id }]
})
return data
})
.catch(error => {
console.error('Error sending message', error)
this.$store.dispatch('handleMessageError', {
chatId: this.currentChat.id,
fakeId: fakeMessage.id,
isRetry: retriesLeft !== MAX_RETRIES
})
if ((error.statusCode >= 500 && error.statusCode < 600) || error.message === 'Failed to fetch') {
this.messageRetriers[fakeMessage.id] = setTimeout(() => {
this.doSendMessage({ params, fakeMessage, retriesLeft: retriesLeft - 1 })
}, 1000 * (2 ** (MAX_RETRIES - retriesLeft)))
}
return {}
})
return Promise.resolve(fakeMessage)
},
goBack () {
this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
}
}
}
export default Chat

View file

@ -1,170 +0,0 @@
.chat-view {
display: flex;
height: calc(100vh - 60px);
width: 100%;
.chat-title {
// prevents chat header jumping on when the user avatar loads
height: 28px;
}
.chat-view-inner {
height: auto;
width: 100%;
overflow: visible;
display: flex;
margin: 0.5em 0.5em 0 0.5em;
}
.chat-view-body {
background-color: var(--chatBg, $fallback--bg);
display: flex;
flex-direction: column;
width: 100%;
overflow: visible;
min-height: 100%;
margin: 0 0 0 0;
border-radius: 10px 10px 0 0;
border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0;
&::after {
border-radius: 0;
}
}
.scrollable-message-list {
padding: 0 0.8em;
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
.footer {
position: sticky;
bottom: 0;
}
.chat-view-heading {
align-items: center;
justify-content: space-between;
top: 50px;
display: flex;
z-index: 2;
position: sticky;
overflow: hidden;
}
.go-back-button {
cursor: pointer;
width: 28px;
text-align: center;
padding: 0.6em;
margin: -0.6em 0.6em -0.6em -0.6em;
}
.jump-to-bottom-button {
width: 2.5em;
height: 2.5em;
border-radius: 100%;
position: absolute;
right: 1.3em;
top: -3.2em;
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.3);
z-index: 10;
transition: 0.35s all;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
opacity: 0;
visibility: hidden;
cursor: pointer;
&.visible {
opacity: 1;
visibility: visible;
}
i {
font-size: 1em;
color: $fallback--text;
color: var(--text, $fallback--text);
}
.unread-message-count {
font-size: 0.8em;
left: 50%;
margin-top: -1rem;
padding: 0.1em;
border-radius: 50px;
position: absolute;
}
.chat-loading-error {
width: 100%;
display: flex;
align-items: flex-end;
height: 100%;
.error {
width: 100%;
}
}
}
@media all and (max-width: 800px) {
height: 100%;
overflow: hidden;
.chat-view-inner {
overflow: hidden;
height: 100%;
margin-top: 0;
margin-left: 0;
margin-right: 0;
}
.chat-view-body {
display: flex;
min-height: auto;
overflow: hidden;
height: 100%;
margin: 0;
border-radius: 0;
}
.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 {
display: unset;
overflow-y: scroll;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.footer {
position: sticky;
bottom: auto;
}
}
}

View file

@ -1,103 +0,0 @@
<template>
<div class="chat-view">
<div class="chat-view-inner">
<div
id="nav"
ref="inner"
class="panel-default panel chat-view-body"
>
<div
ref="header"
class="panel-heading chat-view-heading mobile-hidden"
>
<a
class="go-back-button"
@click="goBack"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
</a>
<div class="title text-center">
<ChatTitle
:user="recipient"
:with-avatar="true"
/>
</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>
<div
v-else
class="chat-loading-error"
>
<div class="alert error">
{{ $t('chats.error_loading_chat') }}
</div>
</div>
</div>
<div
ref="footer"
class="panel-body footer"
>
<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>
</div>
</div>
</div>
</template>
<script src="./chat.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@import './chat.scss';
</style>

View file

@ -1,33 +0,0 @@
// Captures a scroll position
export const getScrollPosition = (el) => {
return {
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
offsetHeight: el.offsetHeight
}
}
// A helper function that is used to keep the scroll position fixed as the new elements are added to the top
// Takes two scroll positions, before and after the update.
export const getNewTopPosition = (previousPosition, newPosition) => {
return previousPosition.scrollTop + (newPosition.scrollHeight - previousPosition.scrollHeight)
}
export const isBottomedOut = (el, offset = 0) => {
if (!el) { return }
const scrollHeight = el.scrollTop + offset
const totalHeight = el.scrollHeight - el.offsetHeight
return totalHeight <= scrollHeight
}
// Height of the scrollable container. The dynamic height is needed to ensure the mobile browser panel doesn't overlap or hide the posting form.
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

@ -1,37 +0,0 @@
import { mapState, mapGetters } from 'vuex'
import ChatListItem from '../chat_list_item/chat_list_item.vue'
import ChatNew from '../chat_new/chat_new.vue'
import List from '../list/list.vue'
const ChatList = {
components: {
ChatListItem,
List,
ChatNew
},
computed: {
...mapState({
currentUser: state => state.users.currentUser
}),
...mapGetters(['sortedChatList'])
},
data () {
return {
isNew: false
}
},
created () {
this.$store.dispatch('fetchChats', { latest: true })
},
methods: {
cancelNewChat () {
this.isNew = false
this.$store.dispatch('fetchChats', { latest: true })
},
newChat () {
this.isNew = true
}
}
}
export default ChatList

View file

@ -1,64 +0,0 @@
<template>
<div v-if="isNew">
<ChatNew @cancel="cancelNewChat" />
</div>
<div
v-else
class="chat-list panel panel-default"
>
<div class="panel-heading">
<span class="title">
{{ $t("chats.chats") }}
</span>
<button
class="button-default"
@click="newChat"
>
{{ $t("chats.new") }}
</button>
</div>
<div class="panel-body">
<div
v-if="sortedChatList.length > 0"
class="timeline"
>
<List :items="sortedChatList">
<template v-slot:item="{item}">
<ChatListItem
:key="item.id"
:compact="false"
:chat="item"
/>
</template>
</List>
</div>
<div
v-else
class="emtpy-chat-list-alert"
>
<span>{{ $t('chats.empty_chat_list_placeholder') }}</span>
</div>
</div>
</div>
</template>
<script src="./chat_list.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.chat-list {
min-height: 25em;
margin-bottom: 0;
}
.emtpy-chat-list-alert {
padding: 3em;
font-size: 1.2em;
display: flex;
justify-content: center;
color: $fallback--text;
color: var(--faint, $fallback--text);
}
</style>

View file

@ -1,69 +0,0 @@
import { mapState } from 'vuex'
import StatusBody 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'
import Timeago from '../timeago/timeago.vue'
import ChatTitle from '../chat_title/chat_title.vue'
const ChatListItem = {
name: 'ChatListItem',
props: [
'chat'
],
components: {
UserAvatar,
AvatarList,
Timeago,
ChatTitle,
StatusBody
},
computed: {
...mapState({
currentUser: state => state.users.currentUser
}),
attachmentInfo () {
if (this.chat.lastMessage.attachments.length === 0) { return }
const types = this.chat.lastMessage.attachments.map(file => fileType.fileType(file.mimetype))
if (types.includes('video')) {
return this.$t('file_type.video')
} else if (types.includes('audio')) {
return this.$t('file_type.audio')
} else if (types.includes('image')) {
return this.$t('file_type.image')
} else {
return this.$t('file_type.file')
}
},
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,
text: messagePreview,
attachments: []
}
}
},
methods: {
openChat (_e) {
if (this.chat.id) {
this.$router.push({
name: 'chat',
params: {
username: this.currentUser.screen_name,
recipient_id: this.chat.account.id
}
})
}
}
}
}
export default ChatListItem

View file

@ -1,91 +0,0 @@
.chat-list-item {
display: flex;
flex-direction: row;
padding: 0.75em;
height: 5em;
overflow: hidden;
box-sizing: border-box;
cursor: pointer;
:focus {
outline: none;
}
&:hover {
background-color: var(--selectedPost, $fallback--lightBg);
box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1);
}
.chat-list-item-left {
margin-right: 1em;
}
.chat-list-item-center {
width: 100%;
box-sizing: border-box;
overflow: hidden;
word-wrap: break-word;
}
.heading {
width: 100%;
display: inline-flex;
justify-content: space-between;
line-height: 1em;
}
.heading-right {
white-space: nowrap;
}
.name-and-account-name {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
flex-shrink: 1;
line-height: 1.4em;
}
.chat-preview {
display: inline-flex;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin: 0.35em 0;
color: $fallback--text;
color: var(--faint, $fallback--text);
width: 100%;
}
a {
color: var(--faintLink, $fallback--link);
text-decoration: none;
pointer-events: none;
}
&:hover .animated.avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
.Avatar {
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
}
.chat-preview-body {
--emoji-size: 1.4em;
}
.time-wrapper {
line-height: 1.4em;
}
.chat-preview-body {
padding-right: 1em;
}
}

View file

@ -1,53 +0,0 @@
<template>
<div
class="chat-list-item"
@click.capture.prevent="openChat"
>
<div class="chat-list-item-left">
<UserAvatar
:user="chat.account"
height="48px"
width="48px"
/>
</div>
<div class="chat-list-item-center">
<div class="heading">
<span
v-if="chat.account"
class="name-and-account-name"
>
<ChatTitle
:user="chat.account"
/>
</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"
:status="messageForStatusContent"
:single-line="true"
/>
<div
v-if="chat.unread > 0"
class="badge badge-notification unread-chat-count"
>
{{ chat.unread }}
</div>
</div>
</div>
</div>
</template>
<script src="./chat_list_item.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@import './chat_list_item.scss';
</style>

View file

@ -1,108 +0,0 @@
import { mapState, mapGetters } from 'vuex'
import Popover from '../popover/popover.vue'
import Attachment from '../attachment/attachment.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import StatusContent from '../status_content/status_content.vue'
import ChatMessageDate from '../chat_message_date/chat_message_date.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes,
faEllipsisH
} from '@fortawesome/free-solid-svg-icons'
library.add(
faTimes,
faEllipsisH
)
const ChatMessage = {
name: 'ChatMessage',
props: [
'author',
'edited',
'noHeading',
'chatViewItem',
'hoveredMessageChain'
],
emits: ['hover'],
components: {
Popover,
Attachment,
StatusContent,
UserAvatar,
Gallery,
LinkPreview,
ChatMessageDate
},
computed: {
// Returns HH:MM (hours and minutes) in local time.
createdAt () {
const time = this.chatViewItem.data.created_at
return time.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false })
},
isCurrentUser () {
return this.message.account_id === this.currentUser.id
},
message () {
return this.chatViewItem.data
},
userProfileLink () {
return generateProfileLink(this.author.id, this.author.screen_name, this.$store.state.instance.restrictedNicknames)
},
isMessage () {
return this.chatViewItem.type === 'message'
},
messageForStatusContent () {
return {
summary: '',
emojis: this.message.emojis,
raw_html: this.message.content || '',
text: this.message.content || '',
attachments: this.message.attachments
}
},
hasAttachment () {
return this.message.attachments.length > 0
},
...mapState({
betterShadow: state => state.interface.browserSupport.cssFilter,
currentUser: state => state.users.currentUser,
restrictedNicknames: state => state.instance.restrictedNicknames
}),
popoverMarginStyle () {
if (this.isCurrentUser) {
return {}
} else {
return { left: 50 }
}
},
...mapGetters(['mergedConfig', 'findUser'])
},
data () {
return {
hovered: false,
menuOpened: false
}
},
methods: {
onHover (bool) {
this.$emit('hover', { isHovered: bool, messageChainId: this.chatViewItem.messageChainId })
},
async deleteMessage () {
const confirmed = window.confirm(this.$t('chats.delete_confirm'))
if (confirmed) {
await this.$store.dispatch('deleteChatMessage', {
messageId: this.chatViewItem.data.id,
chatId: this.chatViewItem.data.chat_id
})
}
this.hovered = false
this.menuOpened = false
}
}
}
export default ChatMessage

View file

@ -1,179 +0,0 @@
@import '../../_variables.scss';
.chat-message-wrapper {
&.hovered-message-chain {
.animated.Avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
}
.chat-message-menu {
transition: opacity 0.1s;
opacity: 0;
position: absolute;
top: -0.8em;
button {
padding-top: 0.2em;
padding-bottom: 0.2em;
}
}
.menu-icon {
cursor: pointer;
&:hover, .extra-button-popover.open & {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
.popover {
width: 12em;
}
.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 {
margin-right: 0.72em;
width: 32px;
}
.link-preview, .attachments {
margin-bottom: 1em;
}
.chat-message-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 80%;
min-width: 10em;
width: 100%;
&.with-media {
width: 100%;
.status {
width: 100%;
}
}
}
.status {
border-radius: $fallback--chatMessageRadius;
border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
display: flex;
padding: 0.75em;
}
.created-at {
position: relative;
float: right;
font-size: 0.8em;
margin: -1em 0 -0.5em 0;
font-style: italic;
opacity: 0.8;
}
.without-attachment {
.message-content {
// TODO figure out how to do it properly
.RichContent::after {
margin-right: 5.4em;
content: " ";
display: inline-block;
}
}
}
.pending {
.status-content.media-body, .created-at {
color: var(--faint);
}
}
.error {
.status-content.media-body, .created-at {
color: $fallback--cRed;
color: var(--badgeNotification, $fallback--cRed);
}
}
.incoming {
a {
color: var(--chatMessageIncomingLink, $fallback--link);
}
.status {
color: var(--chatMessageIncomingText, $fallback--text);
background-color: var(--chatMessageIncomingBg, $fallback--bg);
border: 1px solid var(--chatMessageIncomingBorder, --border);
}
.created-at {
a {
color: var(--chatMessageIncomingText, $fallback--text);
}
}
.chat-message-menu {
left: 0.4rem;
}
}
.outgoing {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-content: end;
justify-content: flex-end;
a {
color: var(--chatMessageOutgoingLink, $fallback--link);
}
.status {
color: var(--chatMessageOutgoingText, $fallback--text);
background-color: var(--chatMessageOutgoingBg, $fallback--lightBg);
border: 1px solid var(--chatMessageOutgoingBorder, --lightBg);
}
.chat-message-inner {
align-items: flex-end;
}
.chat-message-menu {
right: 0.4rem;
}
}
.visible {
opacity: 1;
}
}
.chat-message-date-separator {
text-align: center;
margin: 1.4em 0;
font-size: 0.9em;
user-select: none;
color: $fallback--text;
color: var(--faintedText, $fallback--text);
}

View file

@ -1,103 +0,0 @@
<template>
<div
v-if="isMessage"
class="chat-message-wrapper"
:class="{ 'hovered-message-chain': hoveredMessageChain }"
@mouseover="onHover(true)"
@mouseleave="onHover(false)"
>
<div
class="chat-message"
:class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]"
>
<div
v-if="!isCurrentUser"
class="avatar-wrapper"
>
<router-link
v-if="chatViewItem.isHead"
:to="userProfileLink"
>
<UserAvatar
:compact="true"
:better-shadow="betterShadow"
:user="author"
/>
</router-link>
</div>
<div class="chat-message-inner">
<div
class="status-body"
:style="{ 'min-width': message.attachment ? '80%' : '' }"
>
<div
class="media status"
:class="{ 'without-attachment': !hasAttachment, 'pending': chatViewItem.data.pending, 'error': chatViewItem.data.error }"
style="position: relative"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
>
<div
class="chat-message-menu"
:class="{ 'visible': hovered || menuOpened }"
>
<Popover
trigger="click"
placement="top"
:bound-to-selector="isCurrentUser ? '' : '.scrollable-message-list'"
:bound-to="{ x: 'container' }"
:margin="popoverMarginStyle"
@show="menuOpened = true"
@close="menuOpened = false"
>
<template v-slot:content>
<div class="dropdown-menu">
<button
class="button-default 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>
</Popover>
</div>
<StatusContent
class="message-content"
:status="messageForStatusContent"
:full-content="true"
>
<template v-slot:footer>
<span
class="created-at"
>
{{ createdAt }}
</span>
</template>
</StatusContent>
</div>
</div>
</div>
</div>
</div>
<div
v-else
class="chat-message-date-separator"
>
<ChatMessageDate :date="chatViewItem.date" />
</div>
</template>
<script src="./chat_message.js" ></script>
<style lang="scss">
@import './chat_message.scss';
</style>

View file

@ -1,26 +0,0 @@
<template>
<time>
{{ displayDate }}
</time>
</template>
<script>
import localeService from 'src/services/locale/locale.service.js'
export default {
name: 'Timeago',
props: ['date'],
computed: {
displayDate () {
const today = new Date()
today.setHours(0, 0, 0, 0)
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' })
}
}
}
}
</script>

View file

@ -1,83 +0,0 @@
import { mapState, mapGetters } from 'vuex'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSearch,
faChevronLeft
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSearch,
faChevronLeft
)
const chatNew = {
components: {
BasicUserCard,
UserAvatar
},
data () {
return {
suggestions: [],
userIds: [],
loading: false,
query: ''
}
},
async created () {
const { chats } = await this.backendInteractor.chats()
chats.forEach(chat => this.suggestions.push(chat.account))
},
computed: {
users () {
return this.userIds.map(userId => this.findUser(userId))
},
availableUsers () {
if (this.query.length !== 0) {
return this.users
} else {
return this.suggestions
}
},
...mapState({
currentUser: state => state.users.currentUser,
backendInteractor: state => state.api.backendInteractor
}),
...mapGetters(['findUser'])
},
methods: {
goBack () {
this.$emit('cancel')
},
goToChat (user) {
this.$router.push({ name: 'chat', params: { recipient_id: user.id } })
},
onInput () {
this.search(this.query)
},
addUser (user) {
this.selectedUserIds.push(user.id)
this.query = ''
},
removeUser (userId) {
this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId)
},
search (query) {
if (!query) {
this.loading = false
return
}
this.loading = true
this.userIds = []
this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts' })
.then(data => {
this.loading = false
this.userIds = data.accounts.map(a => a.id)
})
}
}
}
export default chatNew

View file

@ -1,31 +0,0 @@
.chat-new {
.input-wrap {
display: flex;
margin: 0.7em 0.5em 0.7em 0.5em;
input {
width: 100%;
}
}
.search-icon {
margin-right: 0.3em;
}
.member-list {
padding-bottom: 0.7rem;
}
.basic-user-card:hover {
cursor: pointer;
background-color: var(--selectedPost, $fallback--lightBg);
}
.go-back-button {
cursor: pointer;
width: 28px;
text-align: center;
padding: 0.6em;
margin: -0.6em 0.6em -0.6em -0.6em;
}
}

View file

@ -1,52 +0,0 @@
<template>
<div
id="nav"
class="panel-default panel chat-new"
>
<div
ref="header"
class="panel-heading"
>
<a
class="go-back-button"
@click="goBack"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
</a>
</div>
<div class="input-wrap">
<div class="input-search">
<FAIcon
class="search-icon fa-scale-110 fa-old-padding"
icon="search"
/>
</div>
<input
ref="search"
v-model="query"
placeholder="Search people"
@input="onInput"
>
</div>
<div class="member-list">
<div
v-for="user in availableUsers"
:key="user.id"
class="member"
>
<div @click.capture.prevent="goToChat(user)">
<BasicUserCard :user="user" />
</div>
</div>
</div>
</div>
</template>
<script src="./chat_new.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@import './chat_new.scss';
</style>

View file

@ -0,0 +1,31 @@
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const chatPanel = {
props: [ 'floating' ],
data () {
return {
currentMessage: '',
channel: null,
collapsed: true
}
},
computed: {
messages () {
return this.$store.state.chat.messages
}
},
methods: {
submit (message) {
this.$store.state.chat.channel.push('new_msg', {text: message}, 10000)
this.currentMessage = ''
},
togglePanel () {
this.collapsed = !this.collapsed
},
userProfileLink (user) {
return generateProfileLink(user.id, user.username, this.$store.state.instance.restrictedNicknames)
}
}
}
export default chatPanel

View file

@ -0,0 +1,101 @@
<template>
<div class="chat-panel" v-if="!this.collapsed || !this.floating">
<div class="panel panel-default">
<div class="panel-heading timeline-heading" :class="{ 'chat-heading': floating }" @click.stop.prevent="togglePanel">
<div class="title">
{{$t('chat.title')}}
<i class="icon-cancel" style="float: right;" v-if="floating"></i>
</div>
</div>
<div class="chat-window" v-chat-scroll>
<div class="chat-message" v-for="message in messages" :key="message.id">
<span class="chat-avatar">
<img :src="message.author.avatar" />
</span>
<div class="chat-content">
<router-link
class="chat-name"
:to="userProfileLink(message.author)">
{{message.author.username}}
</router-link>
<br>
<span class="chat-text">
{{message.text}}
</span>
</div>
</div>
</div>
<div class="chat-input">
<textarea @keyup.enter="submit(currentMessage)" v-model="currentMessage" class="chat-input-textarea" rows="1"></textarea>
</div>
</div>
</div>
<div v-else class="chat-panel">
<div class="panel panel-default">
<div class="panel-heading stub timeline-heading chat-heading" @click.stop.prevent="togglePanel">
<div class="title">
<i class="icon-comment-empty"></i>
{{$t('chat.title')}}
</div>
</div>
</div>
</div>
</template>
<script src="./chat_panel.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.floating-chat {
position: fixed;
right: 0px;
bottom: 0px;
z-index: 1000;
max-width: 25em;
}
.chat-heading {
cursor: pointer;
.icon-comment-empty {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
.chat-window {
overflow-y: auto;
overflow-x: hidden;
max-height: 20em;
}
.chat-window-container {
height: 100%;
}
.chat-message {
display: flex;
padding: 0.2em 0.5em
}
.chat-avatar {
img {
height: 24px;
width: 24px;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
margin-right: 0.5em;
margin-top: 0.25em;
}
}
.chat-input {
display: flex;
textarea {
flex: 1;
margin: 0.6em;
min-height: 3.5em;
resize: none;
}
}
</style>

View file

@ -1,27 +0,0 @@
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 {
name: 'ChatTitle',
components: {
UserAvatar,
RichContent
},
props: [
'user', 'withAvatar'
],
computed: {
title () {
return this.user ? this.user.screen_name_ui : ''
},
htmlTitle () {
return this.user ? this.user.name_html : ''
}
},
methods: {
getUserProfileLink (user) {
return generateProfileLink(user.id, user.screen_name)
}
}
}

View file

@ -1,62 +0,0 @@
<template>
<div
class="chat-title"
:title="title"
>
<router-link
v-if="withAvatar && user"
:to="getUserProfileLink(user)"
>
<UserAvatar
:user="user"
width="23px"
height="23px"
/>
</router-link>
<RichContent
v-if="user"
class="username"
:title="'@'+user.screen_name_ui"
:html="htmlTitle"
:emoji="user.emoji || []"
/>
</div>
</template>
<script src="./chat_title.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.chat-title {
display: flex;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
align-items: center;
--emoji-size: 14px;
.username {
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
display: inline;
word-wrap: break-word;
overflow: hidden;
}
.Avatar {
width: 23px;
height: 23px;
margin-right: 0.5em;
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
&.animated::before {
display: none;
}
}
}
</style>

View file

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

View file

@ -1,68 +0,0 @@
@import '../../_variables.scss';
.color-input {
display: inline-flex;
&-field.input {
display: inline-flex;
flex: 0 0 0;
max-width: 9em;
align-items: stretch;
padding: .2em 8px;
input {
background: none;
color: $fallback--lightText;
color: var(--inputText, $fallback--lightText);
border: none;
padding: 0;
margin: 0;
&.textColor {
flex: 1 0 3em;
min-width: 3em;
padding: 0;
}
&.nativeColor {
flex: 0 0 2em;
min-width: 2em;
align-self: center;
height: 100%;
}
}
.computedIndicator,
.transparentIndicator {
flex: 0 0 2em;
min-width: 2em;
align-self: center;
height: 100%;
}
.transparentIndicator {
// forgot to install counter-strike source, ooops
background-color: #FF00FF;
position: relative;
&::before, &::after {
display: block;
content: '';
background-color: #000000;
position: absolute;
height: 50%;
width: 50%;
}
&::after {
top: 0;
left: 0;
}
&::before {
bottom: 0;
right: 0;
}
}
}
.label {
flex: 1 1 auto;
}
}

View file

@ -1,109 +1,43 @@
<template> <template>
<div <div class="color-control style-control" :class="{ disabled: !present || disabled }">
class="color-input style-control" <label :for="name" class="label">
:class="{ disabled: !present || disabled }" {{label}}
> </label>
<label <input
:for="name" v-if="typeof fallback !== 'undefined'"
class="label" class="opt exlcude-disabled"
:id="name + '-o'"
type="checkbox"
:checked="present"
@input="$emit('input', typeof value === 'undefined' ? fallback : undefined)">
<label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label>
<input
:id="name"
class="color-input"
type="color"
:value="value || fallback"
:disabled="!present || disabled"
@input="$emit('input', $event.target.value)"
> >
{{ label }} <input
</label> :id="name + '-t'"
<Checkbox class="text-input"
v-if="typeof fallback !== 'undefined' && showOptionalTickbox" type="text"
:model-value="present" :value="value || fallback"
:disabled="disabled" :disabled="!present || disabled"
class="opt" @input="$emit('input', $event.target.value)"
@update:modelValue="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)" >
/> </div>
<div class="input color-input-field">
<input
:id="name + '-t'"
class="textColor unstyled"
type="text"
:value="modelValue || fallback"
:disabled="!present || disabled"
@input="$emit('update:modelValue', $event.target.value)"
>
<input
v-if="validColor"
:id="name"
class="nativeColor unstyled"
type="color"
:value="modelValue || fallback"
:disabled="!present || disabled"
@input="$emit('update:modelValue', $event.target.value)"
>
<div
v-if="transparentColor"
class="transparentIndicator"
/>
<div
v-if="computedColor"
class="computedIndicator"
:style="{backgroundColor: fallback}"
/>
</div>
</div>
</template> </template>
<style lang="scss" src="./color_input.scss"></style>
<script> <script>
import Checkbox from '../checkbox/checkbox.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
export default { export default {
components: { props: [
Checkbox 'name', 'label', 'value', 'fallback', 'disabled'
}, ],
props: {
// Name of color, used for identifying
name: {
required: true,
type: String
},
// Readable label
label: {
required: true,
type: String
},
// Color value, should be required but vue cannot tell the difference
// between "property missing" and "property set to undefined"
modelValue: {
required: false,
type: String,
default: undefined
},
// Color fallback to use when value is not defeind
fallback: {
required: false,
type: String,
default: undefined
},
// Disable the control
disabled: {
required: false,
type: Boolean,
default: false
},
// Show "optional" tickbox, for when value might become mandatory
showOptionalTickbox: {
required: false,
type: Boolean,
default: true
}
},
emits: ['update:modelValue'],
computed: { computed: {
present () { present () {
return typeof this.modelValue !== 'undefined' return typeof this.value !== 'undefined'
},
validColor () {
return hex2rgb(this.modelValue || this.fallback)
},
transparentColor () {
return this.modelValue === 'transparent'
},
computedColor () {
return this.modelValue && this.modelValue.startsWith('--')
} }
} }
} }

View file

@ -1,69 +1,35 @@
<template> <template>
<span <span v-if="contrast" class="contrast-ratio">
v-if="contrast" <span :title="hint" class="rating">
class="contrast-ratio" <span v-if="contrast.aaa">
> <i class="icon-thumbs-up-alt"/>
<span
:title="hint"
class="rating"
>
<span v-if="contrast.aaa">
<FAIcon icon="thumbs-up" />
</span>
<span v-if="!contrast.aaa && contrast.aa">
<FAIcon icon="adjust" />
</span>
<span v-if="!contrast.aaa && !contrast.aa">
<FAIcon icon="exclamation-triangle" />
</span>
</span> </span>
<span <span v-if="!contrast.aaa && contrast.aa">
v-if="contrast && large" <i class="icon-adjust"/>
class="rating" </span>
:title="hint_18pt" <span v-if="!contrast.aaa && !contrast.aa">
> <i class="icon-attention"/>
<span v-if="contrast.laaa">
<FAIcon icon="thumbs-up" />
</span>
<span v-if="!contrast.laaa && contrast.laa">
<FAIcon icon="adjust" />
</span>
<span v-if="!contrast.laaa && !contrast.laa">
<FAIcon icon="exclamation-triangle" />
</span>
</span> </span>
</span> </span>
<span class="rating" v-if="contrast && large" :title="hint_18pt">
<span v-if="contrast.laaa">
<i class="icon-thumbs-up-alt"/>
</span>
<span v-if="!contrast.laaa && contrast.laa">
<i class="icon-adjust"/>
</span>
<span v-if="!contrast.laaa && !contrast.laa">
<i class="icon-attention"/>
</span>
</span>
</span>
</template> </template>
<script> <script>
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAdjust,
faExclamationTriangle,
faThumbsUp
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAdjust,
faExclamationTriangle,
faThumbsUp
)
export default { export default {
props: { props: [
large: { 'large', 'contrast'
required: false, ],
type: Boolean,
default: false
},
// TODO: Make theme switcher compute theme initially so that contrast
// component won't be called without contrast data
contrast: {
required: false,
type: Object,
default: () => ({})
}
},
computed: { computed: {
hint () { hint () {
const levelVal = this.contrast.aaa ? 'aaa' : (this.contrast.aa ? 'aa' : 'bad') const levelVal = this.contrast.aaa ? 'aaa' : (this.contrast.aa ? 'aa' : 'bad')
@ -98,7 +64,6 @@ export default {
.rating { .rating {
display: inline-block; display: inline-block;
text-align: center; text-align: center;
margin-left: 0.5em;
} }
} }
</style> </style>

View file

@ -1,12 +1,17 @@
import Conversation from '../conversation/conversation.vue' import Conversation from '../conversation/conversation.vue'
import { find } from 'lodash'
const conversationPage = { const conversationPage = {
components: { components: {
Conversation Conversation
}, },
computed: { computed: {
statusId () { statusoid () {
return this.$route.params.id const id = this.$route.params.id
const statuses = this.$store.state.statuses.allStatuses
const status = find(statuses, {id})
return status
} }
} }
} }

View file

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

View file

@ -1,25 +1,9 @@
import { reduce, filter, findIndex, clone, get } from 'lodash' import { reduce, filter } from 'lodash'
import Status from '../status/status.vue' 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 sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id const seqA = Number(a.id)
const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id const seqB = Number(b.id)
const seqA = Number(idA)
const seqB = Number(idB)
const isSeqA = !Number.isNaN(seqA) const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB) const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) { if (isSeqA && isSeqB) {
@ -29,241 +13,49 @@ const sortById = (a, b) => {
} else if (!isSeqA && isSeqB) { } else if (!isSeqA && isSeqB) {
return 1 return 1
} else { } else {
return idA < idB ? -1 : 1 return a.id < b.id ? -1 : 1
} }
} }
const sortAndFilterConversation = (conversation, statusoid) => { const sortAndFilterConversation = (conversation) => {
if (statusoid.type === 'retweet') { conversation = filter(conversation, (status) => status.type !== 'retweet')
conversation = filter(
conversation,
(status) => (status.type === 'retweet' || status.id !== statusoid.retweeted_status.id)
)
} else {
conversation = filter(conversation, (status) => status.type !== 'retweet')
}
return conversation.filter(_ => _).sort(sortById) return conversation.filter(_ => _).sort(sortById)
} }
const conversation = { const conversation = {
data () { data () {
return { return {
highlight: null, highlight: null
expanded: false,
threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
statusContentPropertiesObject: {},
inlineDivePosition: null
} }
}, },
props: [ props: [
'statusId', 'statusoid',
'collapsable', 'collapsable'
'isPage',
'pinnedStatusIdsObject',
'inProfile',
'profileUserId',
'virtualHidden'
], ],
created () {
if (this.isPage) {
this.fetchConversation()
}
},
computed: { 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
},
status () { status () {
return this.$store.state.statuses.allStatusesObject[this.statusId] return this.statusoid
}, },
originalStatusId () { statusId () {
if (this.status.retweeted_status) { if (this.statusoid.retweeted_status) {
return this.status.retweeted_status.id return this.statusoid.retweeted_status.id
} else { } else {
return this.statusId return this.statusoid.id
} }
}, },
conversationId () {
return this.getConversationId(this.statusId)
},
conversation () { conversation () {
if (!this.status) { if (!this.status) {
return [] return []
} }
if (!this.isExpanded) { const conversationId = this.status.statusnet_conversation_id
return [this.status] const statuses = this.$store.state.statuses.allStatuses
} const conversation = filter(statuses, { statusnet_conversation_id: conversationId })
return sortAndFilterConversation(conversation)
const conversation = clone(this.$store.state.statuses.conversationsObject[this.conversationId])
const statusIndex = findIndex(conversation, { id: this.originalStatusId })
if (statusIndex !== -1) {
conversation[statusIndex] = this.status
}
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 () { replies () {
let i = 1 let i = 1
// eslint-disable-next-line camelcase return reduce(this.conversation, (result, {id, in_reply_to_status_id}) => {
return reduce(this.conversation, (result, { id, in_reply_to_status_id }) => {
/* eslint-disable camelcase */ /* eslint-disable camelcase */
const irid = in_reply_to_status_id const irid = in_reply_to_status_id
/* eslint-enable camelcase */ /* eslint-enable camelcase */
@ -277,260 +69,39 @@ const conversation = {
i++ i++
return result return result
}, {}) }, {})
},
isExpanded () {
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: { components: {
Status, Status
ThreadTree },
created () {
this.fetchConversation()
}, },
watch: { watch: {
statusId (newVal, oldVal) { '$route': 'fetchConversation'
const newConversationId = this.getConversationId(newVal)
const oldConversationId = this.getConversationId(oldVal)
if (newConversationId && oldConversationId && newConversationId === oldConversationId) {
this.setHighlight(this.originalStatusId)
} else {
this.fetchConversation()
}
},
expanded (value) {
if (value) {
this.fetchConversation()
} else {
this.resetDisplayState()
}
},
virtualHidden (value) {
this.$store.dispatch(
'setVirtualHeight',
{ statusId: this.statusId, height: `${this.$el.clientHeight}px` }
)
}
}, },
methods: { methods: {
fetchConversation () { fetchConversation () {
if (this.status) { if (this.status) {
this.$store.state.api.backendInteractor.fetchConversation({ id: this.statusId }) const conversationId = this.status.statusnet_conversation_id
.then(({ ancestors, descendants }) => { this.$store.state.api.backendInteractor.fetchConversation({id: conversationId})
this.$store.dispatch('addNewStatuses', { statuses: ancestors }) .then((statuses) => this.$store.dispatch('addNewStatuses', { statuses }))
this.$store.dispatch('addNewStatuses', { statuses: descendants }) .then(() => this.setHighlight(this.statusId))
this.setHighlight(this.originalStatusId)
})
} else { } else {
this.$store.state.api.backendInteractor.fetchStatus({ id: this.statusId }) const id = this.$route.params.id
.then((status) => { this.$store.state.api.backendInteractor.fetchStatus({id})
this.$store.dispatch('addNewStatuses', { statuses: [status] }) .then((status) => this.$store.dispatch('addNewStatuses', { statuses: [status] }))
this.fetchConversation() .then(() => this.fetchConversation())
})
} }
}, },
getReplies (id) { getReplies (id) {
return this.replies[id] || [] return this.replies[id] || []
}, },
getHighlight () { focused (id) {
return this.isExpanded ? this.highlight : null return id === this.statusId
}, },
setHighlight (id) { setHighlight (id) {
if (!id) return
this.highlight = id this.highlight = id
this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
},
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

@ -1,274 +1,26 @@
<template> <template>
<div <div class="timeline panel panel-default">
v-if="!hideStatus" <div class="panel-heading conversation-heading">
:style="hiddenStyle"
class="Conversation"
:class="{ '-expanded' : isExpanded, 'panel' : isExpanded }"
>
<div
v-if="isExpanded"
class="panel-heading conversation-heading"
>
<span class="title"> {{ $t('timeline.conversation') }} </span> <span class="title"> {{ $t('timeline.conversation') }} </span>
<button <span v-if="collapsable">
v-if="collapsable" <a href="#" @click.prevent="$emit('toggleExpanded')">{{ $t('timeline.collapse') }}</a>
class="button-unstyled -link" </span>
@click.prevent="toggleExpanded"
>
{{ $t('timeline.collapse') }}
</button>
</div> </div>
<div class="conversation-body panel-body"> <div class="panel-body">
<div <div class="timeline">
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 <status
v-for="status in conversation" v-for="status in conversation"
:key="status.id" @goto="setHighlight" :key="status.id"
ref="statusComponent" :inlineExpanded="collapsable" :statusoid="status"
:inline-expanded="collapsable && isExpanded" :expandable='false' :focused="focused(status.id)"
:statusoid="status" :inConversation='true'
:expandable="!isExpanded" :highlight="highlight"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="focused(status.id)"
:in-conversation="isExpanded"
:highlight="getHighlight()"
:replies="getReplies(status.id)" :replies="getReplies(status.id)"
:in-profile="inProfile" class="status-fadein">
:profile-user-id="profileUserId" </status>
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> </div>
</div> </div>
</div> </div>
<div
v-else
:style="hiddenStyle"
/>
</template> </template>
<script src="./conversation.js"></script> <script src="./conversation.js"></script>
<style lang="scss">
@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-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;
}
.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);
}
}
</style>

View file

@ -0,0 +1,17 @@
const DeleteButton = {
props: [ 'status' ],
methods: {
deleteStatus () {
const confirmed = window.confirm('Do you really want to delete this status?')
if (confirmed) {
this.$store.dispatch('deleteStatus', { id: this.status.id })
}
}
},
computed: {
currentUser () { return this.$store.state.users.currentUser },
canDelete () { return this.currentUser && this.currentUser.rights.delete_others_notice || this.status.user.id === this.currentUser.id }
}
}
export default DeleteButton

View file

@ -0,0 +1,21 @@
<template>
<div v-if="canDelete">
<a href="#" v-on:click.prevent="deleteStatus()">
<i class='button-icon icon-cancel delete-status'></i>
</a>
</div>
</template>
<script src="./delete_button.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
.icon-cancel,.delete-status {
cursor: pointer;
&:hover {
color: $fallback--cRed;
color: var(--cRed, $fallback--cRed);
}
}
</style>

View file

@ -1,89 +0,0 @@
import SearchBar from 'components/search_bar/search_bar.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSignInAlt,
faSignOutAlt,
faHome,
faComments,
faBell,
faUserPlus,
faBullhorn,
faSearch,
faTachometerAlt,
faCog,
faInfoCircle
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSignInAlt,
faSignOutAlt,
faHome,
faComments,
faBell,
faUserPlus,
faBullhorn,
faSearch,
faTachometerAlt,
faCog,
faInfoCircle
)
export default {
components: {
SearchBar
},
data: () => ({
searchBarHidden: true,
supportsMask: window.CSS && window.CSS.supports && (
window.CSS.supports('mask-size', 'contain') ||
window.CSS.supports('-webkit-mask-size', 'contain') ||
window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain')
)
}),
computed: {
enableMask () { return this.supportsMask && this.$store.state.instance.logoMask },
logoStyle () {
return {
'visibility': this.enableMask ? 'hidden' : 'visible'
}
},
logoMaskStyle () {
return this.enableMask ? {
'mask-image': `url(${this.$store.state.instance.logo})`
} : {
'background-color': this.enableMask ? '' : 'transparent'
}
},
logoBgStyle () {
return Object.assign({
'margin': `${this.$store.state.instance.logoMargin} 0`,
opacity: this.searchBarHidden ? 1 : 0
}, this.enableMask ? {} : {
'background-color': this.enableMask ? '' : 'transparent'
})
},
logo () { return this.$store.state.instance.logo },
sitename () { return this.$store.state.instance.name },
hideSitename () { return this.$store.state.instance.hideSitename },
logoLeft () { return this.$store.state.instance.logoLeft },
currentUser () { return this.$store.state.users.currentUser },
privateMode () { return this.$store.state.instance.private }
},
methods: {
scrollToTop () {
window.scrollTo(0, 0)
},
logout () {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
},
onSearchBarToggled (hidden) {
this.searchBarHidden = hidden
},
openSettingsModal () {
this.$store.dispatch('openSettingsModal')
}
}
}

View file

@ -1,117 +0,0 @@
@import '../../_variables.scss';
.DesktopNav {
height: 50px;
width: 100%;
position: fixed;
a {
color: var(--topBarLink, $fallback--link);
}
.inner-nav {
display: grid;
grid-template-rows: 50px;
grid-template-columns: 2fr auto 2fr;
grid-template-areas: "sitename logo actions";
box-sizing: border-box;
padding: 0 1.2em;
margin: auto;
max-width: 980px;
}
&.-logoLeft {
grid-template-columns: auto 2fr 2fr;
grid-template-areas: "logo sitename actions";
}
.button-default {
&, svg {
color: $fallback--text;
color: var(--btnTopBarText, $fallback--text);
}
&:active {
background-color: $fallback--fg;
background-color: var(--btnPressedTopBar, $fallback--fg);
color: $fallback--text;
color: var(--btnPressedTopBarText, $fallback--text);
}
&:disabled {
color: $fallback--text;
color: var(--btnDisabledTopBarText, $fallback--text);
}
&.toggled {
color: $fallback--text;
color: var(--btnToggledTopBarText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--btnToggledTopBar, $fallback--fg)
}
}
.logo {
grid-area: logo;
position: relative;
transition: opacity;
transition-timing-function: ease-out;
transition-duration: 100ms;
@media all and (min-width: 800px) {
opacity: 1 !important;
}
.mask {
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
background-color: $fallback--fg;
background-color: var(--topBarText, $fallback--fg);
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
img {
display: inline-block;
height: 50px;
}
}
.nav-icon {
margin-left: 0.2em;
width: 2em;
height: 100%;
text-align: center;
.svg-inline--fa {
color: $fallback--link;
color: var(--topBarLink, $fallback--link);
}
}
.sitename {
grid-area: sitename;
}
.actions {
grid-area: actions;
}
.item {
flex: 1;
line-height: 50px;
height: 50px;
overflow: hidden;
display: flex;
flex-wrap: wrap;
&.right {
justify-content: flex-end;
text-align: right;
}
}
}

View file

@ -1,82 +0,0 @@
<template>
<nav
id="nav"
class="DesktopNav"
:class="{ '-logoLeft': logoLeft }"
@click="scrollToTop()"
>
<div class="inner-nav">
<div class="item sitename">
<router-link
v-if="!hideSitename"
class="site-name"
:to="{ name: 'root' }"
active-class="home"
>
{{ sitename }}
</router-link>
</div>
<router-link
class="logo"
:to="{ name: 'root' }"
:style="logoBgStyle"
>
<div
class="mask"
:style="logoMaskStyle"
/>
<img
:src="logo"
:style="logoStyle"
>
</router-link>
<div class="item right actions">
<search-bar
v-if="currentUser || !privateMode"
@toggled="onSearchBarToggled"
@click.stop
/>
<button
class="button-unstyled nav-icon"
@click.stop="openSettingsModal"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="cog"
:title="$t('nav.preferences')"
/>
</button>
<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
v-if="currentUser"
class="button-unstyled nav-icon"
@click.prevent="logout"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="sign-out-alt"
:title="$t('login.logout')"
/>
</button>
</div>
</div>
</nav>
</template>
<script src="./desktop_nav.js"></script>
<style src="./desktop_nav.scss" lang="scss"></style>

View file

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

View file

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

View file

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

View file

@ -1,26 +0,0 @@
import ProgressButton from '../progress_button/progress_button.vue'
const DomainMuteCard = {
props: ['domain'],
components: {
ProgressButton
},
computed: {
user () {
return this.$store.state.users.currentUser
},
muted () {
return this.user.domainMutes.includes(this.domain)
}
},
methods: {
unmuteDomain () {
return this.$store.dispatch('unmuteDomain', this.domain)
},
muteDomain () {
return this.$store.dispatch('muteDomain', this.domain)
}
}
}
export default DomainMuteCard

View file

@ -1,53 +0,0 @@
<template>
<div class="domain-mute-card">
<div class="domain-mute-card-domain">
{{ domain }}
</div>
<ProgressButton
v-if="muted"
:click="unmuteDomain"
class="btn button-default"
>
{{ $t('domain_mute_card.unmute') }}
<template v-slot:progress>
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-else
:click="muteDomain"
class="btn button-default"
>
{{ $t('domain_mute_card.mute') }}
<template v-slot:progress>
{{ $t('domain_mute_card.mute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<script src="./domain_mute_card.js"></script>
<style lang="scss">
.domain-mute-card {
flex: 1 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6em 1em 0.6em 0;
&-domain {
margin-right: 1em;
overflow: hidden;
text-overflow: ellipsis;
}
button {
width: 10em;
}
.autosuggest-results & {
padding-left: 1em;
}
}
</style>

View file

@ -1,509 +0,0 @@
import Completion from '../../services/completion/completion.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSmileBeam
} from '@fortawesome/free-regular-svg-icons'
library.add(
faSmileBeam
)
/**
* EmojiInput - augmented inputs for emoji and autocomplete support in inputs
* without having to give up the comfort of <input/> and <textarea/> elements
*
* Intended usage is:
* <EmojiInput v-model="something">
* <input v-model="something"/>
* </EmojiInput>
*
* Works only with <input> and <textarea>. Intended to use with only one nested
* input. It will find first input or textarea and work with that, multiple
* nested children not tested. You HAVE TO duplicate v-model for both
* <emoji-input> and <input>/<textarea> otherwise it will not work.
*
* Be prepared for CSS troubles though because it still wraps component in a div
* while TRYING to make it look like nothing happened, but it could break stuff.
*/
const EmojiInput = {
emits: ['update:modelValue', 'shown'],
props: {
suggest: {
/**
* suggest: function (input: String) => Suggestion[]
*
* Function that takes input string which takes string (textAtCaret)
* and returns an array of Suggestions
*
* Suggestion is an object containing following properties:
* displayText: string. Main display text, what actual suggestion
* represents (user's screen name/emoji shortcode)
* replacement: string. Text that should replace the textAtCaret
* detailText: string, optional. Subtitle text, providing additional info
* if present (user's nickname)
* imageUrl: string, optional. Image to display alongside with suggestion,
* currently if no image is provided, replacement will be used (for
* unicode emojis)
*
* TODO: make it asynchronous when adding proper server-provided user
* suggestions
*
* For commonly used suggestors (emoji, users, both) use suggestor.js
*/
required: true,
type: Function
},
modelValue: {
/**
* Used for v-model
*/
required: true,
type: String
},
enableEmojiPicker: {
/**
* Enables emoji picker support, this implies that custom emoji are supported
*/
required: false,
type: Boolean,
default: false
},
hideEmojiButton: {
/**
* intended to use with external picker trigger, i.e. you have a button outside
* input that will open up the picker, see triggerShowPicker()
*/
required: false,
type: Boolean,
default: false
},
enableStickerPicker: {
/**
* Enables sticker picker support, only makes sense when enableEmojiPicker=true
*/
required: false,
type: Boolean,
default: false
},
placement: {
/**
* Forces the panel to take a specific position relative to the input element.
* The 'auto' placement chooses either bottom or top depending on which has the available space (when both have available space, bottom is preferred).
*/
required: false,
type: String, // 'auto', 'top', 'bottom'
default: 'auto'
},
newlineOnCtrlEnter: {
required: false,
type: Boolean,
default: false
}
},
data () {
return {
input: undefined,
highlighted: 0,
caret: 0,
focused: false,
blurTimeout: null,
showPicker: false,
temporarilyHideSuggestions: false,
keepOpen: false,
disableClickOutside: false,
suggestions: []
}
},
components: {
EmojiPicker
},
computed: {
padEmoji () {
return this.$store.getters.mergedConfig.padEmoji
},
showSuggestions () {
return this.focused &&
this.suggestions &&
this.suggestions.length > 0 &&
!this.showPicker &&
!this.temporarilyHideSuggestions
},
textAtCaret () {
return (this.wordAtCaret || {}).word || ''
},
wordAtCaret () {
if (this.modelValue && this.caret) {
const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
return word
}
}
},
mounted () {
const { root } = this.$refs
const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea')
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)
},
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)
}
},
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
// from outside, thus preventing picker from opening
this.disableClickOutside = true
setTimeout(() => {
this.disableClickOutside = false
}, 0)
},
togglePicker () {
this.input.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)
this.caret = 0
},
insert ({ insertion, keepOpen, surroundingSpace = true }) {
const before = this.modelValue.substring(0, this.caret) || ''
const after = this.modelValue.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
* are at the beginning of post or in spam mode
* - put a space after emoji if there isn't one already unless we are
* in spam mode
*
* The idea is that when you put a cursor somewhere in between sentence
* inserting just ' :emoji: ' will add more spaces to post which might
* break the flow/spacing, as well as the case where user ends sentence
* with a space before adding emoji.
*
* Spam mode is intended for creating multi-part emojis and overall spamming
* them, masto seem to be rendering :emoji::emoji: correctly now so why not
*/
const isSpaceRegex = /\s/
const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : ''
const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : ''
const newValue = [
before,
spaceBefore,
insertion,
spaceAfter,
after
].join('')
this.keepOpen = keepOpen
this.$emit('update:modelValue', newValue)
const position = this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) {
this.input.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.caret = position
})
},
replaceText (e, suggestion) {
const len = this.suggestions.length || 0
if (this.textAtCaret.length === 1) { return }
if (len > 0 || suggestion) {
const chosenSuggestion = suggestion || this.suggestions[this.highlighted]
const replacement = chosenSuggestion.replacement
const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement)
this.$emit('update:modelValue', newValue)
this.highlighted = 0
const position = this.wordAtCaret.start + replacement.length
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
this.input.focus()
// Set selection right after the replacement instead of the very end
this.input.setSelectionRange(position, position)
this.caret = position
})
e.preventDefault()
}
},
cycleBackward (e) {
const len = this.suggestions.length || 0
if (len > 1) {
this.highlighted -= 1
if (this.highlighted < 0) {
this.highlighted = this.suggestions.length - 1
}
e.preventDefault()
} else {
this.highlighted = 0
}
},
cycleForward (e) {
const len = this.suggestions.length || 0
if (len > 1) {
this.highlighted += 1
if (this.highlighted >= len) {
this.highlighted = 0
}
e.preventDefault()
} else {
this.highlighted = 0
}
},
scrollIntoView () {
const rootRef = this.$refs['picker'].$el
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
*/
const scrollerRef = this.$el.closest('.sidebar-scroller') ||
this.$el.closest('.post-form-modal-view') ||
window
const currentScroll = scrollerRef === window
? scrollerRef.scrollY
: scrollerRef.scrollTop
const scrollerHeight = scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// We check where the bottom border of root element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const rootBottomBorder = rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top
const bottomDelta = Math.max(0, rootBottomBorder - scrollerBottomBorder)
// could also check top delta but there's no case for it
const targetScroll = currentScroll + bottomDelta
if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll)
} else {
scrollerRef.scrollTop = targetScroll
}
this.$nextTick(() => {
const { offsetHeight } = this.input
const { picker } = this.$refs
const pickerBottom = picker.$el.getBoundingClientRect().bottom
if (pickerBottom > window.innerHeight) {
picker.$el.style.top = 'auto'
picker.$el.style.bottom = offsetHeight + 'px'
}
})
},
onTransition (e) {
this.resize()
},
onBlur (e) {
// Clicking on any suggestion removes focus from autocomplete,
// preventing click handler ever executing.
this.blurTimeout = setTimeout(() => {
this.focused = false
this.setCaret(e)
this.resize()
}, 200)
},
onClick (e, suggestion) {
this.replaceText(e, suggestion)
},
onFocus (e) {
if (this.blurTimeout) {
clearTimeout(this.blurTimeout)
this.blurTimeout = null
}
if (!this.keepOpen) {
this.showPicker = false
}
this.focused = true
this.setCaret(e)
this.resize()
this.temporarilyHideSuggestions = false
},
onKeyUp (e) {
const { key } = e
this.setCaret(e)
this.resize()
// Setting hider in keyUp to prevent suggestions from blinking
// when moving away from suggested spot
if (key === 'Escape') {
this.temporarilyHideSuggestions = true
} else {
this.temporarilyHideSuggestions = false
}
},
onPaste (e) {
this.setCaret(e)
this.resize()
},
onKeyDown (e) {
const { ctrlKey, shiftKey, key } = e
if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') {
this.insert({ insertion: '\n', surroundingSpace: false })
// Ensure only one new line is added on macos
e.stopPropagation()
e.preventDefault()
// Scroll the input element to the position of the cursor
this.$nextTick(() => {
this.input.blur()
this.input.focus()
})
}
// Disable suggestions hotkeys if suggestions are hidden
if (!this.temporarilyHideSuggestions) {
if (key === 'Tab') {
if (shiftKey) {
this.cycleBackward(e)
} else {
this.cycleForward(e)
}
}
if (key === 'ArrowUp') {
this.cycleBackward(e)
} else if (key === 'ArrowDown') {
this.cycleForward(e)
}
if (key === 'Enter') {
if (!ctrlKey) {
this.replaceText(e)
}
}
}
// Probably add optional keyboard controls for emoji picker?
// Escape hides suggestions, if suggestions are hidden it
// de-focuses the element (i.e. default browser behavior)
if (key === 'Escape') {
if (!this.temporarilyHideSuggestions) {
this.input.focus()
}
}
this.showPicker = false
this.resize()
},
onInput (e) {
this.showPicker = false
this.setCaret(e)
this.resize()
this.$emit('update:modelValue', e.target.value)
},
onClickInput (e) {
this.showPicker = false
},
onClickOutside (e) {
if (this.disableClickOutside) return
this.showPicker = false
},
onStickerUploaded (e) {
this.showPicker = false
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed (e) {
this.showPicker = false
this.$emit('sticker-upload-Failed', e)
},
setCaret ({ target: { selectionStart } }) {
this.caret = selectionStart
},
resize () {
const panel = this.$refs.panel
if (!panel) return
const picker = this.$refs.picker.$el
const panelBody = this.$refs['panel-body']
const { offsetHeight, offsetTop } = this.input
const offsetBottom = offsetTop + offsetHeight
this.setPlacement(panelBody, panel, offsetBottom)
this.setPlacement(picker, picker, offsetBottom)
},
setPlacement (container, target, offsetBottom) {
if (!container || !target) return
target.style.top = offsetBottom + 'px'
target.style.bottom = 'auto'
if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) {
target.style.top = 'auto'
target.style.bottom = this.input.offsetHeight + 'px'
}
},
overflowsBottom (el) {
return el.getBoundingClientRect().bottom > window.innerHeight
}
}
}
export default EmojiInput

View file

@ -1,185 +0,0 @@
<template>
<div
ref="root"
v-click-outside="onClickOutside"
class="emoji-input"
:class="{ 'with-picker': !hideEmojiButton }"
>
<slot />
<template v-if="enableEmojiPicker">
<button
v-if="!hideEmojiButton"
class="button-unstyled emoji-picker-icon"
type="button"
@click.prevent="togglePicker"
>
<FAIcon :icon="['far', 'smile-beam']" />
</button>
<EmojiPicker
v-if="enableEmojiPicker"
ref="picker"
:class="{ hide: !showPicker }"
:enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel"
@emoji="insert"
@sticker-uploaded="onStickerUploaded"
@sticker-upload-failed="onStickerUploadFailed"
/>
</template>
<div
ref="panel"
class="autocomplete-panel"
:class="{ hide: !showSuggestions }"
>
<div
ref="panel-body"
class="autocomplete-panel-body"
>
<div
v-for="(suggestion, index) in suggestions"
:key="index"
class="autocomplete-item"
:class="{ highlighted: index === highlighted }"
@click.stop.prevent="onClick($event, suggestion)"
>
<span class="image">
<img
v-if="suggestion.img"
:src="suggestion.img"
>
<span v-else>{{ suggestion.replacement }}</span>
</span>
<div class="label">
<span class="displayText">{{ suggestion.displayText }}</span>
<span class="detailText">{{ suggestion.detailText }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script src="./emoji_input.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.emoji-input {
display: flex;
flex-direction: column;
position: relative;
&.with-picker input {
padding-right: 30px;
}
.emoji-picker-icon {
position: absolute;
top: 0;
right: 0;
margin: .2em .25em;
font-size: 16px;
cursor: pointer;
line-height: 24px;
&:hover i {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
.emoji-picker-panel {
position: absolute;
z-index: 20;
margin-top: 2px;
&.hide {
display: none
}
}
.autocomplete {
&-panel {
position: absolute;
z-index: 20;
margin-top: 2px;
&.hide {
display: none
}
&-body {
margin: 0 0.5em 0 0.5em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
box-shadow: var(--popupShadow);
min-width: 75%;
background-color: $fallback--bg;
background-color: var(--popover, $fallback--bg);
color: $fallback--link;
color: var(--popoverText, $fallback--link);
--faint: var(--popoverFaintText, $fallback--faint);
--faintLink: var(--popoverFaintLink, $fallback--faint);
--lightText: var(--popoverLightText, $fallback--lightText);
--postLink: var(--popoverPostLink, $fallback--link);
--postFaintLink: var(--popoverPostFaintLink, $fallback--link);
--icon: var(--popoverIcon, $fallback--icon);
}
}
&-item {
display: flex;
cursor: pointer;
padding: 0.2em 0.4em;
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
height: 32px;
.image {
width: 32px;
height: 32px;
line-height: 32px;
text-align: center;
font-size: 32px;
margin-right: 4px;
img {
width: 32px;
height: 32px;
object-fit: contain;
}
}
.label {
display: flex;
flex-direction: column;
justify-content: center;
margin: 0 0.1em 0 0.2em;
.displayText {
line-height: 1.5;
}
.detailText {
font-size: 9px;
line-height: 9px;
}
}
&.highlighted {
background-color: $fallback--fg;
background-color: var(--selectedMenuPopover, $fallback--fg);
color: var(--selectedMenuPopoverText, $fallback--text);
--faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
--faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
--lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
--icon: var(--selectedMenuPopoverIcon, $fallback--icon);
}
}
}
input, textarea {
flex: 1 0 auto;
}
}
</style>

View file

@ -1,130 +0,0 @@
/**
* suggest - generates a suggestor function to be used by emoji-input
* data: object providing source information for specific types of suggestions:
* data.emoji - optional, an array of all emoji available i.e.
* (state.instance.emoji + state.instance.customEmoji)
* data.users - optional, an array of all known users
* updateUsersList - optional, a function to search and append to users
*
* Depending on data present one or both (or none) can be present, so if field
* doesn't support user linking you can just provide only emoji.
*/
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 []
}
}
export const suggestEmoji = emojis => input => {
const noPrefix = input.toLowerCase().substr(1)
return emojis
.filter(({ displayText }) => displayText.toLowerCase().match(noPrefix))
.sort((a, b) => {
let aScore = 0
let bScore = 0
// An exact match always wins
aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0
bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0
// Prioritize custom emoji a lot
aScore += a.imageUrl ? 100 : 0
bScore += b.imageUrl ? 100 : 0
// Prioritize prefix matches somewhat
aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
// Sort by length
aScore -= a.displayText.length
bScore -= b.displayText.length
// Break ties alphabetically
const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5
return bScore - aScore + alphabetically
})
}
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
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
}
}

View file

@ -1,213 +0,0 @@
import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBoxOpen,
faStickyNote,
faSmileBeam
} from '@fortawesome/free-solid-svg-icons'
library.add(
faBoxOpen,
faStickyNote,
faSmileBeam
)
// At widest, approximately 20 emoji are visible in a row,
// loading 3 rows, could be overkill for narrow picker
const LOAD_EMOJI_BY = 60
// When to start loading new batch emoji, in pixels
const LOAD_EMOJI_MARGIN = 64
const filterByKeyword = (list, keyword = '') => {
if (keyword === '') return list
const keywordLowercase = keyword.toLowerCase()
let orderedEmojiList = []
for (const emoji of list) {
const indexOfKeyword = emoji.displayText.toLowerCase().indexOf(keywordLowercase)
if (indexOfKeyword > -1) {
if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
orderedEmojiList[indexOfKeyword] = []
}
orderedEmojiList[indexOfKeyword].push(emoji)
}
}
return orderedEmojiList.flat()
}
const EmojiPicker = {
props: {
enableStickerPicker: {
required: false,
type: Boolean,
default: false
}
},
data () {
return {
keyword: '',
activeGroup: 'custom',
showingStickers: false,
groupsScrolledClass: 'scrolled-top',
keepOpen: false,
customEmojiBufferSlice: LOAD_EMOJI_BY,
customEmojiTimeout: null,
customEmojiLoadAllConfirmed: false
}
},
components: {
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
Checkbox
},
methods: {
onStickerUploaded (e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed (e) {
this.$emit('sticker-upload-failed', e)
},
onEmoji (emoji) {
const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
},
onScroll (e) {
const target = (e && e.target) || this.$refs['emoji-groups']
this.updateScrolledClass(target)
this.scrolledGroup(target)
this.triggerLoadMore(target)
},
highlight (key) {
const ref = this.$refs['group-' + key]
const top = ref.offsetTop
this.setShowStickers(false)
this.activeGroup = key
this.$nextTick(() => {
this.$refs['emoji-groups'].scrollTop = top + 1
})
},
updateScrolledClass (target) {
if (target.scrollTop <= 5) {
this.groupsScrolledClass = 'scrolled-top'
} else if (target.scrollTop >= target.scrollTopMax - 5) {
this.groupsScrolledClass = 'scrolled-bottom'
} else {
this.groupsScrolledClass = 'scrolled-middle'
}
},
triggerLoadMore (target) {
const ref = this.$refs['group-end-custom']
if (!ref) return
const bottom = ref.offsetTop + ref.offsetHeight
const scrollerBottom = target.scrollTop + target.clientHeight
const scrollerTop = target.scrollTop
const scrollerMax = target.scrollHeight
// Loads more emoji when they come into view
const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN
// Always load when at the very top in case there's no scroll space yet
const atTop = scrollerTop < 5
// Don't load when looking at unicode category or at the very bottom
const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax
if (!bottomAboveViewport && (approachingBottom || atTop)) {
this.loadEmoji()
}
},
scrolledGroup (target) {
const top = target.scrollTop + 5
this.$nextTick(() => {
this.emojisView.forEach(group => {
const ref = this.$refs['group-' + group.id]
if (ref.offsetTop <= top) {
this.activeGroup = group.id
}
})
})
},
loadEmoji () {
const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length
if (allLoaded) {
return
}
this.customEmojiBufferSlice += LOAD_EMOJI_BY
},
startEmojiLoad (forceUpdate = false) {
if (!forceUpdate) {
this.keyword = ''
}
this.$nextTick(() => {
this.$refs['emoji-groups'].scrollTop = 0
})
const bufferSize = this.customEmojiBuffer.length
const bufferPrefilledAll = bufferSize === this.filteredEmoji.length
if (bufferPrefilledAll && !forceUpdate) {
return
}
this.customEmojiBufferSlice = LOAD_EMOJI_BY
},
toggleStickers () {
this.showingStickers = !this.showingStickers
},
setShowStickers (value) {
this.showingStickers = value
}
},
watch: {
keyword () {
this.customEmojiLoadAllConfirmed = false
this.onScroll()
this.startEmojiLoad(true)
}
},
computed: {
activeGroupView () {
return this.showingStickers ? '' : this.activeGroup
},
stickersAvailable () {
if (this.$store.state.instance.stickers) {
return this.$store.state.instance.stickers.length > 0
}
return 0
},
filteredEmoji () {
return filterByKeyword(
this.$store.state.instance.customEmoji || [],
this.keyword
)
},
customEmojiBuffer () {
return this.filteredEmoji.slice(0, this.customEmojiBufferSlice)
},
emojis () {
const standardEmojis = this.$store.state.instance.emoji || []
const customEmojis = this.customEmojiBuffer
return [
{
id: 'custom',
text: this.$t('emoji.custom'),
icon: 'smile-beam',
emojis: customEmojis
},
{
id: 'standard',
text: this.$t('emoji.unicode'),
icon: 'box-open',
emojis: filterByKeyword(standardEmojis, this.keyword)
}
]
},
emojisView () {
return this.emojis.filter(value => value.emojis.length > 0)
},
stickerPickerEnabled () {
return (this.$store.state.instance.stickers || []).length !== 0
}
}
}
export default EmojiPicker

View file

@ -1,184 +0,0 @@
@import '../../_variables.scss';
.emoji-picker {
display: flex;
flex-direction: column;
position: absolute;
right: 0;
left: 0;
margin: 0 !important;
z-index: 1;
background-color: $fallback--bg;
background-color: var(--popover, $fallback--bg);
color: $fallback--link;
color: var(--popoverText, $fallback--link);
--lightText: var(--popoverLightText, $fallback--faint);
--faint: var(--popoverFaintText, $fallback--faint);
--faintLink: var(--popoverFaintLink, $fallback--faint);
--lightText: var(--popoverLightText, $fallback--lightText);
--icon: var(--popoverIcon, $fallback--icon);
.keep-open,
.too-many-emoji {
padding: 7px;
line-height: normal;
}
.too-many-emoji {
display: flex;
flex-direction: column;
}
.keep-open-label {
padding: 0 7px;
display: flex;
}
.heading {
display: flex;
height: 32px;
padding: 10px 7px 5px;
}
.content {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0px;
}
.emoji-tabs {
flex-grow: 1;
}
.emoji-groups {
min-height: 200px;
}
.additional-tabs {
border-left: 1px solid;
border-left-color: $fallback--icon;
border-left-color: var(--icon, $fallback--icon);
padding-left: 7px;
flex: 0 0 auto;
}
.additional-tabs,
.emoji-tabs {
display: block;
min-width: 0;
flex-basis: auto;
flex-shrink: 1;
&-item {
padding: 0 7px;
cursor: pointer;
font-size: 24px;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&.active {
border-bottom: 4px solid;
svg {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
}
}
}
.sticker-picker {
flex: 1 1 auto
}
.stickers,
.emoji {
&-content {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
&.hidden {
opacity: 0;
pointer-events: none;
position: absolute;
}
}
}
.emoji {
&-search {
padding: 5px;
flex: 0 0 auto;
input {
width: 100%;
}
}
&-groups {
flex: 1 1 1px;
position: relative;
overflow: auto;
user-select: none;
mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
linear-gradient(to top, white, white);
transition: mask-size 150ms;
mask-size: 100% 20px, 100% 20px, auto;
// Autoprefixed seem to ignore this one, and also syntax is different
-webkit-mask-composite: xor;
mask-composite: exclude;
&.scrolled {
&-top {
mask-size: 100% 20px, 100% 0, auto;
}
&-bottom {
mask-size: 100% 0, 100% 20px, auto;
}
}
}
&-group {
display: flex;
align-items: center;
flex-wrap: wrap;
padding-left: 5px;
justify-content: left;
&-title {
font-size: 12px;
width: 100%;
margin: 0;
&.disabled {
display: none;
}
}
}
&-item {
width: 32px;
height: 32px;
box-sizing: border-box;
display: flex;
font-size: 32px;
align-items: center;
justify-content: center;
margin: 4px;
cursor: pointer;
img {
object-fit: contain;
max-width: 100%;
max-height: 100%;
}
}
}
}

View file

@ -1,105 +0,0 @@
<template>
<div class="emoji-picker panel panel-default panel-body">
<div class="heading">
<span class="emoji-tabs">
<span
v-for="group in emojis"
:key="group.id"
class="emoji-tabs-item"
:class="{
active: activeGroupView === group.id,
disabled: group.emojis.length === 0
}"
:title="group.text"
@click.prevent="highlight(group.id)"
>
<FAIcon
:icon="group.icon"
fixed-width
/>
</span>
</span>
<span
v-if="stickerPickerEnabled"
class="additional-tabs"
>
<span
class="stickers-tab-icon additional-tabs-item"
:class="{active: showingStickers}"
:title="$t('emoji.stickers')"
@click.prevent="toggleStickers"
>
<FAIcon
icon="sticky-note"
fixed-width
/>
</span>
</span>
</div>
<div class="content">
<div
class="emoji-content"
:class="{hidden: showingStickers}"
>
<div class="emoji-search">
<input
v-model="keyword"
type="text"
class="form-control"
:placeholder="$t('emoji.search_emoji')"
>
</div>
<div
ref="emoji-groups"
class="emoji-groups"
:class="groupsScrolledClass"
@scroll="onScroll"
>
<div
v-for="group in emojisView"
:key="group.id"
class="emoji-group"
>
<h6
:ref="'group-' + group.id"
class="emoji-group-title"
>
{{ group.text }}
</h6>
<span
v-for="emoji in group.emojis"
:key="group.id + emoji.displayText"
:title="emoji.displayText"
class="emoji-item"
@click.stop.prevent="onEmoji(emoji)"
>
<span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span>
<img
v-else
:src="emoji.imageUrl"
>
</span>
<span :ref="'group-end-' + group.id" />
</div>
</div>
<div class="keep-open">
<Checkbox v-model="keepOpen">
{{ $t('emoji.keep_open') }}
</Checkbox>
</div>
</div>
<div
v-if="showingStickers"
class="stickers-content"
>
<sticker-picker
@uploaded="onStickerUploaded"
@upload-failed="onStickerUploadFailed"
/>
</div>
</div>
</div>
</template>
<script src="./emoji_picker.js"></script>
<style lang="scss" src="./emoji_picker.scss"></style>

View file

@ -1,69 +0,0 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserListPopover from '../user_list_popover/user_list_popover.vue'
const EMOJI_REACTION_COUNT_CUTOFF = 12
const EmojiReactions = {
name: 'EmojiReactions',
components: {
UserAvatar,
UserListPopover
},
props: ['status'],
data: () => ({
showAll: false
}),
computed: {
tooManyReactions () {
return this.status.emoji_reactions.length > EMOJI_REACTION_COUNT_CUTOFF
},
emojiReactions () {
return this.showAll
? this.status.emoji_reactions
: this.status.emoji_reactions.slice(0, EMOJI_REACTION_COUNT_CUTOFF)
},
showMoreString () {
return `+${this.status.emoji_reactions.length - EMOJI_REACTION_COUNT_CUTOFF}`
},
accountsForEmoji () {
return this.status.emoji_reactions.reduce((acc, reaction) => {
acc[reaction.name] = reaction.accounts || []
return acc
}, {})
},
loggedIn () {
return !!this.$store.state.users.currentUser
}
},
methods: {
toggleShowAll () {
this.showAll = !this.showAll
},
reactedWith (emoji) {
return this.status.emoji_reactions.find(r => r.name === emoji).me
},
fetchEmojiReactionsByIfMissing () {
const hasNoAccounts = this.status.emoji_reactions.find(r => !r.accounts)
if (hasNoAccounts) {
this.$store.dispatch('fetchEmojiReactionsBy', this.status.id)
}
},
reactWith (emoji) {
this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
},
unreact (emoji) {
this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
},
emojiOnClick (emoji, event) {
if (!this.loggedIn) return
if (this.reactedWith(emoji)) {
this.unreact(emoji)
} else {
this.reactWith(emoji)
}
}
}
}
export default EmojiReactions

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