Compare commits

..

6 commits

Author SHA1 Message Date
01c4aec4f3 run update first 2022-06-15 18:01:22 +01:00
93bca5f851 fix tests 2022-06-15 17:53:52 +01:00
079035670a update apt 2022-06-15 17:10:36 +01:00
c30e11139d specify node 2022-06-15 17:08:33 +01:00
c3da5db3dd rename CI file 2022-06-15 17:07:57 +01:00
c7e9ccd318 add woodpecker config 2022-06-15 17:05:15 +01:00
343 changed files with 31398 additions and 36815 deletions

View file

@ -1,17 +1,17 @@
module.exports = {
root: true,
parserOptions: {
parser: '@babel/eslint-parser',
parser: 'babel-eslint',
sourceType: 'module'
},
// https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style
extends: [
'standard',
'plugin:vue/recommended'
],
// required to lint *.vue files
plugins: [
'vue',
'import'
'vue'
],
// add your custom rules here
rules: {
@ -23,8 +23,6 @@ module.exports = {
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
'vue/require-prop-types': 0,
'vue/no-unused-vars': 0,
'no-tabs': 0,
'vue/multi-word-component-names': 0,
'vue/no-reserved-component-names': 0
'no-tabs': 0
}
}

View file

@ -1,49 +0,0 @@
name: "Bug report"
about: "Something isn't working as expected"
title: "[bug] "
body:
- type: markdown
attributes:
value: "Thanks for taking the time to file this bug report! Please try to be as specific and detailed as you can, so we can track down the issue and fix it as soon as possible."
- type: input
id: version
attributes:
label: "Version"
description: "Which version of pleroma-fe are you running? If running develop, specify the commit hash."
placeholder: "e.g. 2022.11, 40e86998e6"
- type: textarea
id: attempt
attributes:
label: "What were you trying to do?"
validations:
required: true
- type: textarea
id: expectation
attributes:
label: "What did you expect to happen?"
validations:
required: true
- type: textarea
id: reality
attributes:
label: "What actually happened?"
validations:
required: true
- type: dropdown
id: severity
attributes:
label: "Severity"
description: "Does this issue prevent you from using the software as normal?"
options:
- "I cannot use the software"
- "I cannot use it as easily as I'd like"
- "I can manage"
validations:
required: true
- type: checkboxes
id: searched
attributes:
label: "Have you searched for this issue?"
description: "Please double-check that your issue is not already being tracked on [the forums](https://meta.akkoma.dev) or [the issue tracker](https://akkoma.dev/AkkomaGang/pleroma-fe/issues)."
options:
- label: "I have double-checked and have not found this issue mentioned anywhere."

View file

@ -1,29 +0,0 @@
name: "Feature request"
about: "I'd like something to be added to pleroma-fe"
title: "[feat] "
body:
- type: markdown
attributes:
value: "Thanks for taking the time to request a new feature! Please be as concise and clear as you can in your proposal, so we could understand what you're going for."
- type: textarea
id: idea
attributes:
label: "The idea"
description: "What do you think you should be able to do in pleroma-fe?"
validations:
required: true
- type: textarea
id: reason
attributes:
label: "The reasoning"
description: "Why would this be a worthwhile feature? Does it solve any problems? Have people talked about wanting it?"
validations:
required: true
- type: checkboxes
id: searched
attributes:
label: "Have you searched for this feature request?"
description: "Please double-check that your issue is not already being tracked on [the forums](https://meta.akkoma.dev), [the issue tracker](https://akkoma.dev/AkkomaGang/pleroma-fe/issues), or the one for [the backend](https://akkoma.dev/AkkomaGang/akkoma/issues)."
options:
- label: "I have double-checked and have not found this feature request mentioned anywhere."
- label: "This feature is related to the pleroma-fe Akkoma frontend specifically, and not the backend."

2
.gitignore vendored
View file

@ -8,5 +8,3 @@ selenium-debug.log
.idea/
config/local.json
config/local.*.json
docs/site/
.vscode/

47
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,47 @@
# This file is a template, and might need editing before it works on your project.
# Official framework image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/node/tags/
image: node:12
stages:
- lint
- build
- test
- deploy
lint:
stage: lint
script:
- yarn
- npm run lint
- npm run stylelint
test:
stage: test
variables:
APT_CACHE_DIR: apt-cache
script:
- mkdir -pv $APT_CACHE_DIR && apt-get -qq update
- apt install firefox-esr -y --no-install-recommends
- firefox --version
- yarn
- yarn unit
build:
stage: build
script:
- yarn
- npm run build
artifacts:
paths:
- 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,13 +1,19 @@
{
"extends": [
"stylelint-config-recommended-vue/scss",
"stylelint-rscss/config",
"stylelint-config-recommended",
"stylelint-config-standard"
],
"customSyntax": "postcss-scss",
"rules": {
"declaration-no-important": true,
"selector-class-pattern": null,
"custom-property-pattern": null
"rscss/no-descendant-combinator": false,
"rscss/class-format": [
true,
{
"component": "pascal-case",
"variant": "^-[a-z]\\w+",
"element": "^[a-z]\\w+"
}
]
}
}

View file

@ -2,18 +2,18 @@ pipeline:
lint:
when:
event:
- pull_request
image: node:18
- push
image: node:16
commands:
- yarn
- yarn lint
#- yarn stylelint
- yarn stylelint
test:
when:
event:
- pull_request
image: node:18
- push
image: node:16
commands:
- apt update
- apt install firefox-esr -y --no-install-recommends
@ -23,11 +23,8 @@ pipeline:
build:
when:
event:
- push
branch:
- develop
- stable
image: node:18
- tag
image: node:16
commands:
- yarn
- yarn build
@ -35,11 +32,8 @@ pipeline:
release:
when:
event:
- push
branch:
- develop
- stable
image: node:18
- tag
image: node:16
secrets:
- SCW_ACCESS_KEY
- SCW_SECRET_KEY
@ -51,31 +45,4 @@ pipeline:
- chmod +x scaleway-cli
- ./scaleway-cli object config install type=rclone
- zip akkoma-fe.zip -r dist
- rclone copyto akkoma-fe.zip scaleway:akkoma-updates/frontend/$CI_COMMIT_BRANCH/akkoma-fe.zip
docs:
when:
event:
- push
branch:
- develop
- stable
environment:
CI: "true"
image: python:3.10-slim
secrets:
- SCW_ACCESS_KEY
- SCW_SECRET_KEY
- SCW_DEFAULT_ORGANIZATION_ID
commands:
- apt-get update && apt-get install -y rclone wget git zip
- wget https://github.com/scaleway/scaleway-cli/releases/download/v2.5.1/scaleway-cli_2.5.1_linux_amd64
- mv scaleway-cli_2.5.1_linux_amd64 scaleway-cli
- chmod +x scaleway-cli
- ./scaleway-cli object config install type=rclone
- cd docs
- pip install -r requirements.txt
- mkdocs build
- zip -r docs.zip site/*
- cd site
- rclone copy . scaleway:akkoma-docs/frontend/$CI_COMMIT_BRANCH/
- rclone copyto akkoma-fe.zip scaleway:akkoma-updates/frontend/akkoma-fe.zip

View file

@ -4,33 +4,6 @@ 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
### Added
- Implemented remote interaction with statuses
## 2022.09 - 2022-09-10
### Added
- Automatic post translations. Must be configured on the backend in order to work.
- Post editing, including a log of previous edits.
### Changed
- Top bar now has navigation shortcuts. Can be enabled or disabled by admins or users.
- Optional replacement of timeline drop-down with navigation buttons. Also configurable.
- Posts and posts with replies are now separated on user profiles.
- Custom emoji from remote instances on a post can now also be used.
## 2022.08 - 2022-08-12
### Added
- Ability to quote public and unlisted posts
- Bubble timeline
### Changed
- Emoji in emoji picker is separated by packs
### Removed
- Chats, they were half-baked. Just use PMs.
## 2022.07 - 2022-07-16
### 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)
@ -43,8 +16,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- 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
- Misskey-Flavoured Markdown support
- Custom emoji reactions
### Changed
- (You)s are optional (opt-in) now, bolding your nickname is also optional (opt-out)
@ -66,7 +37,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- 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
- Added frontend ui for account migration
## [2.4.2] - 2022-01-09

View file

@ -1,24 +0,0 @@
# Akkoma Code of Conduct
The Akkoma project aims to be **enjoyable** for anyone to participate in, regardless of their identity or level of expertise. To achieve this, the community must create an environment which is **safe** and **equitable**; the following guidelines have been created with these goals in mind.
1. **Treat individuals with respect.** Differing experiences and viewpoints deserve to be respected, and bigotry and harassment are not tolerated under any circumstances.
- Individuals should at all times be treated as equals, regardless of their age, gender, sexuality, race, ethnicity, _or any other characteristic_, intrinsic or otherwise.
- Behaviour that is harmful in nature should be addressed and corrected *regardless of intent*.
- Respect personal boundaries and ask for clarification whenever they are unclear.
- (Obviously, hate does not count as merely a "differing viewpoint", because it is harmful in nature.)
2. **Be understanding of differences in communication.** Not everyone is aware of unspoken social cues, and speech that is not intended to be offensive should not be treated as such simply due to an atypical manner of communication.
- Somebody who speaks bluntly is not necessarily rude, and somebody who swears a lot is not necessarily volatile.
- Try to confirm your interpretation of their intent rather than assuming bad faith.
- Someone may not communicate as, or come across as a picture of "professionalism", but this should not be seen as a reason to dismiss them. This is a **casual** space, and communication styles can reflect that.
3. **"Uncomfortable" does not mean "unsafe".** In an ideal world, the community would be safe, equitable, enjoyable, *and* comfortable for all members at all times. Unfortunately, this is not always possible in reality.
- Safety and equity will be prioritized over comfort whenever it is necessary to do so.
- Weaponizing one's own discomfort to deflect accountability or censor an individual (e.g. "white fragility") is a form of discriminatory conduct.
4. **Let people grow from their mistakes.** Nobody is perfect; even the most well-meaning individual can do something hurtful. Everyone should be given a fair opportunity to explain themselves and correct their behaviour. Portraying someone as inherently malicious prevents improvement and shifts focus away from the *action* that was problematic.
- Avoid bringing up past events that do not accurately reflect an individual's current actions or beliefs. (This is, of course, different from providing evidence of a recurring pattern of behaviour.)
---
This document was adapted from one created by ~keith as part of punks default repository template, and is licensed under CC-BY-SA 4.0. The original template is here: <https://bytes.keithhacks.cyou/keith/default-template>

49
COFE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,49 @@
```
o$$$$$$oo
o$" "$oo
$ o""""$o "$o
"$ o "o "o $
"$ $o $ $ o$
"$ o$"$ o$
"$ooooo$$ $ o$
o$ """ $ " $$$ " $
o$ $o $$" " "
$$ $ " $ $$$o"$ o o$"
$" o "" $ $" " o" $$
$o " " $ o$" o" o$"
"$o $$ $ o" o$$"
""o$o"$" $oo" o$"
o$$ $ $$$ o$$
o" o oo"" "" "$o
o$o" "" $
$" " o" " " " "o
$$ " " o$ o$o " $
o$ $ $ o$$ " " ""
o $ $" " "o o$
$ o $o$oo$""
$o $ o o o"$$
$o o $ $ "$o
$o $ o $ $ "o
$ $ "o $ "o"$o
$ " o $ o $$
$o$o$o$o$$o$$$o$$o$o$$o$$o$$$o$o$o$o$o$o$o$o$o$ooo
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$o
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ " $$$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ "$$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ $$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ $$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ $$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ o$$$$"
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ooooo$$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"""""
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"
"$o$o$o$o$o$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"
"$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"
"$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"""
"""""""""""""""""""""""""""""""""""""""""""""""""""""
```

View file

@ -10,5 +10,3 @@ Contributors of this project.
- shpuld (shpuld@shitposter.club): CSS and styling
- Vincent Guth (https://unsplash.com/photos/XrwVIFy6rTw): Background images.
- hj (hj@shigusegubu.club): Code
- Sean King (seanking@freespeechextremist.com): Code
- Tusooa Zhu (tusooa@kazv.moe): Code

View file

@ -1,22 +1,16 @@
# Akkoma-FE
# Pleroma-FE
![English OK](https://img.shields.io/badge/English-OK-blueviolet) ![日本語OK](https://img.shields.io/badge/%E6%97%A5%E6%9C%AC%E8%AA%9E-OK-blueviolet)
> A single column frontend designed for Pleroma.
This is a fork of Akkoma-FE from the Pleroma project, with support for new Akkoma features such as:
- MFM support via [marked-mfm](https://akkoma.dev/sfr/marked-mfm)
- Custom emoji reactions
![screenshot](/uploads/796c5ecf985ed1e2b0943ee0df131ed0/DJVqSJ0.png)
# For Translators
The [Weblate UI](https://translate.akkoma.dev/projects/akkoma/pleroma-fe/) is recommended for adding or modifying translations for Akkoma-FE.
Alternatively, edit/create `src/i18n/$LANGUAGE_CODE.json` (where `$LANGUAGE_CODE` is the [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) for your language), then add your language to [src/i18n/messages.js](https://akkoma.dev/AkkomaGang/pleroma-fe/src/branch/develop/src/i18n/messages.js) if it doesn't already exist there.
Akkoma-FE will set your language by your browser locale, but you can temporarily force it in the code by changing the locale in main.js.
To translate Pleroma-FE, add your language to [src/i18n/messages.js](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/i18n/messages.js). Pleroma-FE will set your language by your browser locale, but you can temporarily force it in the code by changing the locale in main.js.
# FOR ADMINS
To use Akkoma-FE in Akkoma, use the [frontend](https://docs.akkoma.dev/stable/administration/CLI_tasks/frontend/) CLI task to install Akkoma-FE, then modify your configuration as described in the [Frontend Management](https://docs.akkoma.dev/stable/configuration/frontend_management/) doc.
You don't need to build Pleroma-FE yourself. Those using the Pleroma backend will be able to use it out of the box.
## Build Setup

View file

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

View file

@ -2,6 +2,8 @@ var path = require('path')
var config = require('../config')
var utils = require('./utils')
var projectRoot = path.resolve(__dirname, '../')
var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin')
var CopyPlugin = require('copy-webpack-plugin');
var { VueLoaderPlugin } = require('vue-loader')
var env = process.env.NODE_ENV
@ -18,7 +20,6 @@ module.exports = {
app: './src/main.js'
},
output: {
hashFunction: "sha256", // Workaround for builds with OpenSSL 3.
path: config.build.assetsRoot,
publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath,
filename: '[name].js'
@ -29,13 +30,10 @@ module.exports = {
}
},
resolve: {
extensions: ['.js', '.jsx', '.vue', '.mjs'],
extensions: ['.js', '.jsx', '.vue'],
modules: [
path.join(__dirname, '../node_modules')
],
fallback: {
"url": require.resolve("url/"),
},
alias: {
'static': path.resolve(__dirname, '../static'),
'src': path.resolve(__dirname, '../src'),
@ -70,11 +68,6 @@ module.exports = {
path.resolve(__dirname, '../src/i18n')
]
},
{
test: /\.mjs$/,
include: /node_modules/,
type: "javascript/auto"
},
{
test: /\.vue$/,
loader: 'vue-loader',
@ -118,6 +111,23 @@ module.exports = {
]
},
plugins: [
new VueLoaderPlugin()
new ServiceWorkerWebpackPlugin({
entry: path.join(__dirname, '..', 'src/sw.js'),
filename: 'sw-pleroma.js'
}),
new VueLoaderPlugin(),
// This copies Ruffle's WASM to a directory so that JS side can access it
new CopyPlugin({
patterns: [
{
from: "node_modules/ruffle-mirror/*",
to: "static/ruffle",
flatten: true
},
],
options: {
concurrency: 100,
},
})
]
}

View file

@ -1,6 +1,6 @@
var config = require('../config')
var webpack = require('webpack')
var { merge } = require('webpack-merge')
var merge = require('webpack-merge')
var utils = require('./utils')
var baseWebpackConfig = require('./webpack.base.conf')
var HtmlWebpackPlugin = require('html-webpack-plugin')
@ -16,7 +16,7 @@ module.exports = merge(baseWebpackConfig, {
},
mode: 'development',
// eval-source-map is faster for development
devtool: 'eval-source-map',
devtool: '#eval-source-map',
plugins: [
new webpack.DefinePlugin({
'process.env': config.dev.env,

View file

@ -2,8 +2,7 @@ var path = require('path')
var config = require('../config')
var utils = require('./utils')
var webpack = require('webpack')
const WorkboxPlugin = require('workbox-webpack-plugin');
var { merge } = require('webpack-merge')
var merge = require('webpack-merge')
var baseWebpackConfig = require('./webpack.base.conf')
var MiniCssExtractPlugin = require('mini-css-extract-plugin')
var HtmlWebpackPlugin = require('html-webpack-plugin')
@ -20,7 +19,7 @@ var webpackConfig = merge(baseWebpackConfig, {
module: {
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, extract: true })
},
devtool: 'source-map',
devtool: config.build.productionSourceMap ? '#source-map' : false,
optimization: {
minimize: true,
splitChunks: {
@ -33,11 +32,6 @@ var webpackConfig = merge(baseWebpackConfig, {
chunkFilename: utils.assetsPath('js/[name].[chunkhash].js')
},
plugins: [
new WorkboxPlugin.InjectManifest({
swSrc: path.join(__dirname, '..', 'src/sw.js'),
swDest: 'sw-pleroma.js',
maximumFileSizeToCacheInBytes: 15 * 1024 * 1024,
}),
// http://vuejs.github.io/vue-loader/workflow/production.html
new webpack.DefinePlugin({
'process.env': env,
@ -68,7 +62,7 @@ var webpackConfig = merge(baseWebpackConfig, {
// https://github.com/kangax/html-minifier#options-quick-reference
},
// necessary to consistently work with multiple chunks via CommonsChunkPlugin
chunksSortMode: 'auto'
chunksSortMode: 'dependency'
}),
// split vendor js into its own file
// extract webpack runtime and module manifest to its own file in order to

View file

@ -1,4 +1,4 @@
var { merge } = require('webpack-merge')
var merge = require('webpack-merge')
var prodEnv = require('./prod.env')
module.exports = merge(prodEnv, {

View file

@ -1,4 +0,0 @@
{
"target": "https://ihatebeinga.live",
"staticConfigPreference": false
}

View file

@ -38,11 +38,6 @@ module.exports = {
assetsSubDirectory: 'static',
assetsPublicPath: '/',
proxyTable: {
'/manifest.json': {
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost'
},
'/api': {
target,
changeOrigin: true,
@ -66,17 +61,7 @@ module.exports = {
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost'
},
'/static/stickers.json': {
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost'
},
'/static/stickers': {
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost'
},
}
},
// CSS Sourcemaps off by default because relative paths are "buggy"
// with this option, according to the CSS-Loader README

View file

@ -1,4 +1,4 @@
var { merge } = require('webpack-merge')
var merge = require('webpack-merge')
var devEnv = require('./dev.env')
module.exports = merge(devEnv, {

View file

@ -7,9 +7,9 @@
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](https://docs.akkoma.dev/stable/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://akkoma.dev/AkkomaGang/pleroma-fe/src/branch/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://akkoma.dev/AkkomaGang/pleroma-fe/src/branch/develop/src/modules/instance.js) )
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
@ -70,6 +70,9 @@ 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`.

View file

@ -25,7 +25,7 @@ This could be a bit trickier, you basically need steps 1-4 from *develop build*
### 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](https://docs.akkoma.dev/stable/configuration/static_dir/), 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.
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
@ -67,7 +67,7 @@ server {
### API, Data, Operations
In 99% cases PleromaFE uses [MastoAPI](https://docs.joinmastodon.org/api/) with [Pleroma Extensions](https://docs.akkoma.dev/stable/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.
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.

View file

@ -1,7 +0,0 @@
all: install
pipenv run mkdocs build
install:
pipenv install
clean:
rm -rf site

View file

@ -1,10 +0,0 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
[packages]
mkdocs-material = "*"
markdown-include = "*"

277
docs/Pipfile.lock generated
View file

@ -1,277 +0,0 @@
{
"_meta": {
"hash": {
"sha256": "926d34630c729228bb015cb958c04f8269c57f5ca1ffc2ceab1dfd1798884772"
},
"pipfile-spec": 6,
"requires": {},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"click": {
"hashes": [
"sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e",
"sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"
],
"markers": "python_version >= '3.7'",
"version": "==8.1.3"
},
"ghp-import": {
"hashes": [
"sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619",
"sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"
],
"version": "==2.1.0"
},
"importlib-metadata": {
"hashes": [
"sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670",
"sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"
],
"markers": "python_version >= '3.7'",
"version": "==4.12.0"
},
"jinja2": {
"hashes": [
"sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852",
"sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"
],
"markers": "python_version >= '3.7'",
"version": "==3.1.2"
},
"markdown": {
"hashes": [
"sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874",
"sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621"
],
"markers": "python_version >= '3.6'",
"version": "==3.3.7"
},
"markdown-include": {
"hashes": [
"sha256:6f5d680e36f7780c7f0f61dca53ca581bd50d1b56137ddcd6353efafa0c3e4a2"
],
"index": "pypi",
"version": "==0.6.0"
},
"markupsafe": {
"hashes": [
"sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003",
"sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88",
"sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5",
"sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7",
"sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a",
"sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603",
"sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1",
"sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135",
"sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247",
"sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6",
"sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601",
"sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77",
"sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02",
"sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e",
"sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63",
"sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f",
"sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980",
"sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b",
"sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812",
"sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff",
"sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96",
"sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1",
"sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925",
"sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a",
"sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6",
"sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e",
"sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f",
"sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4",
"sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f",
"sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3",
"sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c",
"sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a",
"sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417",
"sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a",
"sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a",
"sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37",
"sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452",
"sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933",
"sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a",
"sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"
],
"markers": "python_version >= '3.7'",
"version": "==2.1.1"
},
"mergedeep": {
"hashes": [
"sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8",
"sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"
],
"markers": "python_version >= '3.6'",
"version": "==1.3.4"
},
"mkdocs": {
"hashes": [
"sha256:26bd2b03d739ac57a3e6eed0b7bcc86168703b719c27b99ad6ca91dc439aacde",
"sha256:b504405b04da38795fec9b2e5e28f6aa3a73bb0960cb6d5d27ead28952bd35ea"
],
"markers": "python_version >= '3.6'",
"version": "==1.3.0"
},
"mkdocs-material": {
"hashes": [
"sha256:263f2721f3abe533b61f7c8bed435a0462620912742c919821ac2d698b4bfe67",
"sha256:dc82b667d2a83f0de581b46a6d0949732ab77e7638b87ea35b770b33bc02e75a"
],
"index": "pypi",
"version": "==8.3.9"
},
"mkdocs-material-extensions": {
"hashes": [
"sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44",
"sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"
],
"markers": "python_version >= '3.6'",
"version": "==1.0.3"
},
"packaging": {
"hashes": [
"sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb",
"sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"
],
"markers": "python_version >= '3.6'",
"version": "==21.3"
},
"pygments": {
"hashes": [
"sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb",
"sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"
],
"markers": "python_version >= '3.6'",
"version": "==2.12.0"
},
"pymdown-extensions": {
"hashes": [
"sha256:3ef2d998c0d5fa7eb09291926d90d69391283561cf6306f85cd588a5eb5befa0",
"sha256:ec141c0f4983755349f0c8710416348d1a13753976c028186ed14f190c8061c4"
],
"markers": "python_version >= '3.7'",
"version": "==9.5"
},
"pyparsing": {
"hashes": [
"sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb",
"sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"
],
"markers": "python_full_version >= '3.6.8'",
"version": "==3.0.9"
},
"python-dateutil": {
"hashes": [
"sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86",
"sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.8.2"
},
"pyyaml": {
"hashes": [
"sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293",
"sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b",
"sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57",
"sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b",
"sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4",
"sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07",
"sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba",
"sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9",
"sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287",
"sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513",
"sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0",
"sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0",
"sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92",
"sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f",
"sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2",
"sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc",
"sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c",
"sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86",
"sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4",
"sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c",
"sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34",
"sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b",
"sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c",
"sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb",
"sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737",
"sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3",
"sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d",
"sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53",
"sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78",
"sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803",
"sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a",
"sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174",
"sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"
],
"markers": "python_version >= '3.6'",
"version": "==6.0"
},
"pyyaml-env-tag": {
"hashes": [
"sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb",
"sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"
],
"markers": "python_version >= '3.6'",
"version": "==0.1"
},
"six": {
"hashes": [
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.16.0"
},
"watchdog": {
"hashes": [
"sha256:083171652584e1b8829581f965b9b7723ca5f9a2cd7e20271edf264cfd7c1412",
"sha256:117ffc6ec261639a0209a3252546b12800670d4bf5f84fbd355957a0595fe654",
"sha256:186f6c55abc5e03872ae14c2f294a153ec7292f807af99f57611acc8caa75306",
"sha256:195fc70c6e41237362ba720e9aaf394f8178bfc7fa68207f112d108edef1af33",
"sha256:226b3c6c468ce72051a4c15a4cc2ef317c32590d82ba0b330403cafd98a62cfd",
"sha256:247dcf1df956daa24828bfea5a138d0e7a7c98b1a47cf1fa5b0c3c16241fcbb7",
"sha256:255bb5758f7e89b1a13c05a5bceccec2219f8995a3a4c4d6968fe1de6a3b2892",
"sha256:43ce20ebb36a51f21fa376f76d1d4692452b2527ccd601950d69ed36b9e21609",
"sha256:4f4e1c4aa54fb86316a62a87b3378c025e228178d55481d30d857c6c438897d6",
"sha256:5952135968519e2447a01875a6f5fc8c03190b24d14ee52b0f4b1682259520b1",
"sha256:64a27aed691408a6abd83394b38503e8176f69031ca25d64131d8d640a307591",
"sha256:6b17d302850c8d412784d9246cfe8d7e3af6bcd45f958abb2d08a6f8bedf695d",
"sha256:70af927aa1613ded6a68089a9262a009fbdf819f46d09c1a908d4b36e1ba2b2d",
"sha256:7a833211f49143c3d336729b0020ffd1274078e94b0ae42e22f596999f50279c",
"sha256:8250546a98388cbc00c3ee3cc5cf96799b5a595270dfcfa855491a64b86ef8c3",
"sha256:97f9752208f5154e9e7b76acc8c4f5a58801b338de2af14e7e181ee3b28a5d39",
"sha256:9f05a5f7c12452f6a27203f76779ae3f46fa30f1dd833037ea8cbc2887c60213",
"sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330",
"sha256:ad576a565260d8f99d97f2e64b0f97a48228317095908568a9d5c786c829d428",
"sha256:b530ae007a5f5d50b7fbba96634c7ee21abec70dc3e7f0233339c81943848dc1",
"sha256:bfc4d351e6348d6ec51df007432e6fe80adb53fd41183716017026af03427846",
"sha256:d3dda00aca282b26194bdd0adec21e4c21e916956d972369359ba63ade616153",
"sha256:d9820fe47c20c13e3c9dd544d3706a2a26c02b2b43c993b62fcd8011bcc0adb3",
"sha256:ed80a1628cee19f5cfc6bb74e173f1b4189eb532e705e2a13e3250312a62e0c9",
"sha256:ee3e38a6cc050a8830089f79cbec8a3878ec2fe5160cdb2dc8ccb6def8552658"
],
"markers": "python_version >= '3.6'",
"version": "==2.1.9"
},
"zipp": {
"hashes": [
"sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad",
"sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"
],
"markers": "python_version >= '3.7'",
"version": "==3.8.0"
}
},
"develop": {}
}

View file

Before

Width:  |  Height:  |  Size: 491 B

After

Width:  |  Height:  |  Size: 491 B

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1,28 +0,0 @@
p, a, li, pre {
font-family: "Tiresias PCFont", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !important;
}
code,
.codehilite pre {
font-weight: bold;
}
:root > * {
--md-primary-fg-color: #593196;
--md-accent-fg-color: #455a63;
}
@font-face {
font-family: 'Tiresias PCFont';
font-style: normal;
font-weight: 400;
src: local('Tiresias PCFont'), local('Tiresias PCFont'),
url('./fonts/Tiresias_PCfont.ttf') format('truetype')
}
@font-face {
font-family: 'Tiresias Infofont';
font-style: normal;
font-weight: 400;
src: local('Tiresias Infofont'), local('Tiresias Infofont'),
url('./fonts/Tiresias_Infofont.ttf') format('truetype')
}

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 B

View file

@ -1,92 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="svg4485"
width="512"
height="512"
viewBox="0 0 512 512"
sodipodi:docname="pleroma_logo_vector_nobg.svg"
inkscape:version="0.92.1 r15371">
<metadata
id="metadata4491">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs4489" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1680"
inkscape:window-height="997"
id="namedview4487"
showgrid="false"
inkscape:zoom="0.70710678"
inkscape:cx="26.131594"
inkscape:cy="235.37499"
inkscape:window-x="1912"
inkscape:window-y="22"
inkscape:window-maximized="1"
inkscape:current-layer="svg4485" />
<g
id="g4612">
<path
sodipodi:nodetypes="cccccc"
inkscape:connector-curvature="0"
id="path4495"
d="M 235,89 V 423 H 152 V 115 l 26,-26 z"
style="opacity:1;fill:#fba457;fill-opacity:1;stroke:#009bff;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.17587938" />
<circle
r="26"
cx="178"
cy="115"
id="path4497"
style="opacity:1;fill:#fba457;fill-opacity:1;stroke:#009bff;stroke-width:0;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.17587938" />
<circle
r="26"
cx="335"
cy="230"
id="path4497-0"
style="opacity:1;fill:#fba457;fill-opacity:1;stroke:#009bff;stroke-width:0;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.17587938" />
<path
sodipodi:nodetypes="cccccc"
inkscape:connector-curvature="0"
id="path4516"
d="M 277,256 V 89 l 84,3e-6 L 361.00002,230 335,256 Z"
style="fill:#fba457;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
r="26"
cx="335"
cy="397"
id="path4497-0-6"
style="opacity:1;fill:#fba457;fill-opacity:1;stroke:#009bff;stroke-width:0;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.17587938" />
<path
sodipodi:nodetypes="cccccc"
inkscape:connector-curvature="0"
id="path4516-5"
d="m 277,423 v -83 h 84 l 2e-5,57 L 335,423 Z"
style="opacity:1;fill:#fba457;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -1,53 +0,0 @@
# Adding stickers
Pleroma-fe supports stickers, which are essentially little images stored server-side
which can be selected by a user to automatically attach them to a post.
There's no explicit setting for these, they just rely on the existence of certain files.
## Initialising the sticker config file
You're probably serving pleroma-fe from your instance's `instance/static/` directory -
this directy can also override files served at a given path.
The first thing we need to do is set up our `stickers.json` file. At `instance/static/static/stickers.json`,
put a file that looks like this
```json
{
"myPack": "/static/stickers/myPack"
}
```
This file is a mapping from name to pack directory location. It says "we have a pack called myPack, look for
it at `/static/stickers/myPack`". You can add as many packs as you like in this manner.
## Creating the pack
First, create your pack directory
```bash
mkdir -p instance/static/static/stickers/myPack
```
Now you need to give it some config.
At `instance/static/static/stickers/myPack/pack.json`, put a file that looks like:
```json
{
"title": "myPack",
"author": "me for i am very cool",
"tabIcon": "tab.png",
"stickers": [
"mySticker.png"
]
}
```
This should be relatively self-explanatory, it declares a pack with a title `myPack` which has only one sticker in it.
The `tabIcon` will appear on the sticker picker itself as a representative of the pack.
You can add as many stickers as you like. They should all be in the same directory as your `pack.json`.
Now you should find that there's a sticky note icon on the emoji picker on pleroma-fe that allows you to attach stickers.

View file

@ -1,37 +0,0 @@
site_name: Pleroma-FE Documentation
theme:
favicon: 'images/pleroma_logo_vector_bg_32.png'
name: 'material'
custom_dir: 'theme'
# Disable google fonts
font: false
logo: 'images/pleroma_logo_vector_nobg.svg'
features:
- tabs
palette:
primary: 'deep purple'
accent: 'blue grey'
extra_css:
- css/extra.css
repo_name: 'AkkomaGang/pleroma-fe'
repo_url: 'https://akkoma.dev/AkkomaGang/pleroma-fe'
extra:
repo_icon: gitea
markdown_extensions:
# Note/warning blocks https://squidfunk.github.io/mkdocs-material/extensions/admonition/
- admonition
- codehilite:
guess_lang: false
# Make it possible to link to every header https://squidfunk.github.io/mkdocs-material/extensions/permalinks/
- toc:
permalink: true
- pymdownx.tasklist:
custom_checkbox: true
- pymdownx.superfences
- pymdownx.tabbed
- pymdownx.details
- markdown_include.include:
base_path: docs

View file

@ -1,22 +0,0 @@
click==8.1.3
ghp-import==2.1.0
importlib-metadata==4.12.0
Jinja2==3.1.2
Markdown==3.3.7
markdown-include==0.6.0
MarkupSafe==2.1.1
mergedeep==1.3.4
mkdocs==1.3.0
mkdocs-bootswatch==1.1
mkdocs-material==8.1.8
mkdocs-material-extensions==1.0.3
packaging==21.3
Pygments==2.11.2
pymdown-extensions==9.1
pyparsing==3.0.9
python-dateutil==2.8.2
PyYAML==6.0
pyyaml_env_tag==0.1
six==1.16.0
watchdog==2.1.9
zipp==3.8.0

View file

@ -1,64 +0,0 @@
<!--
Copyright (c) 2016-2019 Martin Donath <martin.donath@squidfunk.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
-->
{% import "partials/language.html" as lang with context %}
<!--
Check whether the repository is hosted on one of the supported code hosting
platforms (GitHub, GitLab or Bitbucket) to show icon.
-->
{% set platform = config.extra.repo_icon or config.repo_url %}
{% if "github" in platform %}
{% set repo_type = "github" %}
{% elif "gitlab" in platform %}
{% set repo_type = "gitlab" %}
{% elif "bitbucket" in platform %}
{% set repo_type = "bitbucket" %}
{% else %}
{% set repo_type = "" %}
{% endif %}
{% if page and page.url.startswith('backend') %}
{% set repo_url = "https://git.pleroma.social/pleroma/pleroma" %}
{% set repo_name = "pleroma/pleroma" %}
{% elif page and page.url.startswith('frontend') %}
{% set repo_url = "https://git.pleroma.social/pleroma/pleroma-fe" %}
{% set repo_name = "pleroma/pleroma-fe" %}
{% else %}
{% set repo_url = config.repo_url %}
{% set repo_name = config.repo_name %}
{% endif %}
<!-- Repository containing source -->
<a href="{{ repo_url }}" title="{{ lang.t('source.link.title') }}"
class="md-source" data-md-source="{{ repo_type }}">
{% if repo_type %}
<div class="md-source__icon">
<svg viewBox="0 0 24 24" width="24" height="24">
<use xlink:href="#__{{ repo_type }}" width="24" height="24"></use>
</svg>
</div>
{% endif %}
<div class="md-source__repository">
{{ repo_name }}
</div>
</a>

View file

@ -65,16 +65,6 @@ If you set the input-method to Markdown, and post this, it will look something l
![example_markdown](../assets/example_markdown.png)
## Misskey markdown
The akkoma version of pleroma-fe includes support for writing and rendering
misskey markdown (MFM). To write this you will need to select "MFM" from
the content type dropdown (if supported), and then you can format text
[in MFM](https://akkoma.dev/sfr/marked-mfm/src/branch/master/docs/syntax.md).
To render MFM, you will need to turn on "Render Misskey Markdown" in
the settings modal.
## 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".

View file

@ -6,7 +6,6 @@ You have several timelines to browse trough
- **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
- **Bubble Timeline** all public posts from instances recommended by your admin(s) in the instance settings. This won't appear if they haven't set anything up for it.
- **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).

View file

@ -3,20 +3,18 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no">
<title>Akkoma</title>
<title>Pleroma</title>
<link rel="stylesheet" href="/static/font/css/fontello.css">
<link rel="stylesheet" href="/static/font/css/animation.css">
<link rel="stylesheet" href="/static/font/tiresias.css">
<link rel="stylesheet" href="/static/font/css/lato.css">
<link rel="stylesheet" href="/static/mfm.css">
<link rel="stylesheet" href="/static/custom.css">
<link rel="stylesheet" href="/static/theme-holder.css" id="theme-holder">
<!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="manifest" href="/manifest.json">
</head>
<body class="hidden">
<noscript>To use Akkoma, please enable JavaScript.</noscript>
<noscript>To use Pleroma, please enable JavaScript.</noscript>
<div id="app"></div>
<div id="modal"></div>
<!-- built files will be auto injected -->
</body>
</html>

View file

@ -1,7 +1,7 @@
{
"name": "pleroma_fe",
"version": "3.5.0",
"description": "A frontend for Akkoma instances",
"version": "1.0.0",
"description": "A Qvitter-style frontend for certain GS servers.",
"author": "Roger Braun <roger@rogerbraun.net>",
"private": true,
"scripts": {
@ -11,44 +11,42 @@
"unit:watch": "karma start test/unit/karma.conf.js --single-run=false",
"e2e": "node test/e2e/runner.js",
"test": "npm run unit && npm run e2e",
"stylelint": "stylelint src/**/*.scss",
"stylelint": "npx stylelint src/components/status/status.scss",
"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": {
"@babel/runtime": "7.17.8",
"@chenfengyuan/vue-qrcode": "2.0.0",
"@floatingghost/pinch-zoom-element": "^1.3.1",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-regular-svg-icons": "^6.1.2",
"@fortawesome/free-solid-svg-icons": "^6.2.0",
"@fortawesome/vue-fontawesome": "3.0.1",
"@vuelidate/core": "^2.0.0",
"@vuelidate/validators": "^2.0.0",
"blurhash": "^2.0.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/vue-fontawesome": "3.0.0-5",
"@kazvmoe-infra/pinch-zoom-element": "1.2.0",
"@vuelidate/core": "2.0.0-alpha.41",
"@vuelidate/validators": "2.0.0-alpha.27",
"body-scroll-lock": "2.7.1",
"chromatism": "3.0.0",
"click-outside-vue3": "4.0.1",
"cropperjs": "1.5.12",
"diff": "3.5.0",
"escape-html": "1.0.3",
"iso-639-1": "^2.1.15",
"js-cookie": "^3.0.1",
"localforage": "1.10.0",
"parse-link-header": "^2.0.0",
"mfm-js": "^0.22.1",
"parse-link-header": "1.0.1",
"phoenix": "1.6.2",
"punycode.js": "2.1.0",
"qrcode": "1",
"url": "^0.11.0",
"ruffle-mirror": "2021.12.31",
"vue": "^3.2.31",
"vue-i18n": "^9.2.2",
"vue-i18n": "^9.2.0-beta.34",
"vue-router": "4.0.14",
"vue-template-compiler": "2.6.11",
"vuex": "4.0.2"
},
"devDependencies": {
"@babel/core": "7.17.8",
"@babel/eslint-parser": "^7.19.1",
"@babel/plugin-transform-runtime": "7.17.0",
"@babel/preset-env": "7.16.11",
"@babel/register": "7.17.7",
@ -57,33 +55,36 @@
"@vue/babel-helper-vue-jsx-merge-props": "1.2.1",
"@vue/babel-plugin-jsx": "1.1.1",
"@vue/compiler-sfc": "^3.1.0",
"@vue/test-utils": "^2.0.2",
"@vue/test-utils": "2.0.0-rc.17",
"autoprefixer": "6.7.7",
"babel-loader": "^9.1.0",
"babel-eslint": "7.2.3",
"babel-loader": "8.2.4",
"babel-plugin-lodash": "3.3.4",
"chai": "^4.3.7",
"chai": "3.5.0",
"chalk": "1.1.3",
"chromedriver": "^107.0.3",
"connect-history-api-fallback": "^2.0.0",
"cross-spawn": "^7.0.3",
"css-loader": "^6.7.2",
"custom-event-polyfill": "^1.0.7",
"eslint": "^7.32.0",
"eslint-config-standard": "^17.0.0",
"eslint-friendly-formatter": "^4.0.1",
"eslint-loader": "^4.0.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-standard": "^5.0.0",
"eslint-plugin-vue": "^9.7.0",
"chromedriver": "87.0.7",
"connect-history-api-fallback": "1.6.0",
"copy-webpack-plugin": "6.4.1",
"cross-spawn": "4.0.2",
"css-loader": "0.28.11",
"custom-event-polyfill": "1.0.7",
"eslint": "5.16.0",
"eslint-config-standard": "12.0.0",
"eslint-friendly-formatter": "2.0.7",
"eslint-loader": "2.2.1",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-node": "7.0.1",
"eslint-plugin-promise": "4.3.1",
"eslint-plugin-standard": "4.1.0",
"eslint-plugin-vue": "5.2.3",
"eventsource-polyfill": "0.9.6",
"express": "4.17.3",
"file-loader": "^6.2.0",
"file-loader": "3.0.1",
"function-bind": "1.1.1",
"html-webpack-plugin": "^5.5.0",
"html-webpack-plugin": "3.2.0",
"http-proxy-middleware": "0.21.0",
"inject-loader": "2.0.1",
"iso-639-1": "2.1.13",
"isparta-loader": "2.0.0",
"json-loader": "0.5.7",
"karma": "6.3.17",
@ -94,7 +95,7 @@
"karma-sinon-chai": "2.0.2",
"karma-sourcemap-loader": "0.3.8",
"karma-spec-reporter": "0.0.33",
"karma-webpack": "^5.0.0",
"karma-webpack": "4.0.2",
"lodash": "4.17.21",
"lolex": "1.6.0",
"mini-css-extract-plugin": "0.12.0",
@ -102,33 +103,29 @@
"nightwatch": "0.9.21",
"opn": "4.0.2",
"ora": "0.4.1",
"postcss-html": "^1.5.0",
"postcss-loader": "3.0.0",
"postcss-sass": "^0.5.0",
"raw-loader": "0.5.1",
"sass": "^1.56.0",
"sass-loader": "^13.2.0",
"sass": "1.20.1",
"sass-loader": "7.2.0",
"selenium-server": "2.53.1",
"semver": "5.7.1",
"serviceworker-webpack-plugin": "1.0.1",
"shelljs": "0.8.5",
"sinon": "2.4.1",
"sinon-chai": "2.14.0",
"stylelint": "^14.15.0",
"stylelint-config-recommended-vue": "^1.4.0",
"stylelint-config-standard": "^29.0.0",
"stylelint-config-standard-scss": "^6.1.0",
"stylelint-rscss": "^0.4.0",
"url-loader": "^4.1.1",
"vue-loader": "^17.0.0",
"vue-style-loader": "^4.1.2",
"webpack": "^5.75.0",
"webpack-dev-middleware": "^5.3.3",
"webpack-hot-middleware": "^2.25.1",
"webpack-merge": "^5.8.0",
"workbox-webpack-plugin": "^6.5.4"
"stylelint": "13.6.1",
"stylelint-config-standard": "20.0.0",
"stylelint-rscss": "0.4.0",
"url-loader": "1.1.2",
"vue-loader": "^16.0.0",
"vue-style-loader": "4.1.2",
"webpack": "4.46.0",
"webpack-dev-middleware": "3.7.3",
"webpack-hot-middleware": "2.24.3",
"webpack-merge": "0.20.0"
},
"engines": {
"node": ">= 16.0.0",
"node": ">= 4.0.0",
"npm": ">= 3.0.0"
}
}

View file

@ -3,17 +3,15 @@ import NavPanel from './components/nav_panel/nav_panel.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import ShoutPanel from './components/shout_panel/shout_panel.vue'
import SettingsModal from './components/settings_modal/settings_modal.vue'
import MediaModal from './components/media_modal/media_modal.vue'
import ModModal from './components/mod_modal/mod_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
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 EditStatusModal from './components/edit_status_modal/edit_status_modal.vue'
import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
import StatusHistoryModal from './components/status_history_modal/status_history_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'
@ -28,17 +26,15 @@ export default {
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel,
ShoutPanel,
MediaModal,
SideDrawer,
MobilePostStatusButton,
MobileNav,
DesktopNav,
SettingsModal,
ModModal,
UserReportingModal,
PostStatusModal,
EditStatusModal,
StatusHistoryModal,
GlobalNoticeList
},
data: () => ({
@ -79,17 +75,27 @@ export default {
}
}
},
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
},
isChats () {
return this.$route.name === 'chat' || this.$route.name === 'chats'
},
newPostButtonShown () {
if (this.isChats) return false
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
},
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
editingAvailable () { return this.$store.state.instance.editingAvailable },
shoutboxPosition () {
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false
},
hideShoutbox () {
return this.$store.getters.mergedConfig.hideShoutbox
},
layoutType () { return this.$store.state.interface.layoutType },
privateMode () { return this.$store.state.instance.private },
reverseLayout () {

View file

@ -1,7 +1,6 @@
// stylelint-disable rscss/class-format
@import './_variables.scss';
@import '@fortawesome/fontawesome-svg-core/styles.css';
@import '@floatingghost/pinch-zoom-element/dist/pinch-zoom.css';
:root {
--navbar-height: 3.5rem;
--post-line-height: 1.4;
@ -311,6 +310,7 @@ nav {
border-top-right-radius: 0;
}
.underlay,
#sidebar,
#notifs-column {
display: none;

View file

@ -33,7 +33,7 @@
<div
id="main-scroller"
class="column main"
:class="{ '-full-height': false }"
:class="{ '-full-height': isChats }"
>
<div
v-if="!currentUser"
@ -55,13 +55,17 @@
/>
</div>
<media-modal />
<shout-panel
v-if="currentUser && shout && !hideShoutbox"
:floating="true"
class="floating-shout mobile-hidden"
:class="{ '-left': shoutboxPosition }"
/>
<MobilePostStatusButton />
<UserReportingModal />
<PostStatusModal />
<EditStatusModal v-if="editingAvailable" />
<StatusHistoryModal v-if="editingAvailable" />
<SettingsModal />
<ModModal />
<div id="modal" />
<GlobalNoticeList />
</div>
</template>

View file

@ -27,6 +27,7 @@ $fallback--tooltipRadius: 5px;
$fallback--avatarRadius: 4px;
$fallback--avatarAltRadius: 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;

View file

@ -1,11 +1,8 @@
import Cookies from 'js-cookie'
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import vClickOutside from 'click-outside-vue3'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import { config } from '@fortawesome/fontawesome-svg-core';
config.autoAddCss = false
import App from '../App.vue'
import routes from './routes'
@ -51,20 +48,6 @@ const preloadFetch = async (request) => {
}
}
const resolveLanguage = (instanceLanguages) => {
// First language in navigator.languages that is listed as an instance language
// falls back to first instance language
const navigatorLanguages = navigator.languages.map((x) => x.split('-')[0])
for (const navLanguage of navigatorLanguages) {
if (instanceLanguages.includes(navLanguage)) {
return navLanguage
}
}
return instanceLanguages[0]
}
const getInstanceConfig = async ({ store }) => {
try {
const res = await preloadFetch('/api/v1/instance')
@ -75,10 +58,6 @@ const getInstanceConfig = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit })
store.dispatch('setInstanceOption', { name: 'accountApprovalRequired', value: data.approval_required })
// don't override cookie if set
if (!Cookies.get('userLanguage')) {
store.dispatch('setOption', { name: 'interfaceLanguage', value: resolveLanguage(data.languages) })
}
if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
@ -145,14 +124,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('hideBotIndication')
copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('hideSiteName')
copyInstanceOption('hideSiteFavicon')
copyInstanceOption('showWiderShortcuts')
copyInstanceOption('showNavShortcuts')
copyInstanceOption('showPanelNavShortcuts')
copyInstanceOption('stopGifs')
copyInstanceOption('logo')
copyInstanceOption('conversationDisplay')
store.dispatch('setInstanceOption', {
name: 'logoMask',
@ -173,14 +145,15 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
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('renderMisskeyMarkdown')
copyInstanceOption('sidebarRight')
return store.dispatch('setTheme', config['theme'])
@ -274,11 +247,12 @@ const getNodeInfo = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations })
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
store.dispatch('setInstanceOption', { name: 'shoutAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: '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: 'editingAvailable', value: features.includes('editing') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
store.dispatch('setInstanceOption', { name: 'translationEnabled', value: features.includes('akkoma:machine_translation') })
const uploadLimits = metadata.uploadLimits
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) })
@ -314,7 +288,6 @@ const getNodeInfo = async ({ store }) => {
})
store.dispatch('setInstanceOption', { name: 'federationPolicy', value: federation })
store.dispatch('setInstanceOption', { name: 'localBubbleInstances', value: metadata.localBubbleInstances })
store.dispatch('setInstanceOption', {
name: 'federating',
value: typeof federation.enabled === 'undefined'
@ -322,9 +295,6 @@ const getNodeInfo = async ({ store }) => {
: federation.enabled
})
store.dispatch('setInstanceOption', { name: 'publicTimelineVisibility', value: metadata.publicTimelineVisibility })
store.dispatch('setInstanceOption', { name: 'federatedTimelineAvailable', value: metadata.federatedTimelineAvailable })
const accountActivationRequired = metadata.accountActivationRequired
store.dispatch('setInstanceOption', { name: 'accountActivationRequired', value: accountActivationRequired })
@ -399,6 +369,7 @@ const afterStoreSetup = async ({ store, i18n }) => {
])
// Start fetching things that don't need to block the UI
store.dispatch('fetchMutes')
getTOS({ store })
getStickers({ store })
@ -407,9 +378,8 @@ const afterStoreSetup = async ({ store, i18n }) => {
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some(m => m.meta.dontScroll)) {
return {}
return false
}
return savedPosition || { left: 0, top: 0 }
}
})

View file

@ -2,11 +2,12 @@ import PublicTimeline from 'components/public_timeline/public_timeline.vue'
import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
import BubbleTimeline from 'components/bubble_timeline/bubble_timeline.vue'
import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue'
import ConversationPage from 'components/conversation-page/conversation-page.vue'
import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue'
import 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 Search from 'components/search/search.vue'
import Registration from 'components/registration/registration.vue'
@ -15,15 +16,10 @@ import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import Notifications from 'components/notifications/notifications.vue'
import AuthForm from 'components/auth_form/auth_form.js'
import ShoutPanel from 'components/shout_panel/shout_panel.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import About from 'components/about/about.vue'
import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
import Lists from 'components/lists/lists.vue'
import ListTimeline from 'components/list_timeline/list_timeline.vue'
import ListEdit from 'components/list_edit/list_edit.vue'
import AnnouncementsPage from 'components/announcements_page/announcements_page.vue'
import RegistrationRequestSent from 'components/registration_request_sent/registration_request_sent.vue'
import AwaitingEmailConfirmation from 'components/awaiting_email_confirmation/awaiting_email_confirmation.vue'
export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => {
@ -45,7 +41,6 @@ export default (store) => {
},
{ name: 'public-external-timeline', path: '/main/all', component: PublicAndExternalTimeline },
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'bubble-timeline', path: '/main/bubble', component: BubbleTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
@ -60,27 +55,29 @@ export default (store) => {
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute
},
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile, meta: { dontScroll: true } },
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
{ name: 'registration', path: '/registration', component: Registration },
{ name: 'registration-request-sent', path: '/registration-request-sent', component: RegistrationRequestSent },
{ name: 'awaiting-email-confirmation', path: '/awaiting-email-confirmation', component: AwaitingEmailConfirmation },
{ name: 'password-reset', path: '/password-reset', component: PasswordReset, props: true },
{ name: 'registration-token', path: '/registration/:token', component: Registration },
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, props: () => ({ disableTeleport: true }), beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm },
{ name: 'shout-panel', path: '/shout-panel', component: ShoutPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About },
{ name: 'lists', path: '/lists', component: Lists },
{ name: 'list-timeline', path: '/lists/:id', component: ListTimeline },
{ name: 'list-edit', path: '/lists/:id/edit', component: ListEdit },
{ name: 'announcements', path: '/announcements', component: AnnouncementsPage },
{ name: 'user-profile', path: '/:_(users)?/:name', component: UserProfile, meta: { dontScroll: true } }
{ 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

@ -3,7 +3,6 @@ import FeaturesPanel from '../features_panel/features_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'
import LocalBubblePanel from '../local_bubble_panel/local_bubble_panel.vue'
const About = {
components: {
@ -11,8 +10,7 @@ const About = {
FeaturesPanel,
TermsOfServicePanel,
StaffPanel,
MRFTransparencyPanel,
LocalBubblePanel
MRFTransparencyPanel
},
computed: {
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
@ -20,9 +18,6 @@ const About = {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
this.$store.state.instance.instanceSpecificPanelContent
},
showLocalBubblePanel () {
return this.$store.state.instance.localBubbleInstances.length > 0
}
}
}

View file

@ -3,7 +3,6 @@
<instance-specific-panel v-if="showInstanceSpecificPanel" />
<staff-panel />
<terms-of-service-panel />
<LocalBubblePanel v-if="showLocalBubblePanel" />
<MRFTransparencyPanel />
<features-panel v-if="showFeaturesPanel" />
</div>

View file

@ -1,8 +1,7 @@
import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { mapState } from 'vuex'
import {
faEllipsisV
} from '@fortawesome/free-solid-svg-icons'
@ -16,25 +15,13 @@ const AccountActions = {
'user', 'relationship'
],
data () {
return {
showingConfirmBlock: false
}
return { }
},
components: {
ProgressButton,
Popover,
ConfirmModal
Popover
},
methods: {
refetchRelationship () {
return this.$store.dispatch('fetchUserRelationship', this.user.id)
},
showConfirmBlock () {
this.showingConfirmBlock = true
},
hideConfirmBlock () {
this.showingConfirmBlock = false
},
showRepeats () {
this.$store.dispatch('showReblogs', this.user.id)
},
@ -42,38 +29,22 @@ const AccountActions = {
this.$store.dispatch('hideReblogs', this.user.id)
},
blockUser () {
if (!this.shouldConfirmBlock) {
this.doBlockUser()
} else {
this.showConfirmBlock()
}
},
doBlockUser () {
this.$store.dispatch('blockUser', this.user.id)
this.hideConfirmBlock()
},
unblockUser () {
this.$store.dispatch('unblockUser', this.user.id)
},
removeUserFromFollowers () {
this.$store.dispatch('removeUserFromFollowers', this.user.id)
},
reportUser () {
this.$store.dispatch('openUserReportingModal', { userId: this.user.id })
},
muteDomain () {
this.$store.dispatch('muteDomain', this.user.screen_name.split('@')[1])
.then(() => this.refetchRelationship())
},
unmuteDomain () {
this.$store.dispatch('unmuteDomain', this.user.screen_name.split('@')[1])
.then(() => this.refetchRelationship())
openChat () {
this.$router.push({
name: 'chat',
params: { username: this.$store.state.users.currentUser.screen_name, recipient_id: this.user.id }
})
}
},
computed: {
shouldConfirmBlock () {
return this.$store.getters.mergedConfig.modalOnBlock
},
...mapState({
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
})

View file

@ -28,13 +28,6 @@
class="dropdown-divider"
/>
</template>
<button
v-if="relationship.followed_by"
class="btn button-default btn-block dropdown-item"
@click="removeUserFromFollowers"
>
{{ $t('user_card.remove_follower') }}
</button>
<button
v-if="relationship.blocking"
class="btn button-default btn-block dropdown-item"
@ -56,18 +49,11 @@
{{ $t('user_card.report') }}
</button>
<button
v-if="relationship.domain_blocking"
v-if="pleromaChatMessagesAvailable"
class="btn button-default btn-block dropdown-item"
@click="unmuteDomain"
@click="openChat"
>
{{ $t('user_card.domain_muted') }}
</button>
<button
v-else-if="!user.is_local"
class="btn button-default btn-block dropdown-item"
@click="muteDomain"
>
{{ $t('user_card.mute_domain') }}
{{ $t('user_card.message') }}
</button>
</div>
</template>
@ -80,27 +66,6 @@
</button>
</template>
</Popover>
<teleport to="#modal">
<confirm-modal
v-if="showingConfirmBlock"
:title="$t('user_card.block_confirm_title')"
:confirm-text="$t('user_card.block_confirm_accept_button')"
:cancel-text="$t('user_card.block_confirm_cancel_button')"
@accepted="doBlockUser"
@cancelled="hideConfirmBlock"
>
<i18n-t
keypath="user_card.block_confirm"
tag="span"
>
<template v-slot:user>
<span
v-text="user.screen_name_ui"
/>
</template>
</i18n-t>
</confirm-modal>
</teleport>
</div>
</template>

View file

@ -1,105 +0,0 @@
import { mapState } from 'vuex'
import AnnouncementEditor from '../announcement_editor/announcement_editor.vue'
import RichContent from '../rich_content/rich_content.jsx'
import localeService from '../../services/locale/locale.service.js'
const Announcement = {
components: {
AnnouncementEditor,
RichContent
},
data () {
return {
editing: false,
editedAnnouncement: {
content: '',
startsAt: undefined,
endsAt: undefined,
allDay: undefined
},
editError: ''
}
},
props: {
announcement: Object
},
computed: {
...mapState({
currentUser: state => state.users.currentUser
}),
content () {
return this.announcement.content
},
isRead () {
return this.announcement.read
},
publishedAt () {
const time = this.announcement['published_at']
if (!time) {
return
}
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
},
startsAt () {
const time = this.announcement['starts_at']
if (!time) {
return
}
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
},
endsAt () {
const time = this.announcement['ends_at']
if (!time) {
return
}
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
},
inactive () {
return this.announcement.inactive
}
},
methods: {
markAsRead () {
if (!this.isRead) {
return this.$store.dispatch('markAnnouncementAsRead', this.announcement.id)
}
},
deleteAnnouncement () {
return this.$store.dispatch('deleteAnnouncement', this.announcement.id)
},
formatTimeOrDate (time, locale) {
const d = new Date(time)
return this.announcement['all_day'] ? d.toLocaleDateString(locale) : d.toLocaleString(locale)
},
enterEditMode () {
this.editedAnnouncement.content = this.announcement.pleroma['raw_content']
this.editedAnnouncement.startsAt = this.announcement['starts_at']
this.editedAnnouncement.endsAt = this.announcement['ends_at']
this.editedAnnouncement.allDay = this.announcement['all_day']
this.editing = true
},
submitEdit () {
this.$store.dispatch('editAnnouncement', {
id: this.announcement.id,
...this.editedAnnouncement
})
.then(() => {
this.editing = false
})
.catch(error => {
this.editError = error.error
})
},
cancelEdit () {
this.editing = false
},
clearError () {
this.editError = undefined
}
}
}
export default Announcement

View file

@ -1,136 +0,0 @@
<template>
<div class="announcement">
<div class="heading">
<h4>{{ $t('announcements.title') }}</h4>
</div>
<div class="body">
<rich-content
v-if="!editing"
:html="content"
:emoji="announcement.emojis"
:handle-links="true"
/>
<announcement-editor
v-else
:announcement="editedAnnouncement"
/>
</div>
<div class="footer">
<div
v-if="!editing"
class="times"
>
<span v-if="publishedAt">
{{ $t('announcements.published_time_display', { time: publishedAt }) }}
</span>
<span v-if="startsAt">
{{ $t('announcements.start_time_display', { time: startsAt }) }}
</span>
<span v-if="endsAt">
{{ $t('announcements.end_time_display', { time: endsAt }) }}
</span>
</div>
<div
v-if="!editing"
class="actions"
>
<button
v-if="currentUser"
class="btn button-default"
:class="{ toggled: isRead }"
:disabled="inactive"
:title="inactive ? $t('announcements.inactive_message') : ''"
@click="markAsRead"
>
{{ $t('announcements.mark_as_read_action') }}
</button>
<button
v-if="currentUser && currentUser.role === 'admin'"
class="btn button-default"
@click="enterEditMode"
>
{{ $t('announcements.edit_action') }}
</button>
<button
v-if="currentUser && currentUser.role === 'admin'"
class="btn button-default"
@click="deleteAnnouncement"
>
{{ $t('announcements.delete_action') }}
</button>
</div>
<div
v-else
class="actions"
>
<button
class="btn button-default"
@click="submitEdit"
>
{{ $t('announcements.submit_edit_action') }}
</button>
<button
class="btn button-default"
@click="cancelEdit"
>
{{ $t('announcements.cancel_edit_action') }}
</button>
<div
v-if="editing && editError"
class="alert error"
>
{{ $t('announcements.edit_error', { error }) }}
<button
class="button-unstyled"
@click="clearError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
:title="$t('announcements.close_error')"
/>
</button>
</div>
</div>
</div>
</div>
</template>
<script src="./announcement.js"></script>
<style lang="scss">
@import "../../variables";
.announcement {
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: var(--border, $fallback--border);
border-radius: 0;
padding: var(--status-margin, $status-margin);
.heading, .body {
margin-bottom: var(--status-margin, $status-margin);
}
.footer {
display: flex;
flex-direction: column;
.times {
display: flex;
flex-direction: column;
}
}
.footer .actions {
display: flex;
flex-direction: row;
justify-content: space-evenly;
.btn {
flex: 1;
margin: 1em;
max-width: 10em;
}
}
}
</style>

View file

@ -1,13 +0,0 @@
import Checkbox from '../checkbox/checkbox.vue'
const AnnouncementEditor = {
components: {
Checkbox
},
props: {
announcement: Object,
disabled: Boolean
}
}
export default AnnouncementEditor

View file

@ -1,60 +0,0 @@
<template>
<div class="announcement-editor">
<textarea
ref="textarea"
v-model="announcement.content"
class="post-textarea"
rows="1"
cols="1"
:placeholder="$t('announcements.post_placeholder')"
:disabled="disabled"
/>
<span class="announcement-metadata">
<label for="announcement-start-time">{{ $t('announcements.start_time_prompt') }}</label>
<input
id="announcement-start-time"
v-model="announcement.startsAt"
:type="announcement.allDay ? 'date' : 'datetime-local'"
:disabled="disabled"
>
</span>
<span class="announcement-metadata">
<label for="announcement-end-time">{{ $t('announcements.end_time_prompt') }}</label>
<input
id="announcement-end-time"
v-model="announcement.endsAt"
:type="announcement.allDay ? 'date' : 'datetime-local'"
:disabled="disabled"
>
</span>
<span class="announcement-metadata">
<Checkbox
id="announcement-all-day"
v-model="announcement.allDay"
:disabled="disabled"
/>
<label for="announcement-all-day">{{ $t('announcements.all_day_prompt') }}</label>
</span>
</div>
</template>
<script src="./announcement_editor.js"></script>
<style lang="scss">
.announcement-editor {
display: flex;
align-items: stretch;
flex-direction: column;
.announcement-metadata {
margin-top: 0.5em;
}
.post-textarea {
resize: vertical;
height: 10em;
overflow: none;
box-sizing: content-box;
}
}
</style>

View file

@ -1,55 +0,0 @@
import { mapState } from 'vuex'
import Announcement from '../announcement/announcement.vue'
import AnnouncementEditor from '../announcement_editor/announcement_editor.vue'
const AnnouncementsPage = {
components: {
Announcement,
AnnouncementEditor
},
data () {
return {
newAnnouncement: {
content: '',
startsAt: undefined,
endsAt: undefined,
allDay: false
},
posting: false,
error: undefined
}
},
mounted () {
this.$store.dispatch('fetchAnnouncements')
},
computed: {
...mapState({
currentUser: state => state.users.currentUser
}),
announcements () {
return this.$store.state.announcements.announcements
}
},
methods: {
postAnnouncement () {
this.posting = true
this.$store.dispatch('postAnnouncement', this.newAnnouncement)
.then(() => {
this.newAnnouncement.content = ''
this.startsAt = undefined
this.endsAt = undefined
})
.catch(error => {
this.error = error.error
})
.finally(() => {
this.posting = false
})
},
clearError () {
this.error = undefined
}
}
}
export default AnnouncementsPage

View file

@ -1,79 +0,0 @@
<template>
<div class="panel panel-default announcements-page">
<div class="panel-heading">
<div class="title">
{{ $t('announcements.page_header') }}
</div>
</div>
<div class="panel-body">
<section
v-if="currentUser && currentUser.role === 'admin'"
>
<div class="post-form">
<div class="heading">
<h4>{{ $t('announcements.post_form_header') }}</h4>
</div>
<div class="body">
<announcement-editor
:announcement="newAnnouncement"
:disabled="posting"
/>
</div>
<div class="footer">
<button
class="btn button-default post-button"
:disabled="posting"
@click.prevent="postAnnouncement"
>
{{ $t('announcements.post_action') }}
</button>
<div
v-if="error"
class="alert error"
>
{{ $t('announcements.post_error', { error }) }}
<button
class="button-unstyled"
@click="clearError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
:title="$t('announcements.close_error')"
/>
</button>
</div>
</div>
</div>
</section>
<section
v-for="announcement in announcements"
:key="announcement.id"
>
<announcement
:announcement="announcement"
/>
</section>
</div>
</div>
</template>
<script src="./announcements_page.js"></script>
<style lang="scss">
@import "../../variables";
.announcements-page {
.post-form {
padding: var(--status-margin, $status-margin);
.heading, .body {
margin-bottom: var(--status-margin, $status-margin);
}
.post-button {
min-width: 10em;
}
}
}
</style>

View file

@ -18,7 +18,6 @@ import {
faPencilAlt,
faAlignRight
} from '@fortawesome/free-solid-svg-icons'
import Blurhash from '../blurhash/Blurhash.vue'
library.add(
faFile,
@ -64,8 +63,7 @@ const Attachment = {
components: {
Flash,
StillImage,
VideoAttachment,
Blurhash
VideoAttachment
},
computed: {
classNames () {
@ -86,9 +84,6 @@ const Attachment = {
useContainFit () {
return this.$store.getters.mergedConfig.useContainFit
},
useBlurhash () {
return this.$store.getters.mergedConfig.useBlurhash
},
placeholderName () {
if (this.attachment.description === '' || !this.attachment.description) {
return this.type.toUpperCase()
@ -131,15 +126,9 @@ const Attachment = {
videoTag () {
return this.useModal ? 'button' : 'span'
},
statusForm () {
return this.$parent.$parent
},
...mapGetters(['mergedConfig'])
},
watch: {
'attachment.description' (newVal) {
this.localDescription = newVal
},
localDescription (newVal) {
this.onEdit(newVal)
}
@ -210,29 +199,6 @@ const Attachment = {
const width = image.naturalWidth
const height = image.naturalHeight
this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height })
},
resize (e) {
const target = e.target || e
if (!(target instanceof window.Element)) { return }
// Reset to default height for empty form, nothing else to do here.
if (target.value === '') {
target.style.height = null
this.$emit('resize')
return
}
const paddingString = getComputedStyle(target)['padding']
// remove -px suffix
const padding = Number(paddingString.substring(0, paddingString.length - 2))
target.style.height = 'auto'
const newHeight = Math.floor(target.scrollHeight - padding * 2)
target.style.height = `${newHeight}px`
this.$emit('resize', newHeight)
},
postStatus (event) {
this.statusForm.postStatus(event, this.statusForm.newStatus)
}
}
}

View file

@ -16,7 +16,7 @@
.attachment-wrapper {
flex: 1 1 auto;
height: 200px;
height: 100%;
position: relative;
overflow: hidden;
}
@ -26,7 +26,6 @@
display: flex;
padding-top: 0.5em;
z-index: 1;
max-height: 50%;
p {
flex: 1;
@ -34,10 +33,9 @@
line-height: 1.5;
padding: 0.5em;
margin: 0;
white-space: pre-line;
word-break: break-word;
white-space: nowrap;
text-overflow: ellipsis;
overflow: scroll;
overflow: hidden;
}
&.-static {
@ -54,19 +52,6 @@
.description-field {
flex: 1;
min-width: 0;
box-sizing: content-box;
overflow: hidden;
transition: min-height 200ms 100ms;
padding-bottom: var(--_padding);
height: calc(var(--post-line-height) * 1em);
min-height: calc(var(--post-line-height) * 1em);
resize: none;
&.scrollable-form {
overflow-y: auto;
}
}
& .placeholder-container,

View file

@ -64,15 +64,7 @@
:title="attachment.description"
@click.prevent.stop="toggleHidden"
>
<Blurhash
v-if="useBlurhash && attachment.blurhash"
:height="512"
:width="1024"
:hash="attachment.blurhash"
:punch="1"
/>
<img
v-else
:key="nsfwImage"
class="nsfw"
:src="nsfwImage"
@ -256,16 +248,14 @@
class="description-container"
:class="{ '-static': !edit }"
>
<textarea
<input
v-if="edit"
v-model="localDescription"
type="text"
class="description-field"
:placeholder="$t('post_status.media_description')"
rows="1"
cols="1"
@keydown.ctrl.enter="postStatus()"
@input="resize"
/>
@keydown.enter.prevent=""
>
<p v-else>
{{ localDescription }}
</p>

View file

@ -1,4 +0,0 @@
export default {
computed: {
}
}

View file

@ -1,12 +0,0 @@
<template>
<div class="panel panel-default">
<div class="panel-heading">
<h4>{{ $t('registration.awaiting_email_confirmation_title') }}</h4>
</div>
<div class="panel-body">
<p>{{ $t('registration.awaiting_email_confirmation') }}</p>
</div>
</div>
</template>
<script src="./awaiting_email_confirmation.js"></script>

View file

@ -1,66 +0,0 @@
<template>
<canvas
ref="canvas"
class="blurhash"
/>
</template>
<script>
import { decode } from "blurhash";
export default {
name: 'Blurhash',
props: {
hash: {
type: String,
required: true,
},
width: {
type: Number,
required: true,
},
height: {
type: Number,
required: true,
},
punch: {
type: Number,
default: null,
},
},
data() {
return {
canvas: null,
ctx: null,
};
},
mounted() {
this.canvas = this.$refs.canvas;
this.ctx = this.canvas.getContext('2d');
this.canvas.width = 1024;
this.canvas.height = 512;
this.draw();
},
methods: {
draw() {
const pixels = decode(this.hash, this.width, this.height, this.punch);
const imageData = this.ctx.createImageData(this.width, this.height);
imageData.data.set(pixels);
this.ctx.putImageData(imageData, 0, 0);
fetch("/static/blurhash-overlay.png")
.then((response) => response.blob())
.then((blob) => {
const img = new Image();
img.src = URL.createObjectURL(blob);
img.onload = () => {
this.ctx.drawImage(img, 0, 0, this.width, this.height);
};
});
},
}
}
</script>
<style scoped>
</style>

View file

@ -1,18 +0,0 @@
import Timeline from '../timeline/timeline.vue'
const PublicTimeline = {
components: {
Timeline
},
computed: {
timeline () { return this.$store.state.statuses.timelines.bubble }
},
created () {
this.$store.dispatch('startFetchingTimeline', { timeline: 'bubble' })
},
unmounted () {
this.$store.dispatch('stopFetchingTimeline', 'bubble')
}
}
export default PublicTimeline

View file

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

346
src/components/chat/chat.js Normal file
View file

@ -0,0 +1,346 @@
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, 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 = 10
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.handleResize)
},
mounted () {
window.addEventListener('scroll', this.handleScroll)
if (typeof document.hidden !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
}
this.$nextTick(() => {
this.handleResize()
})
},
unmounted () {
window.removeEventListener('scroll', this.handleScroll)
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.layoutType === 'mobile',
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()
},
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()
})
},
handleVisibilityChange () {
this.$nextTick(() => {
if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
this.scrollDown({ forceRead: true })
}
})
},
// "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
handleResize (opts = {}) {
const { expand = false, delayed = false } = opts
if (delayed) {
setTimeout(() => {
this.handleResize({ ...opts, delayed: false })
}, SAFE_RESIZE_TIME_OFFSET)
return
}
this.$nextTick(() => {
const { offsetHeight = undefined } = getScrollPosition()
const diff = this.lastScrollPosition.offsetHeight - offsetHeight
if (diff !== 0 || (!this.bottomedOut() && expand)) {
this.$nextTick(() => {
window.scrollTo({ top: window.scrollY + diff })
})
}
this.lastScrollPosition = getScrollPosition()
})
},
scrollDown (options = {}) {
const { behavior = 'auto', forceRead = false } = options
this.$nextTick(() => {
window.scrollTo({ top: document.documentElement.scrollHeight, 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(offset)
},
reachedTop () {
return window.scrollY <= 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()
window.scrollTo({
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading)
})
},
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.$store.dispatch('addChatMessages', { chatId, messages }).then(() => {
this.$nextTick(() => {
if (fetchOlderMessages) {
this.handleScrollUp(positionBeforeUpdate)
}
// 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() && 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.
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

@ -0,0 +1,108 @@
.chat-view {
display: flex;
height: 100%;
.chat-view-inner {
height: auto;
width: 100%;
overflow: visible;
display: flex;
}
.chat-view-body {
box-sizing: border-box;
background-color: var(--chatBg, $fallback--bg);
display: flex;
flex-direction: column;
width: 100%;
overflow: visible;
min-height: calc(100vh - var(--navbar-height));
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;
}
}
.message-list {
padding: 0 0.8em;
height: 100%;
display: flex;
flex-direction: column;
justify-content: end;
}
.footer {
position: sticky;
bottom: 0;
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
z-index: 1;
}
.chat-view-heading {
grid-template-columns: auto minmax(50%, 1fr);
}
.go-back-button {
text-align: center;
line-height: 1;
height: 100%;
align-self: start;
width: var(--__panel-heading-height-inner);
}
.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%;
}
}
}
}

View file

@ -0,0 +1,100 @@
<template>
<div class="chat-view">
<div class="chat-view-inner">
<div
ref="inner"
class="panel-default panel chat-view-body"
>
<div
ref="header"
class="panel-heading -sticky chat-view-heading"
>
<button
class="button-unstyled go-back-button"
@click="goBack"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
</button>
<div class="title text-center">
<ChatTitle
:user="recipient"
:with-avatar="true"
/>
</div>
</div>
<div
class="message-list"
:style="{ height: scrollableContainerHeight }"
>
<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

@ -0,0 +1,24 @@
// Captures a scroll position
export const getScrollPosition = () => {
return {
scrollTop: window.scrollY,
scrollHeight: document.documentElement.scrollHeight,
offsetHeight: window.innerHeight
}
}
// 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 = (offset = 0) => {
const scrollHeight = window.scrollY + offset
const totalHeight = document.documentElement.scrollHeight - window.innerHeight
return totalHeight <= scrollHeight
}
// Returns whether or not the scrollbar is visible.
export const isScrollable = () => {
return document.documentElement.scrollHeight > window.innerHeight
}

View file

@ -0,0 +1,37 @@
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

@ -0,0 +1,64 @@
<template>
<div v-if="isNew">
<ChatNew @cancel="cancelNewChat" />
</div>
<div
v-else
class="chat-list panel panel-default"
>
<div class="panel-heading -sticky">
<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

@ -0,0 +1,69 @@
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

@ -0,0 +1,91 @@
.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: var(--post-line-height);
}
.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: var(--post-line-height);
}
.chat-preview-body {
padding-right: 1em;
}
}

View file

@ -0,0 +1,53 @@
<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

@ -0,0 +1,108 @@
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

@ -0,0 +1,179 @@
@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

@ -0,0 +1,103 @@
<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

@ -0,0 +1,83 @@
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

@ -0,0 +1,31 @@
.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 {
text-align: center;
line-height: 1;
height: 100%;
align-self: start;
width: var(--__panel-heading-height-inner);
}
}

View file

@ -0,0 +1,51 @@
<template>
<div
class="panel-default panel chat-new"
>
<div
ref="header"
class="panel-heading"
>
<button
class="button-unstyled go-back-button"
@click="goBack"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
</button>
</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,27 @@
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

@ -0,0 +1,65 @@
<template>
<div
class="chat-title"
:title="title"
>
<router-link
v-if="withAvatar && user"
class="avatar-container"
:to="getUserProfileLink(user)"
>
<UserAvatar
class="titlebar-avatar"
:user="user"
/>
</router-link>
<RichContent
v-if="user"
class="username"
:title="'@'+(user && 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;
--emoji-size: 14px;
.username {
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
display: inline;
word-wrap: break-word;
overflow: hidden;
}
.avatar-container {
align-self: center;
line-height: 1;
}
.titlebar-avatar {
margin-right: 0.5em;
height: 1.5em;
width: 1.5em;
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
&.animated::before {
display: none;
}
}
}
</style>

View file

@ -28,7 +28,7 @@
flex: 0 0 2em;
min-width: 2em;
align-self: center;
height: 2em;
height: 100%;
}
}
.computedIndicator,

View file

@ -1,37 +0,0 @@
import DialogModal from '../dialog_modal/dialog_modal.vue'
/**
* This component emits the following events:
* cancelled, emitted when the action should not be performed;
* accepted, emitted when the action should be performed;
*
* The caller should close this dialog after receiving any of the two events.
*/
const ConfirmModal = {
components: {
DialogModal
},
props: {
title: {
type: String
},
cancelText: {
type: String
},
confirmText: {
type: String
}
},
computed: {
},
methods: {
onCancel () {
this.$emit('cancelled')
},
onAccept () {
this.$emit('accepted')
}
}
}
export default ConfirmModal

View file

@ -1,39 +0,0 @@
<template>
<dialog-modal
v-body-scroll-lock="true"
class="confirm-modal"
:on-cancel="onCancel"
>
<template #header>
<span v-text="title" />
</template>
<slot />
<template #footer>
<button
class="btn button-default"
@click.prevent="onCancel"
v-text="cancelText"
/>
<button
class="btn button-default button-positive"
@click.prevent="onAccept"
v-text="confirmText"
/>
</template>
</dialog-modal>
</template>
<style lang="scss" scoped>
@import '../../_variables';
.confirm-modal {
.button-positive {
border: 3px solid var(--accent, $fallback--link);
border-radius: var(--btnRadius, $fallback--btnRadius);
}
}
</style>
<script src="./confirm_modal.js"></script>

View file

@ -1,8 +1,6 @@
import { reduce, filter, findIndex, clone, get } from 'lodash'
import Status from '../status/status.vue'
import ThreadTree from '../thread_tree/thread_tree.vue'
import { WSConnectionStatus } from '../../services/api/api.service.js'
import { mapGetters, mapState } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@ -79,9 +77,6 @@ const conversation = {
const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1
},
streamingEnabled () {
return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
},
displayStyle () {
return this.$store.getters.mergedConfig.conversationDisplay
},
@ -344,11 +339,7 @@ const conversation = {
},
maybeHighlight () {
return this.isExpanded ? this.highlight : null
},
...mapGetters(['mergedConfig']),
...mapState({
mastoUserSocketStatus: state => state.api.mastoUserSocketStatus
})
}
},
components: {
Status,
@ -404,11 +395,6 @@ const conversation = {
setHighlight (id) {
if (!id) return
this.highlight = id
if (!this.streamingEnabled) {
this.$store.dispatch('fetchStatus', id)
}
this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
},

View file

@ -1,54 +1,36 @@
import SearchBar from 'components/search_bar/search_bar.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
publicTimelineVisible,
federatedTimelineVisible,
bubbleTimelineVisible,
} from '../../lib/timeline_visibility'
import {
faSignInAlt,
faSignOutAlt,
faHome,
faComments,
faBell,
faUserPlus,
faBullhorn,
faSearch,
faTachometerAlt,
faCog,
faGlobe,
faBolt,
faUsers,
faCommentMedical,
faBookmark,
faInfoCircle,
faUserTie
faInfoCircle
} from '@fortawesome/free-solid-svg-icons'
import { mapState } from 'vuex'
library.add(
faSignInAlt,
faSignOutAlt,
faHome,
faComments,
faBell,
faUserPlus,
faBullhorn,
faSearch,
faTachometerAlt,
faCog,
faGlobe,
faBolt,
faUsers,
faCommentMedical,
faBookmark,
faInfoCircle,
faUserTie
faInfoCircle
)
export default {
components: {
SearchBar,
ConfirmModal
SearchBar
},
data: () => ({
searchBarHidden: true,
@ -58,8 +40,7 @@ export default {
window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain')
),
showingConfirmLogout: false
)
}),
computed: {
enableMask () { return this.supportsMask && this.$store.state.instance.logoMask },
@ -84,50 +65,25 @@ export default {
})
},
logo () { return this.$store.state.instance.logo },
mergedConfig () {
return this.$store.getters.mergedConfig
},
sitename () { return this.$store.state.instance.name },
showNavShortcuts () {
return this.mergedConfig.showNavShortcuts
},
showWiderShortcuts () {
return this.mergedConfig.showWiderShortcuts
},
hideSiteFavicon () {
return this.mergedConfig.hideSiteFavicon
},
hideSiteName () {
return this.mergedConfig.hideSiteName
},
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 },
shouldConfirmLogout () {
return this.$store.getters.mergedConfig.modalOnLogout
},
showBubbleTimeline () {
return this.$store.state.instance.localBubbleInstances.length > 0
},
...mapState({
publicTimelineVisible,
federatedTimelineVisible,
bubbleTimelineVisible,
})
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')
},
openModModal () {
this.$store.dispatch('openModModal')
}
}
}

View file

@ -3,10 +3,6 @@
.DesktopNav {
width: 100%;
input {
color: var(--inputTopbarText, var(--inputText));
}
a {
color: var(--topBarLink, $fallback--link);
}
@ -15,16 +11,16 @@
display: grid;
grid-template-rows: var(--navbar-height);
grid-template-columns: 2fr auto 2fr;
grid-template-areas: "nav-left logo actions";
grid-template-areas: "sitename logo actions";
box-sizing: border-box;
padding: 0 1.2em;
margin: auto;
max-width: 1110px;
max-width: 980px;
}
&.-logoLeft .inner-nav {
grid-template-columns: auto 2fr 2fr;
grid-template-areas: "logo nav-left actions";
grid-template-areas: "logo sitename actions";
}
.button-default {
@ -89,43 +85,14 @@
height: 100%;
text-align: center;
&.router-link-active {
font-size: 1.2em;
margin-top: 0.05em;
.svg-inline--fa {
font-weight: bolder;
color: $fallback--text;
color: var(--selectedMenuText, $fallback--text);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
}
}
.svg-inline--fa {
color: $fallback--link;
color: var(--topBarLink, $fallback--link);
}
}
.-wide {
.nav-icon {
margin-left: 0.7em;
}
}
.left {
padding-left: 5px;
display: flex;
}
.nav-left-wrapper {
grid-area: nav-left;
.favicon {
height: 28px;
vertical-align: middle;
padding-right: 5px;
}
.sitename {
grid-area: sitename;
}
.actions {

View file

@ -5,81 +5,16 @@
:class="{ '-logoLeft': logoLeft }"
@click="scrollToTop()"
>
<div
class="inner-nav"
:class="{ '-wide': showWiderShortcuts }"
>
<div class="item nav-left-wrapper">
<div class="inner-nav">
<div class="item sitename">
<router-link
class="site-brand"
v-if="!hideSitename"
class="site-name"
:to="{ name: 'root' }"
active-class="home"
>
<img
v-if="!hideSiteFavicon"
class="favicon"
src="/favicon.png"
>
<span
v-if="!hideSiteName"
class="site-name"
>
{{ sitename }}
</span>
{{ sitename }}
</router-link>
<div
v-if="(currentUser || !privateMode) && showNavShortcuts"
class="nav-items left"
>
<router-link
v-if="currentUser"
:to="{ name: 'friends' }"
class="nav-icon"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="home"
:title="$t('nav.home_timeline')"
/>
</router-link>
<router-link
:to="{ name: 'public-timeline' }"
class="nav-icon"
v-if="publicTimelineVisible"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="users"
:title="$t('nav.public_tl')"
/>
</router-link>
<router-link
v-if="bubbleTimelineVisible"
:to="{ name: 'bubble-timeline' }"
class="nav-icon"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="circle"
:title="$t('nav.bubble_timeline')"
/>
</router-link>
<router-link
:to="{ name: 'public-external-timeline' }"
class="nav-icon"
v-if="federatedTimelineVisible"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="globe"
:title="$t('nav.twkn')"
/>
</router-link>
</div>
</div>
<router-link
class="logo"
@ -101,47 +36,6 @@
@toggled="onSearchBarToggled"
@click.stop
/>
<div
v-if="(currentUser || !privateMode) && showNavShortcuts"
class="nav-items right"
>
<router-link
v-if="currentUser"
class="nav-icon"
:to="{ name: 'interactions', params: { username: currentUser.screen_name } }"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="bolt"
:title="$t('nav.interactions')"
/>
</router-link>
<router-link
v-if="currentUser"
:to="{ name: 'lists' }"
class="nav-icon"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="list"
:title="$t('nav.lists')"
/>
</router-link>
<router-link
v-if="currentUser"
:to="{ name: 'bookmarks' }"
class="nav-icon"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="bookmark"
:title="$t('nav.bookmarks')"
/>
</router-link>
</div>
<button
class="button-unstyled nav-icon"
@click.stop="openSettingsModal"
@ -153,18 +47,6 @@
:title="$t('nav.preferences')"
/>
</button>
<button
v-if="currentUser && currentUser.role === 'admin' || currentUser.role === 'moderator'"
class="button-unstyled nav-icon"
@click.stop="openModModal"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="user-tie"
:title="$t('nav.moderation')"
/>
</button>
<a
v-if="currentUser && currentUser.role === 'admin'"
href="/pleroma/admin/#/login-pleroma"
@ -179,20 +61,20 @@
: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>
<teleport to="#modal">
<confirm-modal
v-if="showingConfirmLogout"
:title="$t('login.logout_confirm_title')"
:confirm-text="$t('login.logout_confirm_accept_button')"
:cancel-text="$t('login.logout_confirm_cancel_button')"
@accepted="doLogout"
@cancelled="hideConfirmLogout"
>
{{ $t('login.logout_confirm') }}
</confirm-modal>
</teleport>
</nav>
</template>
<script src="./desktop_nav.js"></script>

View file

@ -39,7 +39,7 @@
right: 0;
top: 0;
background: rgba(27,31,35,.5);
z-index: 2000;
z-index: 99;
}
}
@ -51,10 +51,9 @@
margin: 15vh auto;
position: fixed;
transform: translateX(-50%);
z-index: 2001;
z-index: 999;
cursor: default;
display: block;
width: max-content;
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);

View file

@ -1,75 +0,0 @@
import PostStatusForm from '../post_status_form/post_status_form.vue'
import Modal from '../modal/modal.vue'
import statusPosterService from '../../services/status_poster/status_poster.service.js'
import get from 'lodash/get'
const EditStatusModal = {
components: {
PostStatusForm,
Modal
},
data () {
return {
resettingForm: false
}
},
computed: {
isLoggedIn () {
return !!this.$store.state.users.currentUser
},
modalActivated () {
return this.$store.state.editStatus.modalActivated
},
isFormVisible () {
return this.isLoggedIn && !this.resettingForm && this.modalActivated
},
params () {
return this.$store.state.editStatus.params || {}
}
},
watch: {
params (newVal, oldVal) {
if (get(newVal, 'statusId') !== get(oldVal, 'statusId')) {
this.resettingForm = true
this.$nextTick(() => {
this.resettingForm = false
})
}
},
isFormVisible (val) {
if (val) {
this.$nextTick(() => this.$el && this.$el.querySelector('textarea').focus())
}
}
},
methods: {
doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) {
const params = {
store: this.$store,
statusId: this.$store.state.editStatus.params.statusId,
status,
spoilerText,
sensitive,
poll,
media,
contentType
}
return statusPosterService.editStatus(params)
.then((data) => {
return data
})
.catch((err) => {
console.error('Error editing status', err)
return {
error: err.message
}
})
},
closeModal () {
this.$store.dispatch('closeEditStatusModal')
}
}
}
export default EditStatusModal

View file

@ -1,48 +0,0 @@
<template>
<Modal
v-if="isFormVisible"
class="edit-form-modal-view"
@backdropClicked="closeModal"
>
<div class="edit-form-modal-panel panel">
<div class="panel-heading">
{{ $t('post_status.edit_status') }}
</div>
<PostStatusForm
class="panel-body"
v-bind="params"
@posted="closeModal"
:disablePolls="true"
:disableVisibilitySelector="true"
:post-handler="doEditStatus"
/>
</div>
</Modal>
</template>
<script src="./edit_status_modal.js"></script>
<style lang="scss">
.modal-view.edit-form-modal-view {
align-items: flex-start;
}
.edit-form-modal-panel {
flex-shrink: 0;
margin-top: 25%;
margin-bottom: 2em;
width: 100%;
max-width: 700px;
@media (orientation: landscape) {
margin-top: 8%;
}
.form-bottom-left {
max-width: 6.5em;
.emoji-icon {
justify-content: right;
}
}
}
</style>

View file

@ -1,133 +0,0 @@
const EMOJI_SIZE = 32 + 8
const GROUP_TITLE_HEIGHT = 24
const BUFFER_SIZE = 3 * EMOJI_SIZE
const EmojiGrid = {
props: {
groups: {
required: true,
type: Array
}
},
data () {
return {
containerWidth: 0,
containerHeight: 0,
scrollPos: 0,
resizeObserver: null
}
},
mounted () {
const rect = this.$refs.container.getBoundingClientRect()
this.containerWidth = rect.width
this.containerHeight = rect.height
this.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
this.containerWidth = entry.contentRect.width
this.containerHeight = entry.contentRect.height
}
})
this.resizeObserver.observe(this.$refs.container)
},
beforeUnmount () {
this.resizeObserver.disconnect()
this.resizeObserver = null
},
watch: {
groups () {
// Scroll to top when grid content changes
if (this.$refs.container) {
this.$refs.container.scrollTo(0, 0)
}
},
activeGroup (group) {
this.$emit('activeGroup', group)
}
},
methods: {
onScroll () {
this.scrollPos = this.$refs.container.scrollTop
},
onEmoji (emoji) {
this.$emit('emoji', emoji)
},
scrollToItem (itemId) {
const container = this.$refs.container
if (!container) return
for (const item of this.itemList) {
if (item.id === itemId) {
container.scrollTo(0, item.position.y)
return
}
}
}
},
computed: {
// Total height of scroller content
gridHeight () {
if (this.itemList.length === 0) return 0
const lastItem = this.itemList[this.itemList.length - 1]
return (
lastItem.position.y +
('title' in lastItem ? GROUP_TITLE_HEIGHT : EMOJI_SIZE)
)
},
activeGroup () {
const items = this.itemList
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i]
if ('title' in item && item.position.y <= this.scrollPos) {
return item.id
}
}
return null
},
itemList () {
const items = []
let x = 0
let y = 0
for (const group of this.groups) {
items.push({ position: { x, y }, id: group.id, title: group.text })
if (group.text.length) {
y += GROUP_TITLE_HEIGHT
}
for (const emoji of group.emojis) {
items.push({
position: { x, y },
id: `${group.id}-${emoji.displayText}`,
emoji
})
x += EMOJI_SIZE
if (x + EMOJI_SIZE > this.containerWidth) {
y += EMOJI_SIZE
x = 0
}
}
if (x > 0) {
y += EMOJI_SIZE
x = 0
}
}
return items
},
visibleItems () {
const startPos = this.scrollPos - BUFFER_SIZE
const endPos = this.scrollPos + this.containerHeight + BUFFER_SIZE
return this.itemList.filter((i) => {
return i.position.y >= startPos && i.position.y < endPos
})
},
scrolledClass () {
if (this.scrollPos <= 5) {
return 'scrolled-top'
} else if (this.scrollPos >= this.gridHeight - this.containerHeight - 5) {
return 'scrolled-bottom'
} else {
return 'scrolled-middle'
}
}
}
}
export default EmojiGrid

View file

@ -1,60 +0,0 @@
.emoji {
&-grid {
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;
}
}
margin-left: 5px;
min-height: 200px;
}
&-group-title {
position: absolute;
font-size: 0.85em;
width: 100%;
margin: 0;
height: 24px;
display: flex;
align-items: end;
&.disabled {
display: none;
}
}
&-item {
position: absolute;
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,48 +0,0 @@
<template>
<div
ref="container"
class="emoji-grid"
:class="scrolledClass"
@scroll.passive="onScroll"
>
<div
:style="{
height: `${gridHeight}px`,
}"
>
<template v-for="item in visibleItems">
<h6
v-if="'title' in item && item.title.length"
:key="'title-' + item.id"
class="emoji-group-title"
:style="{
top: item.position.y + 'px',
left: item.position.x + 'px'
}"
>
{{ item.title }}
</h6>
<span
v-else-if="'emoji' in item"
:key="'emoji-' + item.id"
class="emoji-item"
:title="item.emoji.displayText"
:style="{
top: item.position.y + 'px',
left: item.position.x + 'px'
}"
@click.stop.prevent="onEmoji(item.emoji)"
>
<span v-if="!item.emoji.imageUrl">{{ item.emoji.replacement }}</span>
<img
v-else
:src="item.emoji.imageUrl"
>
</span>
</template>
</div>
</div>
</template>
<script src="./emoji_grid.js"></script>
<style lang="scss" src="./emoji_grid.scss"></style>

View file

@ -178,7 +178,7 @@ const EmojiInput = {
textAtCaret: async function (newWord) {
const firstchar = newWord.charAt(0)
this.suggestions = []
if (newWord === firstchar && firstchar !== '$') return
if (newWord === firstchar) return
const matchedSuggestions = await this.suggest(newWord)
// Async: cancel if textAtCaret has changed during wait
if (this.textAtCaret !== newWord) return
@ -205,6 +205,7 @@ const EmojiInput = {
},
triggerShowPicker () {
this.showPicker = true
this.$refs.picker.startEmojiLoad()
this.$nextTick(() => {
this.scrollIntoView()
this.focusPickerInput()
@ -222,6 +223,7 @@ const EmojiInput = {
this.showPicker = !this.showPicker
if (this.showPicker) {
this.scrollIntoView()
this.$refs.picker.startEmojiLoad()
this.$nextTick(this.focusPickerInput)
}
},
@ -275,6 +277,7 @@ const EmojiInput = {
},
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
@ -488,10 +491,11 @@ const EmojiInput = {
},
setPlacement (container, target, offsetBottom) {
if (!container || !target) return
if (this.placement === 'bottom' || (this.placement === 'auto' && !this.overflowsBottom(container))) {
target.style.top = offsetBottom + 'px'
target.style.bottom = 'auto'
} else {
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'
}

View file

@ -18,7 +18,6 @@
<EmojiPicker
v-if="enableEmojiPicker"
ref="picker"
show-keep-open
:class="{ hide: !showPicker }"
:enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel"
@ -43,7 +42,7 @@
:class="{ highlighted: index === highlighted }"
@click.stop.prevent="onClick($event, suggestion)"
>
<span v-if="!suggestion.mfm" class="image">
<span class="image">
<img
v-if="suggestion.img"
:src="suggestion.img"

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