Merge branch 'develop' of https://git.pleroma.social/pleroma/pleroma-fe into feat/report-notification

This commit is contained in:
Ilja 2022-02-26 02:08:13 +01:00
commit d0c4ad22cd
225 changed files with 11974 additions and 3981 deletions

View file

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

1
.mailmap Normal file
View file

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

View file

@ -3,10 +3,88 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## Unreleased
### Fixed
- AdminFE button no longer scrolls page to top when clicked
- Pinned statuses no longer appear at bottom of user timeline (still appear as part of the timeline when fetched deep enough)
- Fixed many many bugs related to new mentions, including spacing and alignment issues
- Links in profile bios now properly open in new tabs
- Inline images now respect their intended width/height attributes
- Links with `&` in them work properly now
- Interaction list popovers now properly emojify names
- Completely hidden posts still had 1px border
- Attachments are ALWAYS in same order as user uploaded, no more "videos first"
- Attachment description is prefilled with backend-provided default when uploading
- Proper visual feedback that next image is loading when browsing
### Changed
- (You)s are optional (opt-in) now, bolding your nickname is also optional (opt-out)
- User highlight background now also covers the `@`
- Reverted back to textual `@`, svg version is opt-in.
- Settings window has been throughly rearranged to make make more sense and make navication settings easier.
- Uploaded attachments are uniform with displayed attachments
- Flash is watchable in media-modal (takes up nearly full screen though due to sizing issues)
- Notifications about likes/repeats/emoji reacts are now minimized so they always take up same amount of space irrelevant to size of post.
### Added
- Options to show domains in mentions
- Option to show user avatars in mention links (opt-in)
- Option to disable the tooltip for mentions
- Option to completely hide muted threads
- Ability to open videos in modal even if you disabled that feature, via an icon button
- New button on attachment that indicates that attachment has a description and shows a bar filled with description
- Attachments are truncated just like post contents
- Media modal now also displays description and counter position in gallery (i.e. 1/5)
- Ability to rearrange order of attachments when uploading
## [2.4.2] - 2022-01-09
### Added
- Added Apply and Reset buttons to the bottom of theme tab to minimize UI travel
- Implemented user option to always show floating New Post button (normally mobile-only)
- Display reasons for instance specific policies
- Added functionality to cancel follow request
### Fixed
- Fixed link to external profile not working on user profiles
- Fixed mobile shoutbox display
- Fixed favicon badge not working in Chrome
- Escape html more properly in subject/display name
## [2.4.0] - 2021-08-08
### Added
- Added a quick settings to timeline header for easier access
- Added option to mark posts as sensitive by default
- Added quick filters for notifications
- Implemented user option to change sidebar position to the right side
- Implemented user option to hide floating shout panel
- Implemented "edit profile" button if viewing own profile which opens profile settings
### Fixed
- Fixed follow request count showing in the wrong location in mobile view
## [2.3.0] - 2021-03-01
### Fixed ### Fixed
- Button to remove uploaded media in post status form is now properly placed and sized. - Button to remove uploaded media in post status form is now properly placed and sized.
- Fixed shoutbox not working in mobile layout - Fixed shoutbox not working in mobile layout
- Fixed missing highlighted border in expanded conversations again
- Fixed some UI jumpiness when opening images particularly in chat view
- Fixed chat unread badge looking weird
- Fixed punycode names not working properly
- Fixed notifications crashing on an invalid notification
### Changed
- Display 'people voted' instead of 'votes' for multi-choice polls
- Changed the "Timelines" link in side panel to toggle show all timeline options inside the panel
- Renamed "Timeline" to "Home Timeline" to be more clear
- Optimized chat to not get horrible performance after keeping the same chat open for a long time
- When opening emoji picker or react picker, it automatically focuses the search field
- Language picker now uses native language names
### Added
- Added reason field for registration when approval is required
- Group staff members by role in the About page
## [2.2.3] - 2021-01-18 ## [2.2.3] - 2021-01-18
@ -16,10 +94,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Fixed ### Fixed
- Follows/Followers tabs on user profiles now display the content properly. - Follows/Followers tabs on user profiles now display the content properly.
- Handle punycode in screen names - Handle punycode in screen names
- Fixed local dev mode having non-functional websockets in some cases
- Show notices for websocket events (errors, abnormal closures, reconnections)
- Fix not being able to re-enable websocket until page refresh
- Fix annoying issue where timeline might have few posts when streaming is enabled
### Changed ### Changed
- Don't filter own posts when they hit your wordfilter - Don't filter own posts when they hit your wordfilter
- Language picker now uses native language names
## [2.2.2] - 2020-12-22 ## [2.2.2] - 2020-12-22

View file

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

View file

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

View file

@ -3,6 +3,7 @@ var config = require('../config')
var utils = require('./utils') var utils = require('./utils')
var projectRoot = path.resolve(__dirname, '../') var projectRoot = path.resolve(__dirname, '../')
var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin') var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin')
var CopyPlugin = require('copy-webpack-plugin');
var env = process.env.NODE_ENV var env = process.env.NODE_ENV
// check env & config/index.js to decide weither to enable CSS Sourcemaps for the // check env & config/index.js to decide weither to enable CSS Sourcemaps for the
@ -93,6 +94,19 @@ module.exports = {
new ServiceWorkerWebpackPlugin({ new ServiceWorkerWebpackPlugin({
entry: path.join(__dirname, '..', 'src/sw.js'), entry: path.join(__dirname, '..', 'src/sw.js'),
filename: 'sw-pleroma.js' filename: 'sw-pleroma.js'
}),
// 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

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

View file

@ -32,9 +32,9 @@
"phoenix": "^1.3.0", "phoenix": "^1.3.0",
"portal-vue": "^2.1.4", "portal-vue": "^2.1.4",
"punycode.js": "^2.1.0", "punycode.js": "^2.1.0",
"ruffle-mirror": "^2021.4.10",
"v-click-outside": "^2.1.1", "v-click-outside": "^2.1.1",
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-chat-scroll": "^1.2.1",
"vue-i18n": "^7.3.2", "vue-i18n": "^7.3.2",
"vue-router": "^3.0.1", "vue-router": "^3.0.1",
"vue-template-compiler": "^2.6.11", "vue-template-compiler": "^2.6.11",
@ -47,8 +47,8 @@
"@babel/preset-env": "^7.7.6", "@babel/preset-env": "^7.7.6",
"@babel/register": "^7.7.4", "@babel/register": "^7.7.4",
"@ungap/event-target": "^0.1.0", "@ungap/event-target": "^0.1.0",
"@vue/babel-helper-vue-jsx-merge-props": "^1.0.0", "@vue/babel-helper-vue-jsx-merge-props": "^1.2.1",
"@vue/babel-plugin-transform-vue-jsx": "^1.1.2", "@vue/babel-preset-jsx": "^1.2.4",
"@vue/test-utils": "^1.0.0-beta.26", "@vue/test-utils": "^1.0.0-beta.26",
"autoprefixer": "^6.4.0", "autoprefixer": "^6.4.0",
"babel-eslint": "^7.0.0", "babel-eslint": "^7.0.0",
@ -58,6 +58,7 @@
"chalk": "^1.1.3", "chalk": "^1.1.3",
"chromedriver": "^87.0.1", "chromedriver": "^87.0.1",
"connect-history-api-fallback": "^1.1.0", "connect-history-api-fallback": "^1.1.0",
"copy-webpack-plugin": "^6.4.1",
"cross-spawn": "^4.0.2", "cross-spawn": "^4.0.2",
"css-loader": "^0.28.0", "css-loader": "^0.28.0",
"custom-event-polyfill": "^1.0.7", "custom-event-polyfill": "^1.0.7",
@ -103,7 +104,7 @@
"selenium-server": "2.53.1", "selenium-server": "2.53.1",
"semver": "^5.3.0", "semver": "^5.3.0",
"serviceworker-webpack-plugin": "^1.0.0", "serviceworker-webpack-plugin": "^1.0.0",
"shelljs": "^0.7.4", "shelljs": "^0.8.4",
"sinon": "^2.1.0", "sinon": "^2.1.0",
"sinon-chai": "^2.8.0", "sinon-chai": "^2.8.0",
"stylelint": "^13.6.1", "stylelint": "^13.6.1",
@ -112,7 +113,7 @@
"url-loader": "^1.1.2", "url-loader": "^1.1.2",
"vue-loader": "^14.0.0", "vue-loader": "^14.0.0",
"vue-style-loader": "^4.0.0", "vue-style-loader": "^4.0.0",
"webpack": "^4.0.0", "webpack": "^4.44.0",
"webpack-dev-middleware": "^3.6.0", "webpack-dev-middleware": "^3.6.0",
"webpack-hot-middleware": "^2.12.2", "webpack-hot-middleware": "^2.12.2",
"webpack-merge": "^0.14.1" "webpack-merge": "^0.14.1"

View file

@ -4,7 +4,7 @@ import Notifications from './components/notifications/notifications.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue' import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue' import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue' import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import ChatPanel from './components/chat_panel/chat_panel.vue' import ShoutPanel from './components/shout_panel/shout_panel.vue'
import SettingsModal from './components/settings_modal/settings_modal.vue' import SettingsModal from './components/settings_modal/settings_modal.vue'
import MediaModal from './components/media_modal/media_modal.vue' import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue' import SideDrawer from './components/side_drawer/side_drawer.vue'
@ -26,7 +26,7 @@ export default {
InstanceSpecificPanel, InstanceSpecificPanel,
FeaturesPanel, FeaturesPanel,
WhoToFollowPanel, WhoToFollowPanel,
ChatPanel, ShoutPanel,
MediaModal, MediaModal,
SideDrawer, SideDrawer,
MobilePostStatusButton, MobilePostStatusButton,
@ -65,7 +65,7 @@ export default {
} }
} }
}, },
chat () { return this.$store.state.chat.channel.state === 'joined' }, shout () { return this.$store.state.shout.channel.state === 'joined' },
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled }, suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () { showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel && return this.$store.state.instance.showInstanceSpecificPanel &&
@ -73,11 +73,17 @@ export default {
this.$store.state.instance.instanceSpecificPanelContent this.$store.state.instance.instanceSpecificPanelContent
}, },
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
shoutboxPosition () {
return this.$store.getters.mergedConfig.showNewPostButton || false
},
hideShoutbox () {
return this.$store.getters.mergedConfig.hideShoutbox
},
isMobileLayout () { return this.$store.state.interface.mobileLayout }, isMobileLayout () { return this.$store.state.interface.mobileLayout },
privateMode () { return this.$store.state.instance.private }, privateMode () { return this.$store.state.instance.private },
sidebarAlign () { sidebarAlign () {
return { return {
'order': this.$store.state.instance.sidebarRight ? 99 : 0 'order': this.$store.getters.mergedConfig.sidebarRight ? 99 : 0
} }
}, },
...mapGetters(['mergedConfig']) ...mapGetters(['mergedConfig'])

View file

@ -88,6 +88,10 @@ a {
font-family: sans-serif; font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif); font-family: var(--interfaceFont, sans-serif);
&.-sublime {
background: transparent;
}
i[class*=icon-], i[class*=icon-],
.svg-inline--fa { .svg-inline--fa {
color: $fallback--text; color: $fallback--text;
@ -187,7 +191,7 @@ a {
} }
} }
input, textarea, .select, .input { input, textarea, .input {
&.unstyled { &.unstyled {
border-radius: 0; border-radius: 0;
@ -217,47 +221,11 @@ input, textarea, .select, .input {
hyphens: none; hyphens: none;
padding: 8px .5em; padding: 8px .5em;
&.select { &:disabled, &[disabled=disabled], &.disabled {
padding: 0;
}
&:disabled, &[disabled=disabled] {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
} }
.select-down-icon {
position: absolute;
top: 0;
bottom: 0;
right: 5px;
height: 100%;
color: $fallback--text;
color: var(--inputText, $fallback--text);
line-height: 28px;
z-index: 0;
pointer-events: none;
}
select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background: transparent;
border: none;
color: $fallback--text;
color: var(--inputText, --text, $fallback--text);
margin: 0;
padding: 0 2em 0 .2em;
font-family: sans-serif;
font-family: var(--inputFont, sans-serif);
font-size: 14px;
width: 100%;
z-index: 1;
height: 28px;
line-height: 16px;
}
&[type=range] { &[type=range] {
background: none; background: none;
border: none; border: none;
@ -547,9 +515,21 @@ main-router {
border-radius: var(--panelRadius, $fallback--panelRadius); border-radius: var(--panelRadius, $fallback--panelRadius);
} }
.panel-footer { /* TODO Should remove timeline-footer from here when we refactor panels into
* separate component and utilize slots
*/
.panel-footer, .timeline-footer {
display: flex;
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
flex: none;
padding: 0.6em 0.6em;
text-align: left;
line-height: 28px;
align-items: baseline;
border-width: 1px 0 0 0;
border-style: solid;
border-color: var(--border, $fallback--border);
.faint { .faint {
color: $fallback--faint; color: $fallback--faint;
@ -586,6 +566,7 @@ nav {
color: var(--faint, $fallback--faint); color: var(--faint, $fallback--faint);
box-shadow: 0px 0px 4px rgba(0,0,0,.6); box-shadow: 0px 0px 4px rgba(0,0,0,.6);
box-shadow: var(--topBarShadow); box-shadow: var(--topBarShadow);
box-sizing: border-box;
} }
.fade-enter-active, .fade-leave-active { .fade-enter-active, .fade-leave-active {
@ -705,6 +686,15 @@ nav {
color: var(--alertWarningPanelText, $fallback--text); color: var(--alertWarningPanelText, $fallback--text);
} }
} }
&.success {
background-color: var(--alertSuccess, $fallback--alertWarning);
color: var(--alertSuccessText, $fallback--text);
.panel-heading & {
color: var(--alertSuccessPanelText, $fallback--text);
}
}
} }
.faint { .faint {
@ -808,13 +798,6 @@ nav {
} }
} }
.select-multiple {
display: flex;
.option-list {
margin: 0;
padding-left: .5em;
}
}
.setting-list, .setting-list,
.option-list{ .option-list{
list-style-type: none; list-style-type: none;
@ -862,15 +845,9 @@ nav {
.new-status-notification { .new-status-notification {
position: relative; position: relative;
margin-top: -1px;
font-size: 1.1em; font-size: 1.1em;
border-width: 1px 0 0 0;
border-style: solid;
border-color: var(--border, $fallback--border);
padding: 10px;
z-index: 1; z-index: 1;
background-color: $fallback--fg; flex: 1;
background-color: var(--panel, $fallback--fg);
} }
.chat-layout { .chat-layout {
@ -878,6 +855,11 @@ nav {
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
// Get rid of scrollbar on body as scrolling happens on different element
body {
overflow: hidden;
}
// Ensures the fixed position of the mobile browser bars on scroll up / down events. // Ensures the fixed position of the mobile browser bars on scroll up / down events.
// Prevents the mobile browser bars from overlapping or hiding the message posting form. // Prevents the mobile browser bars from overlapping or hiding the message posting form.
@media all and (max-width: 800px) { @media all and (max-width: 800px) {

View file

@ -49,10 +49,11 @@
</div> </div>
<media-modal /> <media-modal />
</div> </div>
<chat-panel <shout-panel
v-if="currentUser && chat" v-if="currentUser && shout && !hideShoutbox"
:floating="true" :floating="true"
class="floating-chat mobile-hidden" class="floating-shout mobile-hidden"
:class="{ 'left': shoutboxPosition }"
/> />
<MobilePostStatusButton /> <MobilePostStatusButton />
<UserReportingModal /> <UserReportingModal />

View file

@ -51,6 +51,7 @@ const getInstanceConfig = async ({ store }) => {
const vapidPublicKey = data.pleroma.vapid_public_key const vapidPublicKey = data.pleroma.vapid_public_key
store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit }) store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit })
store.dispatch('setInstanceOption', { name: 'accountApprovalRequired', value: data.approval_required })
if (vapidPublicKey) { if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey }) store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
@ -239,7 +240,7 @@ const getNodeInfo = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations }) store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations })
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') }) 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: 'safeDM', value: features.includes('safe_dm_mentions') })
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') }) store.dispatch('setInstanceOption', { name: 'shoutAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') }) store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })

View file

@ -16,7 +16,7 @@ import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue' import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import Notifications from 'components/notifications/notifications.vue' import Notifications from 'components/notifications/notifications.vue'
import AuthForm from 'components/auth_form/auth_form.js' import AuthForm from 'components/auth_form/auth_form.js'
import ChatPanel from 'components/chat_panel/chat_panel.vue' import ShoutPanel from 'components/shout_panel/shout_panel.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue' import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import About from 'components/about/about.vue' import About from 'components/about/about.vue'
import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue' import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
@ -64,7 +64,7 @@ export default (store) => {
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute }, { name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute }, { name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm }, { name: 'login', path: '/login', component: AuthForm },
{ name: 'chat-panel', path: '/chat-panel', component: ChatPanel, props: () => ({ floating: false }) }, { 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: '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: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute }, { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },

View file

@ -6,10 +6,7 @@
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
remove-padding remove-padding
> >
<div <template v-slot:content>
slot="content"
class="account-tools-popover"
>
<div class="dropdown-menu"> <div class="dropdown-menu">
<template v-if="relationship.following"> <template v-if="relationship.following">
<button <button
@ -59,16 +56,15 @@
{{ $t('user_card.message') }} {{ $t('user_card.message') }}
</button> </button>
</div> </div>
</div> </template>
<div <template v-slot:trigger>
slot="trigger" <button class="button-unstyled ellipsis-button">
class="ellipsis-button"
>
<FAIcon <FAIcon
class="icon" class="icon"
icon="ellipsis-v" icon="ellipsis-v"
/> />
</div> </button>
</template>
</Popover> </Popover>
</div> </div>
</template> </template>
@ -83,7 +79,6 @@
} }
.ellipsis-button { .ellipsis-button {
cursor: pointer;
width: 2.5em; width: 2.5em;
margin: -0.5em 0; margin: -0.5em 0;
padding: 0.5em 0; padding: 0.5em 0;

View file

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

View file

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

View file

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

View file

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

View file

@ -25,24 +25,18 @@
:title="user.name" :title="user.name"
class="basic-user-card-user-name" class="basic-user-card-user-name"
> >
<!-- eslint-disable vue/no-v-html --> <RichContent
<span
v-if="user.name_html"
class="basic-user-card-user-name-value" class="basic-user-card-user-name-value"
v-html="user.name_html" :html="user.name"
:emoji="user.emoji"
/> />
<!-- eslint-enable vue/no-v-html -->
<span
v-else
class="basic-user-card-user-name-value"
>{{ user.name }}</span>
</div> </div>
<div> <div>
<router-link <router-link
class="basic-user-card-screen-name" class="basic-user-card-screen-name"
:to="userProfileLink(user)" :to="userProfileLink(user)"
> >
@{{ user.screen_name }} @{{ user.screen_name_ui }}
</router-link> </router-link>
</div> </div>
<slot /> <slot />

View file

@ -73,7 +73,7 @@ const Chat = {
}, },
formPlaceholder () { formPlaceholder () {
if (this.recipient) { if (this.recipient) {
return this.$t('chats.message_user', { nickname: this.recipient.screen_name }) return this.$t('chats.message_user', { nickname: this.recipient.screen_name_ui })
} else { } else {
return '' return ''
} }
@ -234,6 +234,13 @@ const Chat = {
const scrollable = this.$refs.scrollable const scrollable = this.$refs.scrollable
return scrollable && scrollable.scrollTop <= 0 return scrollable && scrollable.scrollTop <= 0
}, },
cullOlderCheck () {
window.setTimeout(() => {
if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.$store.dispatch('cullOlderMessages', this.currentChatMessageService.chatId)
}
}, 5000)
},
handleScroll: _.throttle(function () { handleScroll: _.throttle(function () {
if (!this.currentChat) { return } if (!this.currentChat) { return }
@ -241,6 +248,7 @@ const Chat = {
this.fetchChat({ maxId: this.currentChatMessageService.minId }) this.fetchChat({ maxId: this.currentChatMessageService.minId })
} else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.jumpToBottomButtonVisible = false this.jumpToBottomButtonVisible = false
this.cullOlderCheck()
if (this.newMessageCount > 0) { if (this.newMessageCount > 0) {
// Use a delay before marking as read to prevent situation where new messages // 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 // arrive just as you're leaving the view and messages that you didn't actually

View file

@ -98,10 +98,10 @@
.unread-message-count { .unread-message-count {
font-size: 0.8em; font-size: 0.8em;
left: 50%; left: 50%;
transform: translate(-50%, 0);
border-radius: 100%;
margin-top: -1rem; margin-top: -1rem;
padding: 0; padding: 0.1em;
border-radius: 50px;
position: absolute;
} }
.chat-loading-error { .chat-loading-error {

View file

@ -23,10 +23,7 @@
class="timeline" class="timeline"
> >
<List :items="sortedChatList"> <List :items="sortedChatList">
<template <template v-slot:item="{item}">
slot="item"
slot-scope="{item}"
>
<ChatListItem <ChatListItem
:key="item.id" :key="item.id"
:compact="false" :compact="false"

View file

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

View file

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

View file

@ -29,7 +29,8 @@
</div> </div>
</div> </div>
<div class="chat-preview"> <div class="chat-preview">
<StatusContent <StatusBody
class="chat-preview-body"
:status="messageForStatusContent" :status="messageForStatusContent"
:single-line="true" :single-line="true"
/> />

View file

@ -57,8 +57,9 @@ const ChatMessage = {
messageForStatusContent () { messageForStatusContent () {
return { return {
summary: '', summary: '',
statusnet_html: this.message.content, emojis: this.message.emojis,
text: this.message.content, raw_html: this.message.content || '',
text: this.message.content || '',
attachments: this.message.attachments attachments: this.message.attachments
} }
}, },

View file

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

View file

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

View file

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

View file

@ -12,7 +12,7 @@ export default Vue.component('chat-title', {
], ],
computed: { computed: {
title () { title () {
return this.user ? this.user.screen_name : '' return this.user ? this.user.screen_name_ui : ''
}, },
htmlTitle () { htmlTitle () {
return this.user ? this.user.name_html : '' return this.user ? this.user.name_html : ''

View file

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

View file

@ -50,7 +50,6 @@
.Conversation { .Conversation {
.conversation-status { .conversation-status {
border-left: none;
border-bottom-width: 1px; border-bottom-width: 1px;
border-bottom-style: solid; border-bottom-style: solid;
border-bottom-color: var(--border, $fallback--border); border-bottom-color: var(--border, $fallback--border);

View file

@ -52,6 +52,7 @@
href="/pleroma/admin/#/login-pleroma" href="/pleroma/admin/#/login-pleroma"
class="nav-icon" class="nav-icon"
target="_blank" target="_blank"
@click.stop
> >
<FAIcon <FAIcon
fixed-width fixed-width

View file

@ -9,7 +9,7 @@
class="btn button-default" class="btn button-default"
> >
{{ $t('domain_mute_card.unmute') }} {{ $t('domain_mute_card.unmute') }}
<template slot="progress"> <template v-slot:progress>
{{ $t('domain_mute_card.unmute_progress') }} {{ $t('domain_mute_card.unmute_progress') }}
</template> </template>
</ProgressButton> </ProgressButton>
@ -19,7 +19,7 @@
class="btn button-default" class="btn button-default"
> >
{{ $t('domain_mute_card.mute') }} {{ $t('domain_mute_card.mute') }}
<template slot="progress"> <template v-slot:progress>
{{ $t('domain_mute_card.mute_progress') }} {{ $t('domain_mute_card.mute_progress') }}
</template> </template>
</ProgressButton> </ProgressButton>

View file

@ -57,6 +57,7 @@ const EmojiInput = {
required: true, required: true,
type: Function type: Function
}, },
// TODO VUE3: change to modelValue, change 'input' event to 'input'
value: { value: {
/** /**
* Used for v-model * Used for v-model
@ -143,32 +144,31 @@ const EmojiInput = {
} }
}, },
mounted () { mounted () {
const slots = this.$slots.default const { root } = this.$refs
if (!slots || slots.length === 0) return const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea')
const input = slots.find(slot => ['input', 'textarea'].includes(slot.tag))
if (!input) return if (!input) return
this.input = input this.input = input
this.resize() this.resize()
input.elm.addEventListener('blur', this.onBlur) input.addEventListener('blur', this.onBlur)
input.elm.addEventListener('focus', this.onFocus) input.addEventListener('focus', this.onFocus)
input.elm.addEventListener('paste', this.onPaste) input.addEventListener('paste', this.onPaste)
input.elm.addEventListener('keyup', this.onKeyUp) input.addEventListener('keyup', this.onKeyUp)
input.elm.addEventListener('keydown', this.onKeyDown) input.addEventListener('keydown', this.onKeyDown)
input.elm.addEventListener('click', this.onClickInput) input.addEventListener('click', this.onClickInput)
input.elm.addEventListener('transitionend', this.onTransition) input.addEventListener('transitionend', this.onTransition)
input.elm.addEventListener('input', this.onInput) input.addEventListener('input', this.onInput)
}, },
unmounted () { unmounted () {
const { input } = this const { input } = this
if (input) { if (input) {
input.elm.removeEventListener('blur', this.onBlur) input.removeEventListener('blur', this.onBlur)
input.elm.removeEventListener('focus', this.onFocus) input.removeEventListener('focus', this.onFocus)
input.elm.removeEventListener('paste', this.onPaste) input.removeEventListener('paste', this.onPaste)
input.elm.removeEventListener('keyup', this.onKeyUp) input.removeEventListener('keyup', this.onKeyUp)
input.elm.removeEventListener('keydown', this.onKeyDown) input.removeEventListener('keydown', this.onKeyDown)
input.elm.removeEventListener('click', this.onClickInput) input.removeEventListener('click', this.onClickInput)
input.elm.removeEventListener('transitionend', this.onTransition) input.removeEventListener('transitionend', this.onTransition)
input.elm.removeEventListener('input', this.onInput) input.removeEventListener('input', this.onInput)
} }
}, },
watch: { watch: {
@ -194,11 +194,18 @@ const EmojiInput = {
} }
}, },
methods: { methods: {
focusPickerInput () {
const pickerEl = this.$refs.picker.$el
if (!pickerEl) return
const pickerInput = pickerEl.querySelector('input')
if (pickerInput) pickerInput.focus()
},
triggerShowPicker () { triggerShowPicker () {
this.showPicker = true this.showPicker = true
this.$refs.picker.startEmojiLoad() this.$refs.picker.startEmojiLoad()
this.$nextTick(() => { this.$nextTick(() => {
this.scrollIntoView() this.scrollIntoView()
this.focusPickerInput()
}) })
// This temporarily disables "click outside" handler // This temporarily disables "click outside" handler
// since external trigger also means click originates // since external trigger also means click originates
@ -209,11 +216,12 @@ const EmojiInput = {
}, 0) }, 0)
}, },
togglePicker () { togglePicker () {
this.input.elm.focus() this.input.focus()
this.showPicker = !this.showPicker this.showPicker = !this.showPicker
if (this.showPicker) { if (this.showPicker) {
this.scrollIntoView() this.scrollIntoView()
this.$refs.picker.startEmojiLoad() this.$refs.picker.startEmojiLoad()
this.$nextTick(this.focusPickerInput)
} }
}, },
replace (replacement) { replace (replacement) {
@ -254,13 +262,13 @@ const EmojiInput = {
this.$emit('input', newValue) this.$emit('input', newValue)
const position = this.caret + (insertion + spaceAfter + spaceBefore).length const position = this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) { if (!keepOpen) {
this.input.elm.focus() this.input.focus()
} }
this.$nextTick(function () { this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion // Re-focus inputbox after clicking suggestion
// Set selection right after the replacement instead of the very end // Set selection right after the replacement instead of the very end
this.input.elm.setSelectionRange(position, position) this.input.setSelectionRange(position, position)
this.caret = position this.caret = position
}) })
}, },
@ -277,9 +285,9 @@ const EmojiInput = {
this.$nextTick(function () { this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion // Re-focus inputbox after clicking suggestion
this.input.elm.focus() this.input.focus()
// Set selection right after the replacement instead of the very end // Set selection right after the replacement instead of the very end
this.input.elm.setSelectionRange(position, position) this.input.setSelectionRange(position, position)
this.caret = position this.caret = position
}) })
e.preventDefault() e.preventDefault()
@ -341,7 +349,7 @@ const EmojiInput = {
} }
this.$nextTick(() => { this.$nextTick(() => {
const { offsetHeight } = this.input.elm const { offsetHeight } = this.input
const { picker } = this.$refs const { picker } = this.$refs
const pickerBottom = picker.$el.getBoundingClientRect().bottom const pickerBottom = picker.$el.getBoundingClientRect().bottom
if (pickerBottom > window.innerHeight) { if (pickerBottom > window.innerHeight) {
@ -406,8 +414,8 @@ const EmojiInput = {
// Scroll the input element to the position of the cursor // Scroll the input element to the position of the cursor
this.$nextTick(() => { this.$nextTick(() => {
this.input.elm.blur() this.input.blur()
this.input.elm.focus() this.input.focus()
}) })
} }
// Disable suggestions hotkeys if suggestions are hidden // Disable suggestions hotkeys if suggestions are hidden
@ -436,7 +444,7 @@ const EmojiInput = {
// de-focuses the element (i.e. default browser behavior) // de-focuses the element (i.e. default browser behavior)
if (key === 'Escape') { if (key === 'Escape') {
if (!this.temporarilyHideSuggestions) { if (!this.temporarilyHideSuggestions) {
this.input.elm.focus() this.input.focus()
} }
} }
@ -472,7 +480,7 @@ const EmojiInput = {
if (!panel) return if (!panel) return
const picker = this.$refs.picker.$el const picker = this.$refs.picker.$el
const panelBody = this.$refs['panel-body'] const panelBody = this.$refs['panel-body']
const { offsetHeight, offsetTop } = this.input.elm const { offsetHeight, offsetTop } = this.input
const offsetBottom = offsetTop + offsetHeight const offsetBottom = offsetTop + offsetHeight
this.setPlacement(panelBody, panel, offsetBottom) this.setPlacement(panelBody, panel, offsetBottom)
@ -486,7 +494,7 @@ const EmojiInput = {
if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) { if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) {
target.style.top = 'auto' target.style.top = 'auto'
target.style.bottom = this.input.elm.offsetHeight + 'px' target.style.bottom = this.input.offsetHeight + 'px'
} }
}, },
overflowsBottom (el) { overflowsBottom (el) {

View file

@ -1,5 +1,6 @@
<template> <template>
<div <div
ref="root"
v-click-outside="onClickOutside" v-click-outside="onClickOutside"
class="emoji-input" class="emoji-input"
:class="{ 'with-picker': !hideEmojiButton }" :class="{ 'with-picker': !hideEmojiButton }"
@ -9,6 +10,7 @@
<button <button
v-if="!hideEmojiButton" v-if="!hideEmojiButton"
class="button-unstyled emoji-picker-icon" class="button-unstyled emoji-picker-icon"
type="button"
@click.prevent="togglePicker" @click.prevent="togglePicker"
> >
<FAIcon :icon="['far', 'smile-beam']" /> <FAIcon :icon="['far', 'smile-beam']" />

View file

@ -116,8 +116,8 @@ export const suggestUsers = ({ dispatch, state }) => {
return diff + nameAlphabetically + screenNameAlphabetically return diff + nameAlphabetically + screenNameAlphabetically
/* eslint-disable camelcase */ /* eslint-disable camelcase */
}).map(({ screen_name, name, profile_image_url_original }) => ({ }).map(({ screen_name, screen_name_ui, name, profile_image_url_original }) => ({
displayText: screen_name, displayText: screen_name_ui,
detailText: name, detailText: name,
imageUrl: profile_image_url_original, imageUrl: profile_image_url_original,
replacement: '@' + screen_name + ' ' replacement: '@' + screen_name + ' '

View file

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

View file

@ -7,10 +7,7 @@
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
remove-padding remove-padding
> >
<div <template v-slot:content="{close}">
slot="content"
slot-scope="{close}"
>
<div class="dropdown-menu"> <div class="dropdown-menu">
<button <button
v-if="canMute && !status.thread_muted" v-if="canMute && !status.thread_muted"
@ -120,16 +117,15 @@
/><span>{{ $t("user_card.report") }}</span> /><span>{{ $t("user_card.report") }}</span>
</button> </button>
</div> </div>
</div> </template>
<span <template v-slot:trigger>
slot="trigger" <button class="button-unstyled popover-trigger">
class="popover-trigger"
>
<FAIcon <FAIcon
class="fa-scale-110 fa-old-padding" class="fa-scale-110 fa-old-padding"
icon="ellipsis-h" icon="ellipsis-h"
/> />
</span> </button>
</template>
</Popover> </Popover>
</template> </template>
@ -139,6 +135,11 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.ExtraButtons { .ExtraButtons {
/* override of popover internal stuff */
.popover-trigger-button {
width: auto;
}
.popover-trigger { .popover-trigger {
position: static; position: static;
padding: 10px; padding: 10px;

View file

@ -2,7 +2,7 @@ import fileSizeFormatService from '../../services/file_size_format/file_size_for
const FeaturesPanel = { const FeaturesPanel = {
computed: { computed: {
chat: function () { return this.$store.state.instance.chatAvailable }, shout: function () { return this.$store.state.instance.shoutAvailable },
pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable }, pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable },
gopher: function () { return this.$store.state.instance.gopherAvailable }, gopher: function () { return this.$store.state.instance.gopherAvailable },
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled }, whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },

View file

@ -8,8 +8,8 @@
</div> </div>
<div class="panel-body features-panel"> <div class="panel-body features-panel">
<ul> <ul>
<li v-if="chat"> <li v-if="shout">
{{ $t('features_panel.chat') }} {{ $t('features_panel.shout') }}
</li> </li>
<li v-if="pleromaChatMessages"> <li v-if="pleromaChatMessages">
{{ $t('features_panel.pleroma_chat_messages') }} {{ $t('features_panel.pleroma_chat_messages') }}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,10 @@
import { set } from 'vue' import { set } from 'vue'
import { library } from '@fortawesome/fontawesome-svg-core' import Select from '../select/select.vue'
import {
faChevronDown
} from '@fortawesome/free-solid-svg-icons'
library.add(
faChevronDown
)
export default { export default {
components: {
Select
},
props: [ props: [
'name', 'label', 'value', 'fallback', 'options', 'no-inherit' 'name', 'label', 'value', 'fallback', 'options', 'no-inherit'
], ],

View file

@ -22,12 +22,7 @@
class="opt-l" class="opt-l"
:for="name + '-o'" :for="name + '-o'"
/> />
<label <Select
:for="name + '-font-switcher'"
class="select"
:disabled="!present"
>
<select
:id="name + '-font-switcher'" :id="name + '-font-switcher'"
v-model="preset" v-model="preset"
:disabled="!present" :disabled="!present"
@ -40,12 +35,7 @@
> >
{{ option === 'custom' ? $t('settings.style.fonts.custom') : option }} {{ option === 'custom' ? $t('settings.style.fonts.custom') : option }}
</option> </option>
</select> </Select>
<FAIcon
class="select-down-icon"
icon="chevron-down"
/>
</label>
<input <input
v-if="isCustom" v-if="isCustom"
:id="name" :id="name"
@ -65,7 +55,8 @@
min-width: 10em; min-width: 10em;
} }
&.custom { &.custom {
.select { /* TODO Should make proper joiners... */
.font-switcher {
border-top-right-radius: 0; border-top-right-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
} }

View file

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

View file

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

View file

@ -71,6 +71,14 @@
} }
} }
.global-success {
background-color: var(--alertPopupSuccess, $fallback--cGreen);
color: var(--alertPopupSuccessText, $fallback--text);
.svg-inline--fa {
color: var(--alertPopupSuccessText, $fallback--text);
}
}
.global-info { .global-info {
background-color: var(--alertPopupNeutral, $fallback--fg); background-color: var(--alertPopupNeutral, $fallback--fg);
color: var(--alertPopupNeutralText, $fallback--text); color: var(--alertPopupNeutralText, $fallback--text);

View file

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

View file

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

View file

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

View file

@ -3,11 +3,7 @@
<label for="interface-language-switcher"> <label for="interface-language-switcher">
{{ $t('settings.interfaceLanguage') }} {{ $t('settings.interfaceLanguage') }}
</label> </label>
<label <Select
for="interface-language-switcher"
class="select"
>
<select
id="interface-language-switcher" id="interface-language-switcher"
v-model="language" v-model="language"
> >
@ -18,12 +14,7 @@
> >
{{ lang.name }} {{ lang.name }}
</option> </option>
</select> </Select>
<FAIcon
class="select-down-icon"
icon="chevron-down"
/>
</label>
</div> </div>
</template> </template>
@ -32,16 +23,12 @@ import languagesObject from '../../i18n/messages'
import localeService from '../../services/locale/locale.service.js' import localeService from '../../services/locale/locale.service.js'
import ISO6391 from 'iso-639-1' import ISO6391 from 'iso-639-1'
import _ from 'lodash' import _ from 'lodash'
import { library } from '@fortawesome/fontawesome-svg-core' import Select from '../select/select.vue'
import {
faChevronDown
} from '@fortawesome/free-solid-svg-icons'
library.add(
faChevronDown
)
export default { export default {
components: {
Select
},
computed: { computed: {
languages () { languages () {
return _.map(languagesObject.languages, (code) => ({ code: code, name: this.getLanguageName(code) })).sort((a, b) => a.name.localeCompare(b.name)) return _.map(languagesObject.languages, (code) => ({ code: code, name: this.getLanguageName(code) })).sort((a, b) => a.name.localeCompare(b.name))

View file

@ -3,22 +3,31 @@ import VideoAttachment from '../video_attachment/video_attachment.vue'
import Modal from '../modal/modal.vue' import Modal from '../modal/modal.vue'
import fileTypeService from '../../services/file_type/file_type.service.js' import fileTypeService from '../../services/file_type/file_type.service.js'
import GestureService from '../../services/gesture_service/gesture_service' import GestureService from '../../services/gesture_service/gesture_service'
import Flash from 'src/components/flash/flash.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faChevronLeft, faChevronLeft,
faChevronRight faChevronRight,
faCircleNotch
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
faChevronLeft, faChevronLeft,
faChevronRight faChevronRight,
faCircleNotch
) )
const MediaModal = { const MediaModal = {
components: { components: {
StillImage, StillImage,
VideoAttachment, VideoAttachment,
Modal Modal,
Flash
},
data () {
return {
loading: false
}
}, },
computed: { computed: {
showing () { showing () {
@ -27,6 +36,9 @@ const MediaModal = {
media () { media () {
return this.$store.state.mediaViewer.media return this.$store.state.mediaViewer.media
}, },
description () {
return this.currentMedia.description
},
currentIndex () { currentIndex () {
return this.$store.state.mediaViewer.currentIndex return this.$store.state.mediaViewer.currentIndex
}, },
@ -37,7 +49,7 @@ const MediaModal = {
return this.media.length > 1 return this.media.length > 1
}, },
type () { type () {
return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null return this.currentMedia ? this.getType(this.currentMedia) : null
} }
}, },
created () { created () {
@ -53,6 +65,9 @@ const MediaModal = {
) )
}, },
methods: { methods: {
getType (media) {
return fileTypeService.fileType(media.mimetype)
},
mediaTouchStart (e) { mediaTouchStart (e) {
GestureService.beginSwipe(e, this.mediaSwipeGestureRight) GestureService.beginSwipe(e, this.mediaSwipeGestureRight)
GestureService.beginSwipe(e, this.mediaSwipeGestureLeft) GestureService.beginSwipe(e, this.mediaSwipeGestureLeft)
@ -67,14 +82,25 @@ const MediaModal = {
goPrev () { goPrev () {
if (this.canNavigate) { if (this.canNavigate) {
const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1) const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1)
this.$store.dispatch('setCurrent', this.media[prevIndex]) const newMedia = this.media[prevIndex]
if (this.getType(newMedia) === 'image') {
this.loading = true
}
this.$store.dispatch('setCurrentMedia', newMedia)
} }
}, },
goNext () { goNext () {
if (this.canNavigate) { if (this.canNavigate) {
const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1) const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1)
this.$store.dispatch('setCurrent', this.media[nextIndex]) const newMedia = this.media[nextIndex]
if (this.getType(newMedia) === 'image') {
this.loading = true
} }
this.$store.dispatch('setCurrentMedia', newMedia)
}
},
onImageLoaded () {
this.loading = false
}, },
handleKeyupEvent (e) { handleKeyupEvent (e) {
if (this.showing && e.keyCode === 27) { // escape if (this.showing && e.keyCode === 27) { // escape

View file

@ -6,6 +6,7 @@
> >
<img <img
v-if="type === 'image'" v-if="type === 'image'"
:class="{ loading }"
class="modal-image" class="modal-image"
:src="currentMedia.url" :src="currentMedia.url"
:alt="currentMedia.description" :alt="currentMedia.description"
@ -13,6 +14,7 @@
@touchstart.stop="mediaTouchStart" @touchstart.stop="mediaTouchStart"
@touchmove.stop="mediaTouchMove" @touchmove.stop="mediaTouchMove"
@click="hide" @click="hide"
@load="onImageLoaded"
> >
<VideoAttachment <VideoAttachment
v-if="type === 'video'" v-if="type === 'video'"
@ -28,6 +30,13 @@
:title="currentMedia.description" :title="currentMedia.description"
controls controls
/> />
<Flash
v-if="type === 'flash'"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
/>
<button <button
v-if="canNavigate" v-if="canNavigate"
:title="$t('media_modal.previous')" :title="$t('media_modal.previous')"
@ -50,6 +59,27 @@
icon="chevron-right" icon="chevron-right"
/> />
</button> </button>
<span
v-if="description"
class="description"
>
{{ description }}
</span>
<span
class="counter"
>
{{ $tc('media_modal.counter', currentIndex + 1, { current: currentIndex + 1, total: media.length }) }}
</span>
<span
v-if="loading"
class="loading-spinner"
>
<FAIcon
spin
icon="circle-notch"
size="5x"
/>
</span>
</Modal> </Modal>
</template> </template>
@ -58,6 +88,7 @@
<style lang="scss"> <style lang="scss">
.modal-view.media-modal-view { .modal-view.media-modal-view {
z-index: 1001; z-index: 1001;
flex-direction: column;
.modal-view-button-arrow { .modal-view-button-arrow {
opacity: 0.75; opacity: 0.75;
@ -67,17 +98,65 @@
outline: none; outline: none;
box-shadow: none; box-shadow: none;
} }
&:hover { &:hover {
opacity: 1; opacity: 1;
} }
} }
} }
.media-modal-view {
@keyframes media-fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.description,
.counter {
/* Hardcoded since background is also hardcoded */
color: white;
margin-top: 1em;
text-shadow: 0 0 10px black, 0 0 10px black;
padding: 0.2em 2em;
}
.description {
flex: 0 0 auto;
overflow-y: auto;
min-height: 1em;
max-width: 500px;
max-height: 9.5em;
word-break: break-all;
}
.modal-image { .modal-image {
max-width: 90%; max-width: 90%;
max-height: 90%; max-height: 90%;
box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5); box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
image-orientation: from-image; // NOTE: only FF supports this image-orientation: from-image; // NOTE: only FF supports this
animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein;
&.loading {
opacity: 0.5;
}
}
.loading-spinner {
width: 100%;
height: 100%;
position: absolute;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
svg {
color: white;
}
} }
.modal-view-button-arrow { .modal-view-button-arrow {
@ -123,4 +202,5 @@
} }
} }
} }
}
</style> </style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -25,6 +25,7 @@
<div> <div>
<button <button
class="button-unstyled -link" class="button-unstyled -link"
type="button"
@click.prevent="requireTOTP" @click.prevent="requireTOTP"
> >
{{ $t('login.enter_two_factor_code') }} {{ $t('login.enter_two_factor_code') }}
@ -32,6 +33,7 @@
<br> <br>
<button <button
class="button-unstyled -link" class="button-unstyled -link"
type="button"
@click.prevent="abortMFA" @click.prevent="abortMFA"
> >
{{ $t('general.cancel') }} {{ $t('general.cancel') }}

View file

@ -27,6 +27,7 @@
<div> <div>
<button <button
class="button-unstyled -link" class="button-unstyled -link"
type="button"
@click.prevent="requireRecovery" @click.prevent="requireRecovery"
> >
{{ $t('login.enter_recovery_code') }} {{ $t('login.enter_recovery_code') }}
@ -34,6 +35,7 @@
<br> <br>
<button <button
class="button-unstyled -link" class="button-unstyled -link"
type="button"
@click.prevent="abortMFA" @click.prevent="abortMFA"
> >
{{ $t('general.cancel') }} {{ $t('general.cancel') }}

View file

@ -44,6 +44,9 @@ const MobilePostStatusButton = {
return this.autohideFloatingPostButton && (this.hidden || this.inputActive) return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
}, },
isPersistent () {
return !!this.$store.getters.mergedConfig.showNewPostButton
},
autohideFloatingPostButton () { autohideFloatingPostButton () {
return !!this.$store.getters.mergedConfig.autohideFloatingPostButton return !!this.$store.getters.mergedConfig.autohideFloatingPostButton
} }

View file

@ -2,7 +2,7 @@
<div v-if="isLoggedIn"> <div v-if="isLoggedIn">
<button <button
class="button-default new-status-button" class="button-default new-status-button"
:class="{ 'hidden': isHidden }" :class="{ 'hidden': isHidden, 'always-show': isPersistent }"
@click="openPostForm" @click="openPostForm"
> >
<FAIcon icon="pen" /> <FAIcon icon="pen" />
@ -47,7 +47,7 @@
} }
@media all and (min-width: 801px) { @media all and (min-width: 801px) {
.new-status-button { .new-status-button:not(.always-show) {
display: none; display: none;
} }
} }

View file

@ -1,6 +1,11 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
import DialogModal from '../dialog_modal/dialog_modal.vue' import DialogModal from '../dialog_modal/dialog_modal.vue'
import Popover from '../popover/popover.vue' import Popover from '../popover/popover.vue'
library.add(faChevronDown)
const FORCE_NSFW = 'mrf_tag:media-force-nsfw' const FORCE_NSFW = 'mrf_tag:media-force-nsfw'
const STRIP_MEDIA = 'mrf_tag:media-strip' const STRIP_MEDIA = 'mrf_tag:media-strip'
const FORCE_UNLISTED = 'mrf_tag:force-unlisted' const FORCE_UNLISTED = 'mrf_tag:force-unlisted'

View file

@ -8,7 +8,7 @@
@show="setToggled(true)" @show="setToggled(true)"
@close="setToggled(false)" @close="setToggled(false)"
> >
<div slot="content"> <template v-slot:content>
<div class="dropdown-menu"> <div class="dropdown-menu">
<span v-if="user.is_local"> <span v-if="user.is_local">
<button <button
@ -50,96 +50,98 @@
class="button-default dropdown-item" class="button-default dropdown-item"
@click="toggleTag(tags.FORCE_NSFW)" @click="toggleTag(tags.FORCE_NSFW)"
> >
{{ $t('user_card.admin_menu.force_nsfw') }}
<span <span
class="menu-checkbox" class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }" :class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }"
/> />
{{ $t('user_card.admin_menu.force_nsfw') }}
</button> </button>
<button <button
class="button-default dropdown-item" class="button-default dropdown-item"
@click="toggleTag(tags.STRIP_MEDIA)" @click="toggleTag(tags.STRIP_MEDIA)"
> >
{{ $t('user_card.admin_menu.strip_media') }}
<span <span
class="menu-checkbox" class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }" :class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }"
/> />
{{ $t('user_card.admin_menu.strip_media') }}
</button> </button>
<button <button
class="button-default dropdown-item" class="button-default dropdown-item"
@click="toggleTag(tags.FORCE_UNLISTED)" @click="toggleTag(tags.FORCE_UNLISTED)"
> >
{{ $t('user_card.admin_menu.force_unlisted') }}
<span <span
class="menu-checkbox" class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }" :class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }"
/> />
{{ $t('user_card.admin_menu.force_unlisted') }}
</button> </button>
<button <button
class="button-default dropdown-item" class="button-default dropdown-item"
@click="toggleTag(tags.SANDBOX)" @click="toggleTag(tags.SANDBOX)"
> >
{{ $t('user_card.admin_menu.sandbox') }}
<span <span
class="menu-checkbox" class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }" :class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }"
/> />
{{ $t('user_card.admin_menu.sandbox') }}
</button> </button>
<button <button
v-if="user.is_local" v-if="user.is_local"
class="button-default dropdown-item" class="button-default dropdown-item"
@click="toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)" @click="toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)"
> >
{{ $t('user_card.admin_menu.disable_remote_subscription') }}
<span <span
class="menu-checkbox" class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }" :class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }"
/> />
{{ $t('user_card.admin_menu.disable_remote_subscription') }}
</button> </button>
<button <button
v-if="user.is_local" v-if="user.is_local"
class="button-default dropdown-item" class="button-default dropdown-item"
@click="toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)" @click="toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)"
> >
{{ $t('user_card.admin_menu.disable_any_subscription') }}
<span <span
class="menu-checkbox" class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }" :class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }"
/> />
{{ $t('user_card.admin_menu.disable_any_subscription') }}
</button> </button>
<button <button
v-if="user.is_local" v-if="user.is_local"
class="button-default dropdown-item" class="button-default dropdown-item"
@click="toggleTag(tags.QUARANTINE)" @click="toggleTag(tags.QUARANTINE)"
> >
{{ $t('user_card.admin_menu.quarantine') }}
<span <span
class="menu-checkbox" class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }" :class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }"
/> />
{{ $t('user_card.admin_menu.quarantine') }}
</button> </button>
</span> </span>
</div> </div>
</div> </template>
<template v-slot:trigger>
<button <button
slot="trigger" class="btn button-default btn-block moderation-tools-button"
class="btn button-default btn-block"
:class="{ toggled }" :class="{ toggled }"
> >
{{ $t('user_card.admin_menu.moderation') }} {{ $t('user_card.admin_menu.moderation') }}
<FAIcon icon="chevron-down" />
</button> </button>
</template>
</Popover> </Popover>
<portal to="modal"> <portal to="modal">
<DialogModal <DialogModal
v-if="showDeleteUserDialog" v-if="showDeleteUserDialog"
:on-cancel="deleteUserDialog.bind(this, false)" :on-cancel="deleteUserDialog.bind(this, false)"
> >
<template slot="header"> <template v-slot:header>
{{ $t('user_card.admin_menu.delete_user') }} {{ $t('user_card.admin_menu.delete_user') }}
</template> </template>
<p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p> <p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p>
<template slot="footer"> <template v-slot:footer>
<button <button
class="btn button-default" class="btn button-default"
@click="deleteUserDialog(false)" @click="deleteUserDialog(false)"
@ -163,25 +165,6 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.menu-checkbox {
float: right;
min-width: 22px;
max-width: 22px;
min-height: 22px;
max-height: 22px;
line-height: 22px;
text-align: center;
border-radius: 0px;
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
box-shadow: 0px 0px 2px black inset;
box-shadow: var(--inputShadow);
&.menu-checkbox-checked::after {
content: '✓';
}
}
.moderation-tools-popover { .moderation-tools-popover {
height: 100%; height: 100%;
.trigger { .trigger {
@ -189,4 +172,10 @@
height: 100%; height: 100%;
} }
} }
.moderation-tools-button {
svg,i {
font-size: 0.8em;
}
}
</style> </style>

View file

@ -1,17 +1,56 @@
import { mapState } from 'vuex' import { mapState } from 'vuex'
import { get } from 'lodash' import { get } from 'lodash'
/**
* This is for backwards compatibility. We originally didn't recieve
* extra info like a reason why an instance was rejected/quarantined/etc.
* Because we didn't want to break backwards compatibility it was decided
* to add an extra "info" key.
*/
const toInstanceReasonObject = (instances, info, key) => {
return instances.map(instance => {
if (info[key] && info[key][instance] && info[key][instance]['reason']) {
return { instance: instance, reason: info[key][instance]['reason'] }
}
return { instance: instance, reason: '' }
})
}
const MRFTransparencyPanel = { const MRFTransparencyPanel = {
computed: { computed: {
...mapState({ ...mapState({
federationPolicy: state => get(state, 'instance.federationPolicy'), federationPolicy: state => get(state, 'instance.federationPolicy'),
mrfPolicies: state => get(state, 'instance.federationPolicy.mrf_policies', []), mrfPolicies: state => get(state, 'instance.federationPolicy.mrf_policies', []),
quarantineInstances: state => get(state, 'instance.federationPolicy.quarantined_instances', []), quarantineInstances: state => toInstanceReasonObject(
acceptInstances: state => get(state, 'instance.federationPolicy.mrf_simple.accept', []), get(state, 'instance.federationPolicy.quarantined_instances', []),
rejectInstances: state => get(state, 'instance.federationPolicy.mrf_simple.reject', []), get(state, 'instance.federationPolicy.quarantined_instances_info', []),
ftlRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []), 'quarantined_instances'
mediaNsfwInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []), ),
mediaRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_removal', []), acceptInstances: state => toInstanceReasonObject(
get(state, 'instance.federationPolicy.mrf_simple.accept', []),
get(state, 'instance.federationPolicy.mrf_simple_info', []),
'accept'
),
rejectInstances: state => toInstanceReasonObject(
get(state, 'instance.federationPolicy.mrf_simple.reject', []),
get(state, 'instance.federationPolicy.mrf_simple_info', []),
'reject'
),
ftlRemovalInstances: state => toInstanceReasonObject(
get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []),
get(state, 'instance.federationPolicy.mrf_simple_info', []),
'federated_timeline_removal'
),
mediaNsfwInstances: state => toInstanceReasonObject(
get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []),
get(state, 'instance.federationPolicy.mrf_simple_info', []),
'media_nsfw'
),
mediaRemovalInstances: state => toInstanceReasonObject(
get(state, 'instance.federationPolicy.mrf_simple.media_removal', []),
get(state, 'instance.federationPolicy.mrf_simple_info', []),
'media_removal'
),
keywordsFtlRemoval: state => get(state, 'instance.federationPolicy.mrf_keyword.federated_timeline_removal', []), keywordsFtlRemoval: state => get(state, 'instance.federationPolicy.mrf_keyword.federated_timeline_removal', []),
keywordsReject: state => get(state, 'instance.federationPolicy.mrf_keyword.reject', []), keywordsReject: state => get(state, 'instance.federationPolicy.mrf_keyword.reject', []),
keywordsReplace: state => get(state, 'instance.federationPolicy.mrf_keyword.replace', []) keywordsReplace: state => get(state, 'instance.federationPolicy.mrf_keyword.replace', [])

View file

@ -0,0 +1,21 @@
.mrf-section {
margin: 1em;
table {
width:100%;
text-align: left;
padding-left:10px;
padding-bottom:20px;
th, td {
width: 180px;
max-width: 360px;
overflow: hidden;
vertical-align: text-top;
}
th+th, td+td {
width: auto;
}
}
}

View file

@ -31,13 +31,24 @@
<p>{{ $t("about.mrf.simple.accept_desc") }}</p> <p>{{ $t("about.mrf.simple.accept_desc") }}</p>
<ul> <table>
<li <tr>
v-for="instance in acceptInstances" <th>{{ $t("about.mrf.simple.instance") }}</th>
:key="instance" <th>{{ $t("about.mrf.simple.reason") }}</th>
v-text="instance" </tr>
/> <tr
</ul> v-for="entry in acceptInstances"
:key="entry.instance + '_accept'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</table>
</div> </div>
<div v-if="rejectInstances.length"> <div v-if="rejectInstances.length">
@ -45,13 +56,24 @@
<p>{{ $t("about.mrf.simple.reject_desc") }}</p> <p>{{ $t("about.mrf.simple.reject_desc") }}</p>
<ul> <table>
<li <tr>
v-for="instance in rejectInstances" <th>{{ $t("about.mrf.simple.instance") }}</th>
:key="instance" <th>{{ $t("about.mrf.simple.reason") }}</th>
v-text="instance" </tr>
/> <tr
</ul> v-for="entry in rejectInstances"
:key="entry.instance + '_reject'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</table>
</div> </div>
<div v-if="quarantineInstances.length"> <div v-if="quarantineInstances.length">
@ -59,13 +81,24 @@
<p>{{ $t("about.mrf.simple.quarantine_desc") }}</p> <p>{{ $t("about.mrf.simple.quarantine_desc") }}</p>
<ul> <table>
<li <tr>
v-for="instance in quarantineInstances" <th>{{ $t("about.mrf.simple.instance") }}</th>
:key="instance" <th>{{ $t("about.mrf.simple.reason") }}</th>
v-text="instance" </tr>
/> <tr
</ul> v-for="entry in quarantineInstances"
:key="entry.instance + '_quarantine'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</table>
</div> </div>
<div v-if="ftlRemovalInstances.length"> <div v-if="ftlRemovalInstances.length">
@ -73,13 +106,24 @@
<p>{{ $t("about.mrf.simple.ftl_removal_desc") }}</p> <p>{{ $t("about.mrf.simple.ftl_removal_desc") }}</p>
<ul> <table>
<li <tr>
v-for="instance in ftlRemovalInstances" <th>{{ $t("about.mrf.simple.instance") }}</th>
:key="instance" <th>{{ $t("about.mrf.simple.reason") }}</th>
v-text="instance" </tr>
/> <tr
</ul> v-for="entry in ftlRemovalInstances"
:key="entry.instance + '_ftl_removal'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</table>
</div> </div>
<div v-if="mediaNsfwInstances.length"> <div v-if="mediaNsfwInstances.length">
@ -87,13 +131,24 @@
<p>{{ $t("about.mrf.simple.media_nsfw_desc") }}</p> <p>{{ $t("about.mrf.simple.media_nsfw_desc") }}</p>
<ul> <table>
<li <tr>
v-for="instance in mediaNsfwInstances" <th>{{ $t("about.mrf.simple.instance") }}</th>
:key="instance" <th>{{ $t("about.mrf.simple.reason") }}</th>
v-text="instance" </tr>
/> <tr
</ul> v-for="entry in mediaNsfwInstances"
:key="entry.instance + '_media_nsfw'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</table>
</div> </div>
<div v-if="mediaRemovalInstances.length"> <div v-if="mediaRemovalInstances.length">
@ -101,13 +156,24 @@
<p>{{ $t("about.mrf.simple.media_removal_desc") }}</p> <p>{{ $t("about.mrf.simple.media_removal_desc") }}</p>
<ul> <table>
<li <tr>
v-for="instance in mediaRemovalInstances" <th>{{ $t("about.mrf.simple.instance") }}</th>
:key="instance" <th>{{ $t("about.mrf.simple.reason") }}</th>
v-text="instance" </tr>
/> <tr
</ul> v-for="entry in mediaRemovalInstances"
:key="entry.instance + '_media_removal'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</table>
</div> </div>
<h2 v-if="hasKeywordPolicies"> <h2 v-if="hasKeywordPolicies">
@ -161,7 +227,6 @@
<script src="./mrf_transparency_panel.js"></script> <script src="./mrf_transparency_panel.js"></script>
<style lang="scss"> <style lang="scss">
.mrf-section { @import '../../_variables.scss';
margin: 1em; @import './mrf_transparency_panel.scss';
}
</style> </style>

View file

@ -1,4 +1,4 @@
import { timelineNames } from '../timeline_menu/timeline_menu.js' import TimelineMenuContent from '../timeline_menu/timeline_menu_content.vue'
import { mapState, mapGetters } from 'vuex' import { mapState, mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
@ -7,10 +7,12 @@ import {
faGlobe, faGlobe,
faBookmark, faBookmark,
faEnvelope, faEnvelope,
faHome, faChevronDown,
faChevronUp,
faComments, faComments,
faBell, faBell,
faInfoCircle faInfoCircle,
faStream
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
@ -18,10 +20,12 @@ library.add(
faGlobe, faGlobe,
faBookmark, faBookmark,
faEnvelope, faEnvelope,
faHome, faChevronDown,
faChevronUp,
faComments, faComments,
faBell, faBell,
faInfoCircle faInfoCircle,
faStream
) )
const NavPanel = { const NavPanel = {
@ -30,16 +34,20 @@ const NavPanel = {
this.$store.dispatch('startFetchingFollowRequests') this.$store.dispatch('startFetchingFollowRequests')
} }
}, },
computed: { components: {
onTimelineRoute () { TimelineMenuContent
return !!timelineNames()[this.$route.name]
}, },
timelinesRoute () { data () {
if (this.$store.state.interface.lastTimeline) { return {
return this.$store.state.interface.lastTimeline showTimelines: false
} }
return this.currentUser ? 'friends' : 'public-timeline'
}, },
methods: {
toggleTimelines () {
this.showTimelines = !this.showTimelines
}
},
computed: {
...mapState({ ...mapState({
currentUser: state => state.users.currentUser, currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length, followRequestCount: state => state.api.followRequests.length,

View file

@ -3,19 +3,33 @@
<div class="panel panel-default"> <div class="panel panel-default">
<ul> <ul>
<li v-if="currentUser || !privateMode"> <li v-if="currentUser || !privateMode">
<router-link <button
:to="{ name: timelinesRoute }" class="button-unstyled menu-item"
:class="onTimelineRoute && 'router-link-active'" @click="toggleTimelines"
> >
<FAIcon <FAIcon
fixed-width fixed-width
class="fa-scale-110" class="fa-scale-110"
icon="home" icon="stream"
/>{{ $t("nav.timelines") }} />{{ $t("nav.timelines") }}
</router-link> <FAIcon
class="timelines-chevron"
fixed-width
:icon="showTimelines ? 'chevron-up' : 'chevron-down'"
/>
</button>
<div
v-show="showTimelines"
class="timelines-background"
>
<TimelineMenuContent class="timelines" />
</div>
</li> </li>
<li v-if="currentUser"> <li v-if="currentUser">
<router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }"> <router-link
class="menu-item"
:to="{ name: 'interactions', params: { username: currentUser.screen_name } }"
>
<FAIcon <FAIcon
fixed-width fixed-width
class="fa-scale-110" class="fa-scale-110"
@ -24,7 +38,10 @@
</router-link> </router-link>
</li> </li>
<li v-if="currentUser && pleromaChatMessagesAvailable"> <li v-if="currentUser && pleromaChatMessagesAvailable">
<router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }"> <router-link
class="menu-item"
:to="{ name: 'chats', params: { username: currentUser.screen_name } }"
>
<div <div
v-if="unreadChatCount" v-if="unreadChatCount"
class="badge badge-notification" class="badge badge-notification"
@ -39,7 +56,10 @@
</router-link> </router-link>
</li> </li>
<li v-if="currentUser && currentUser.locked"> <li v-if="currentUser && currentUser.locked">
<router-link :to="{ name: 'friend-requests' }"> <router-link
class="menu-item"
:to="{ name: 'friend-requests' }"
>
<FAIcon <FAIcon
fixed-width fixed-width
class="fa-scale-110" class="fa-scale-110"
@ -54,7 +74,10 @@
</router-link> </router-link>
</li> </li>
<li> <li>
<router-link :to="{ name: 'about' }"> <router-link
class="menu-item"
:to="{ name: 'about' }"
>
<FAIcon <FAIcon
fixed-width fixed-width
class="fa-scale-110" class="fa-scale-110"
@ -91,14 +114,14 @@
border-color: var(--border, $fallback--border); border-color: var(--border, $fallback--border);
padding: 0; padding: 0;
&:first-child a { &:first-child .menu-item {
border-top-right-radius: $fallback--panelRadius; border-top-right-radius: $fallback--panelRadius;
border-top-right-radius: var(--panelRadius, $fallback--panelRadius); border-top-right-radius: var(--panelRadius, $fallback--panelRadius);
border-top-left-radius: $fallback--panelRadius; border-top-left-radius: $fallback--panelRadius;
border-top-left-radius: var(--panelRadius, $fallback--panelRadius); border-top-left-radius: var(--panelRadius, $fallback--panelRadius);
} }
&:last-child a { &:last-child .menu-item {
border-bottom-right-radius: $fallback--panelRadius; border-bottom-right-radius: $fallback--panelRadius;
border-bottom-right-radius: var(--panelRadius, $fallback--panelRadius); border-bottom-right-radius: var(--panelRadius, $fallback--panelRadius);
border-bottom-left-radius: $fallback--panelRadius; border-bottom-left-radius: $fallback--panelRadius;
@ -110,13 +133,15 @@
border: none; border: none;
} }
a { .menu-item {
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
align-items: stretch;
height: 3.5em; height: 3.5em;
line-height: 3.5em; line-height: 3.5em;
padding: 0 1em; padding: 0 1em;
width: 100%;
color: $fallback--link;
color: var(--link, $fallback--link);
&:hover { &:hover {
background-color: $fallback--lightBg; background-color: $fallback--lightBg;
@ -146,6 +171,25 @@
} }
} }
.timelines-chevron {
margin-left: 0.8em;
font-size: 1.1em;
}
.timelines-background {
padding: 0 0 0 0.6em;
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
border-top: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
}
.timelines {
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
}
.fa-scale-110 { .fa-scale-110 {
margin-right: 0.8em; margin-right: 0.8em;
} }

View file

@ -5,6 +5,7 @@ import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue' import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue' import Timeago from '../timeago/timeago.vue'
import Report from '../report/report.vue' import Report from '../report/report.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js' import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -46,7 +47,8 @@ const Notification = {
UserCard, UserCard,
Timeago, Timeago,
Status, Status,
Report Report,
RichContent
}, },
methods: { methods: {
toggleUserExpanded () { toggleUserExpanded () {

View file

@ -2,6 +2,19 @@
// TODO Copypaste from Status, should unify it somehow // TODO Copypaste from Status, should unify it somehow
.Notification { .Notification {
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
word-wrap: break-word;
word-break: break-word;
--emoji-size: 14px;
&:hover {
--_still-image-img-visibility: visible;
--_still-image-canvas-visibility: hidden;
--_still-image-label-visibility: hidden;
}
&.-muted { &.-muted {
padding: 0.25em 0.6em; padding: 0.25em 0.6em;
height: 1.2em; height: 1.2em;

View file

@ -1,6 +1,7 @@
<template> <template>
<Status <Status
v-if="notification.type === 'mention'" v-if="notification.type === 'mention'"
class="Notification"
:compact="true" :compact="true"
:statusoid="notification.status" :statusoid="notification.status"
/> />
@ -11,7 +12,7 @@
> >
<small> <small>
<router-link :to="userProfileLink"> <router-link :to="userProfileLink">
{{ notification.from_profile.screen_name }} {{ notification.from_profile.screen_name_ui }}
</router-link> </router-link>
</small> </small>
<button <button
@ -51,17 +52,19 @@
<span class="notification-details"> <span class="notification-details">
<div class="name-and-action"> <div class="name-and-action">
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<bdi <bdi v-if="!!notification.from_profile.name_html">
v-if="!!notification.from_profile.name_html" <RichContent
class="username" class="username"
:title="'@'+notification.from_profile.screen_name" :title="'@'+notification.from_profile.screen_name_ui"
v-html="notification.from_profile.name_html" :html="notification.from_profile.name_html"
:emoji="notification.from_profile.emoji"
/> />
</bdi>
<!-- eslint-enable vue/no-v-html --> <!-- eslint-enable vue/no-v-html -->
<span <span
v-else v-else
class="username" class="username"
:title="'@'+notification.from_profile.screen_name" :title="'@'+notification.from_profile.screen_name_ui"
>{{ notification.from_profile.name }}</span> >{{ notification.from_profile.name }}</span>
<span v-if="notification.type === 'like'"> <span v-if="notification.type === 'like'">
<FAIcon <FAIcon
@ -155,7 +158,7 @@
:to="userProfileLink" :to="userProfileLink"
class="follow-name" class="follow-name"
> >
@{{ notification.from_profile.screen_name }} @{{ notification.from_profile.screen_name_ui }}
</router-link> </router-link>
<div <div
v-if="notification.type === 'follow_request'" v-if="notification.type === 'follow_request'"
@ -180,7 +183,7 @@
class="move-text" class="move-text"
> >
<router-link :to="targetUserProfileLink"> <router-link :to="targetUserProfileLink">
@{{ notification.target.screen_name }} @{{ notification.target.screen_name_ui }}
</router-link> </router-link>
</div> </div>
<Report <Report
@ -188,8 +191,9 @@
:report-id="notification.report.id" :report-id="notification.report.id"
/> />
<template v-else> <template v-else>
<status-content <StatusContent
class="faint" class="faint"
:compact="true"
:status="notification.action" :status="notification.action"
/> />
</template> </template>

View file

@ -0,0 +1,122 @@
<template>
<Popover
trigger="click"
class="NotificationFilters"
placement="bottom"
:bound-to="{ x: 'container' }"
>
<template v-slot:content>
<div class="dropdown-menu">
<button
class="button-default dropdown-item"
@click="toggleNotificationFilter('likes')"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': filters.likes }"
/>{{ $t('settings.notification_visibility_likes') }}
</button>
<button
class="button-default dropdown-item"
@click="toggleNotificationFilter('repeats')"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': filters.repeats }"
/>{{ $t('settings.notification_visibility_repeats') }}
</button>
<button
class="button-default dropdown-item"
@click="toggleNotificationFilter('follows')"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': filters.follows }"
/>{{ $t('settings.notification_visibility_follows') }}
</button>
<button
class="button-default dropdown-item"
@click="toggleNotificationFilter('mentions')"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': filters.mentions }"
/>{{ $t('settings.notification_visibility_mentions') }}
</button>
<button
class="button-default dropdown-item"
@click="toggleNotificationFilter('emojiReactions')"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': filters.emojiReactions }"
/>{{ $t('settings.notification_visibility_emoji_reactions') }}
</button>
<button
class="button-default dropdown-item"
@click="toggleNotificationFilter('moves')"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': filters.moves }"
/>{{ $t('settings.notification_visibility_moves') }}
</button>
</div>
</template>
<template v-slot:trigger>
<button class="button-unstyled">
<FAIcon icon="filter" />
</button>
</template>
</Popover>
</template>
<script>
import Popover from '../popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faFilter } from '@fortawesome/free-solid-svg-icons'
library.add(
faFilter
)
export default {
components: { Popover },
computed: {
filters () {
return this.$store.getters.mergedConfig.notificationVisibility
}
},
methods: {
toggleNotificationFilter (type) {
this.$store.dispatch('setOption', {
name: 'notificationVisibility',
value: {
...this.filters,
[type]: !this.filters[type]
}
})
}
}
}
</script>
<style lang="scss">
.NotificationFilters {
align-self: stretch;
> button {
font-size: 1.2em;
padding-left: 0.7em;
padding-right: 0.2em;
line-height: 100%;
height: 100%;
}
.dropdown-item {
margin: 0;
}
}
</style>

View file

@ -1,5 +1,6 @@
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import Notification from '../notification/notification.vue' import Notification from '../notification/notification.vue'
import NotificationFilters from './notification_filters.vue'
import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js' import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
import { import {
notificationsFromStore, notificationsFromStore,
@ -17,6 +18,10 @@ library.add(
const DEFAULT_SEEN_TO_DISPLAY_COUNT = 30 const DEFAULT_SEEN_TO_DISPLAY_COUNT = 30
const Notifications = { const Notifications = {
components: {
Notification,
NotificationFilters
},
props: { props: {
// Disables display of panel header // Disables display of panel header
noHeading: Boolean, noHeading: Boolean,
@ -35,11 +40,6 @@ const Notifications = {
seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT
} }
}, },
created () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
notificationsFetcher.fetchAndUpdate({ store, credentials })
},
computed: { computed: {
mainClass () { mainClass () {
return this.minimalMode ? '' : 'panel panel-default' return this.minimalMode ? '' : 'panel panel-default'
@ -70,9 +70,6 @@ const Notifications = {
}, },
...mapGetters(['unreadChatCount']) ...mapGetters(['unreadChatCount'])
}, },
components: {
Notification
},
watch: { watch: {
unseenCountTitle (count) { unseenCountTitle (count) {
if (count > 0) { if (count > 0) {

View file

@ -1,6 +1,6 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.notifications { .Notifications {
&:not(.minimal) { &:not(.minimal) {
// a bit of a hack to allow scrolling below notifications // a bit of a hack to allow scrolling below notifications
padding-bottom: 15em; padding-bottom: 15em;
@ -11,6 +11,10 @@
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
.notifications-footer {
border: none;
}
.notification { .notification {
position: relative; position: relative;
@ -33,11 +37,6 @@
.notification { .notification {
box-sizing: border-box; box-sizing: border-box;
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
word-wrap: break-word;
word-break: break-word;
&:hover .animated.Avatar { &:hover .animated.Avatar {
canvas { canvas {
@ -84,7 +83,6 @@
} }
} }
.follow-text, .move-text { .follow-text, .move-text {
padding: 0.5em 0; padding: 0.5em 0;
overflow-wrap: break-word; overflow-wrap: break-word;
@ -147,13 +145,6 @@
max-width: 100%; max-width: 100%;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
img {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
} }
.timeago { .timeago {

View file

@ -1,7 +1,7 @@
<template> <template>
<div <div
:class="{ minimal: minimalMode }" :class="{ minimal: minimalMode }"
class="notifications" class="Notifications"
> >
<div :class="mainClass"> <div :class="mainClass">
<div <div
@ -22,6 +22,7 @@
> >
{{ $t('notifications.read') }} {{ $t('notifications.read') }}
</button> </button>
<NotificationFilters />
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div <div
@ -34,10 +35,10 @@
<notification :notification="notification" /> <notification :notification="notification" />
</div> </div>
</div> </div>
<div class="panel-footer"> <div class="panel-footer notifications-footer">
<div <div
v-if="bottomedOut" v-if="bottomedOut"
class="new-status-notification text-center panel-footer faint" class="new-status-notification text-center faint"
> >
{{ $t('notifications.no_more_notifications') }} {{ $t('notifications.no_more_notifications') }}
</div> </div>
@ -46,13 +47,13 @@
class="button-unstyled -link -fullwidth" class="button-unstyled -link -fullwidth"
@click.prevent="fetchOlderNotifications()" @click.prevent="fetchOlderNotifications()"
> >
<div class="new-status-notification text-center panel-footer"> <div class="new-status-notification text-center">
{{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older') }} {{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older') }}
</div> </div>
</button> </button>
<div <div
v-else v-else
class="new-status-notification text-center panel-footer" class="new-status-notification text-center"
> >
<FAIcon <FAIcon
icon="circle-notch" icon="circle-notch"

View file

@ -53,7 +53,7 @@
type="submit" type="submit"
class="btn button-default btn-block" class="btn button-default btn-block"
> >
{{ $t('general.submit') }} {{ $t('settings.save') }}
</button> </button>
</div> </div>
</div> </div>

View file

@ -1,10 +1,14 @@
import Timeago from '../timeago/timeago.vue' import Timeago from 'components/timeago/timeago.vue'
import RichContent from 'components/rich_content/rich_content.jsx'
import { forEach, map } from 'lodash' import { forEach, map } from 'lodash'
export default { export default {
name: 'Poll', name: 'Poll',
props: ['basePoll'], props: ['basePoll', 'emoji'],
components: { Timeago }, components: {
Timeago,
RichContent
},
data () { data () {
return { return {
loading: false, loading: false,

View file

@ -17,8 +17,11 @@
<span class="result-percentage"> <span class="result-percentage">
{{ percentageForOption(option.votes_count) }}% {{ percentageForOption(option.votes_count) }}%
</span> </span>
<!-- eslint-disable-next-line vue/no-v-html --> <RichContent
<span v-html="option.title_html" /> :html="option.title_html"
:handle-links="false"
:emoji="emoji"
/>
</div> </div>
<div <div
class="result-fill" class="result-fill"
@ -42,8 +45,11 @@
:value="index" :value="index"
> >
<label class="option-vote"> <label class="option-vote">
<!-- eslint-disable-next-line vue/no-v-html --> <RichContent
<div v-html="option.title_html" /> :html="option.title_html"
:handle-links="false"
:emoji="emoji"
/>
</label> </label>
</div> </div>
</div> </div>
@ -58,7 +64,12 @@
{{ $t('polls.vote') }} {{ $t('polls.vote') }}
</button> </button>
<div class="total"> <div class="total">
{{ totalVotesCount }} {{ $t("polls.votes") }}&nbsp;·&nbsp; <template v-if="typeof poll.voters_count === 'number'">
{{ $tc("polls.people_voted_count", poll.voters_count, { count: poll.voters_count }) }}&nbsp;·&nbsp;
</template>
<template v-else>
{{ $tc("polls.votes_count", poll.votes_count, { count: poll.votes_count }) }}&nbsp;·&nbsp;
</template>
</div> </div>
<i18n :path="expired ? 'polls.expired' : 'polls.expires_in'"> <i18n :path="expired ? 'polls.expired' : 'polls.expires_in'">
<Timeago <Timeago

View file

@ -1,19 +1,21 @@
import * as DateUtils from 'src/services/date_utils/date_utils.js' import * as DateUtils from 'src/services/date_utils/date_utils.js'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import Select from '../select/select.vue'
import { import {
faTimes, faTimes,
faChevronDown,
faPlus faPlus
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
faTimes, faTimes,
faChevronDown,
faPlus faPlus
) )
export default { export default {
components: {
Select
},
name: 'PollForm', name: 'PollForm',
props: ['visible'], props: ['visible'],
data: () => ({ data: () => ({

View file

@ -46,23 +46,19 @@
class="poll-type" class="poll-type"
:title="$t('polls.type')" :title="$t('polls.type')"
> >
<label <Select
for="poll-type-selector"
class="select"
>
<select
v-model="pollType" v-model="pollType"
class="select" class="poll-type-select"
unstyled="true"
@change="updatePollToParent" @change="updatePollToParent"
> >
<option value="single">{{ $t('polls.single_choice') }}</option> <option value="single">
<option value="multiple">{{ $t('polls.multiple_choices') }}</option> {{ $t('polls.single_choice') }}
</select> </option>
<FAIcon <option value="multiple">
class="select-down-icon" {{ $t('polls.multiple_choices') }}
icon="chevron-down" </option>
/> </Select>
</label>
</div> </div>
<div <div
class="poll-expiry" class="poll-expiry"
@ -76,9 +72,10 @@
:max="maxExpirationInCurrentUnit" :max="maxExpirationInCurrentUnit"
@change="expiryAmountChange" @change="expiryAmountChange"
> >
<label class="expiry-unit select"> <Select
<select
v-model="expiryUnit" v-model="expiryUnit"
unstyled="true"
class="expiry-unit"
@change="expiryAmountChange" @change="expiryAmountChange"
> >
<option <option
@ -88,12 +85,7 @@
> >
{{ $t(`time.${unit}_short`, ['']) }} {{ $t(`time.${unit}_short`, ['']) }}
</option> </option>
</select> </Select>
<FAIcon
class="select-down-icon"
icon="chevron-down"
/>
</label>
</div> </div>
</div> </div>
</div> </div>
@ -147,10 +139,9 @@
.poll-type { .poll-type {
margin-right: 0.75em; margin-right: 0.75em;
flex: 1 1 60%; flex: 1 1 60%;
.select {
border: none; .poll-type-select {
box-shadow: none; padding-right: 0.75em;
background-color: transparent;
} }
} }
@ -161,12 +152,6 @@
width: 3em; width: 3em;
text-align: right; text-align: right;
} }
.expiry-unit {
border: none;
box-shadow: none;
background-color: transparent;
}
} }
} }
</style> </style>

View file

@ -3,25 +3,32 @@ const Popover = {
props: { props: {
// Action to trigger popover: either 'hover' or 'click' // Action to trigger popover: either 'hover' or 'click'
trigger: String, trigger: String,
// Either 'top' or 'bottom' // Either 'top' or 'bottom'
placement: String, placement: String,
// Takes object with properties 'x' and 'y', values of these can be // Takes object with properties 'x' and 'y', values of these can be
// 'container' for using offsetParent as boundaries for either axis // 'container' for using offsetParent as boundaries for either axis
// or 'viewport' // or 'viewport'
boundTo: Object, boundTo: Object,
// Takes a selector to use as a replacement for the parent container // Takes a selector to use as a replacement for the parent container
// for getting boundaries for x an y axis // for getting boundaries for x an y axis
boundToSelector: String, boundToSelector: String,
// Takes a top/bottom/left/right object, how much space to leave // Takes a top/bottom/left/right object, how much space to leave
// between boundary and popover element // between boundary and popover element
margin: Object, margin: Object,
// Takes a x/y object and tells how many pixels to offset from // Takes a x/y object and tells how many pixels to offset from
// anchor point on either axis // anchor point on either axis
offset: Object, offset: Object,
// Replaces the classes you may want for the popover container. // Replaces the classes you may want for the popover container.
// Use 'popover-default' in addition to get the default popover // Use 'popover-default' in addition to get the default popover
// styles with your custom class. // styles with your custom class.
popoverClass: String, popoverClass: String,
// If true, subtract padding when calculating position for the popover, // If true, subtract padding when calculating position for the popover,
// use it when popover offset looks to be different on top vs bottom. // use it when popover offset looks to be different on top vs bottom.
removePadding: Boolean removePadding: Boolean
@ -47,8 +54,11 @@ const Popover = {
} }
// Popover will be anchored around this element, trigger ref is the container, so // Popover will be anchored around this element, trigger ref is the container, so
// its children are what are inside the slot. Expect only one slot="trigger". // its children are what are inside the slot. Expect only one v-slot:trigger.
const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
// SVGs don't have offsetWidth/Height, use fallback
const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
const screenBox = anchorEl.getBoundingClientRect() const screenBox = anchorEl.getBoundingClientRect()
// Screen position of the origin point for popover // Screen position of the origin point for popover
const origin = { x: screenBox.left + screenBox.width * 0.5, y: screenBox.top } const origin = { x: screenBox.left + screenBox.width * 0.5, y: screenBox.top }
@ -107,11 +117,11 @@ const Popover = {
const yOffset = (this.offset && this.offset.y) || 0 const yOffset = (this.offset && this.offset.y) || 0
const translateY = usingTop const translateY = usingTop
? -anchorEl.offsetHeight + vPadding - yOffset - content.offsetHeight ? -anchorHeight + vPadding - yOffset - content.offsetHeight
: yOffset : yOffset
const xOffset = (this.offset && this.offset.x) || 0 const xOffset = (this.offset && this.offset.x) || 0
const translateX = (anchorEl.offsetWidth * 0.5) - content.offsetWidth * 0.5 + horizOffset + xOffset const translateX = anchorWidth * 0.5 - content.offsetWidth * 0.5 + horizOffset + xOffset
// Note, separate translateX and translateY avoids blurry text on chromium, // Note, separate translateX and translateY avoids blurry text on chromium,
// single translate or translate3d resulted in blurry text. // single translate or translate3d resulted in blurry text.
@ -121,9 +131,12 @@ const Popover = {
} }
}, },
showPopover () { showPopover () {
if (this.hidden) this.$emit('show') const wasHidden = this.hidden
this.hidden = false this.hidden = false
this.$nextTick(this.updateStyles) this.$nextTick(() => {
if (wasHidden) this.$emit('show')
this.updateStyles()
})
}, },
hidePopover () { hidePopover () {
if (!this.hidden) this.$emit('close') if (!this.hidden) this.$emit('close')

View file

@ -6,6 +6,7 @@
<button <button
ref="trigger" ref="trigger"
class="button-unstyled -fullwidth popover-trigger-button" class="button-unstyled -fullwidth popover-trigger-button"
type="button"
@click="onClick" @click="onClick"
> >
<slot name="trigger" /> <slot name="trigger" />
@ -32,7 +33,7 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.popover-trigger-button { .popover-trigger-button {
display: block; display: inline-block;
} }
.popover { .popover {
@ -81,10 +82,9 @@
.dropdown-item { .dropdown-item {
line-height: 21px; line-height: 21px;
margin-right: 5px;
overflow: auto; overflow: auto;
display: block; display: block;
padding: .25rem 1.0rem .25rem 1.5rem; padding: .5em 0.75em;
clear: both; clear: both;
font-weight: 400; font-weight: 400;
text-align: inherit; text-align: inherit;
@ -100,10 +100,9 @@
--btnText: var(--popoverText, $fallback--text); --btnText: var(--popoverText, $fallback--text);
&-icon { &-icon {
padding-left: 0.5rem;
svg { svg {
margin-right: 0.25rem; width: 22px;
margin-right: 0.75rem;
color: var(--menuPopoverIcon, $fallback--icon) color: var(--menuPopoverIcon, $fallback--icon)
} }
} }
@ -122,6 +121,33 @@
} }
} }
.menu-checkbox {
display: inline-block;
vertical-align: middle;
min-width: 22px;
max-width: 22px;
min-height: 22px;
max-height: 22px;
line-height: 22px;
text-align: center;
border-radius: 0px;
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
box-shadow: 0px 0px 2px black inset;
box-shadow: var(--inputShadow);
margin-right: 0.75em;
&.menu-checkbox-checked::after {
font-size: 1.25em;
content: '✓';
}
&.menu-checkbox-radio::after {
font-size: 2em;
content: '•';
}
}
} }
} }
</style> </style>

View file

@ -4,6 +4,7 @@ import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji_input/emoji_input.vue' import EmojiInput from '../emoji_input/emoji_input.vue'
import PollForm from '../poll/poll_form.vue' import PollForm from '../poll/poll_form.vue'
import Attachment from '../attachment/attachment.vue' import Attachment from '../attachment/attachment.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import StatusContent from '../status_content/status_content.vue' import StatusContent from '../status_content/status_content.vue'
import fileTypeService from '../../services/file_type/file_type.service.js' import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
@ -11,10 +12,10 @@ import { reject, map, uniqBy, debounce } from 'lodash'
import suggestor from '../emoji_input/suggestor.js' import suggestor from '../emoji_input/suggestor.js'
import { mapGetters, mapState } from 'vuex' import { mapGetters, mapState } from 'vuex'
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from '../checkbox/checkbox.vue'
import Select from '../select/select.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faChevronDown,
faSmileBeam, faSmileBeam,
faPollH, faPollH,
faUpload, faUpload,
@ -24,7 +25,6 @@ import {
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
faChevronDown,
faSmileBeam, faSmileBeam,
faPollH, faPollH,
faUpload, faUpload,
@ -84,8 +84,10 @@ const PostStatusForm = {
PollForm, PollForm,
ScopeSelector, ScopeSelector,
Checkbox, Checkbox,
Select,
Attachment, Attachment,
StatusContent StatusContent,
Gallery
}, },
mounted () { mounted () {
this.updateIdempotencyKey() this.updateIdempotencyKey()
@ -115,7 +117,7 @@ const PostStatusForm = {
? this.copyMessageScope ? this.copyMessageScope
: this.$store.state.users.currentUser.default_scope : this.$store.state.users.currentUser.default_scope
const { postContentType: contentType } = this.$store.getters.mergedConfig const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig
return { return {
dropFiles: [], dropFiles: [],
@ -126,7 +128,7 @@ const PostStatusForm = {
newStatus: { newStatus: {
spoilerText: this.subject || '', spoilerText: this.subject || '',
status: statusText, status: statusText,
nsfw: false, nsfw: !!sensitiveByDefault,
files: [], files: [],
poll: {}, poll: {},
mediaDescriptions: {}, mediaDescriptions: {},
@ -388,6 +390,21 @@ const PostStatusForm = {
this.newStatus.files.splice(index, 1) this.newStatus.files.splice(index, 1)
this.$emit('resize') this.$emit('resize')
}, },
editAttachment (fileInfo, newText) {
this.newStatus.mediaDescriptions[fileInfo.id] = newText
},
shiftUpMediaFile (fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
files.splice(index, 1)
files.splice(index - 1, 0, fileInfo)
},
shiftDnMediaFile (fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
files.splice(index, 1)
files.splice(index + 1, 0, fileInfo)
},
uploadFailed (errString, templateArgs) { uploadFailed (errString, templateArgs) {
templateArgs = templateArgs || {} templateArgs = templateArgs || {}
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs) this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)

View file

@ -189,11 +189,7 @@
v-if="postFormats.length > 1" v-if="postFormats.length > 1"
class="text-format" class="text-format"
> >
<label <Select
for="post-content-type"
class="select"
>
<select
id="post-content-type" id="post-content-type"
v-model="newStatus.contentType" v-model="newStatus.contentType"
class="form-control" class="form-control"
@ -205,12 +201,7 @@
> >
{{ $t(`post_status.content_type["${postFormat}"]`) }} {{ $t(`post_status.content_type["${postFormat}"]`) }}
</option> </option>
</select> </Select>
<FAIcon
class="select-down-icon"
icon="chevron-down"
/>
</label>
</div> </div>
<div <div
v-if="postFormats.length === 1 && postFormats[0] !== 'text/plain'" v-if="postFormats.length === 1 && postFormats[0] !== 'text/plain'"
@ -272,7 +263,7 @@
disabled disabled
class="btn button-default" class="btn button-default"
> >
{{ $t('general.submit') }} {{ $t('post_status.post') }}
</button> </button>
<!-- touchstart is used to keep the OSK at the same position after a message send --> <!-- touchstart is used to keep the OSK at the same position after a message send -->
<button <button
@ -282,7 +273,7 @@
@touchstart.stop.prevent="postStatus($event, newStatus)" @touchstart.stop.prevent="postStatus($event, newStatus)"
@click.stop.prevent="postStatus($event, newStatus)" @click.stop.prevent="postStatus($event, newStatus)"
> >
{{ $t('general.submit') }} {{ $t('post_status.post') }}
</button> </button>
</div> </div>
<div <div
@ -296,32 +287,22 @@
@click="clearError" @click="clearError"
/> />
</div> </div>
<div class="attachments"> <gallery
<div v-if="newStatus.files && newStatus.files.length > 0"
v-for="file in newStatus.files" class="attachments"
:key="file.url" :grid="true"
class="media-upload-wrapper" :nsfw="false"
> :attachments="newStatus.files"
<button :descriptions="newStatus.mediaDescriptions"
class="button-unstyled hider"
@click="removeMediaFile(file)"
>
<FAIcon icon="times" />
</button>
<attachment
:attachment="file"
:set-media="() => $store.dispatch('setMedia', newStatus.files)" :set-media="() => $store.dispatch('setMedia', newStatus.files)"
size="small" :editable="true"
allow-play="false" :edit-attachment="editAttachment"
:remove-attachment="removeMediaFile"
:shift-up-attachment="newStatus.files.length > 1 && shiftUpMediaFile"
:shift-dn-attachment="newStatus.files.length > 1 && shiftDnMediaFile"
@play="$emit('mediaplay', attachment.id)"
@pause="$emit('mediapause', attachment.id)"
/> />
<input
v-model="newStatus.mediaDescriptions[file.id]"
type="text"
:placeholder="$t('post_status.media_description')"
@keydown.enter.prevent=""
>
</div>
</div>
<div <div
v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox" v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
class="upload_settings" class="upload_settings"
@ -339,26 +320,13 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.tribute-container {
ul {
padding: 0px;
li {
display: flex;
align-items: center;
}
}
img {
padding: 3px;
width: 16px;
height: 16px;
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
}
}
.post-status-form { .post-status-form {
position: relative; position: relative;
.attachments {
margin-bottom: 0.5em;
}
.form-bottom { .form-bottom {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -516,15 +484,6 @@
flex-direction: column; flex-direction: column;
} }
.attachments .media-upload-wrapper {
position: relative;
.attachment {
margin: 0;
padding: 0;
}
}
.btn { .btn {
cursor: pointer; cursor: pointer;
} }
@ -625,11 +584,4 @@
border: 2px dashed var(--text, $fallback--text); border: 2px dashed var(--text, $fallback--text);
} }
} }
// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before)
img.media-upload, .media-upload-container > video {
line-height: 0;
max-height: 200px;
max-width: 100%;
}
</style> </style>

View file

@ -23,6 +23,12 @@ const ReactButton = {
this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji }) this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
} }
close() close()
},
focusInput () {
this.$nextTick(() => {
const input = this.$el.querySelector('input')
if (input) input.focus()
})
} }
}, },
computed: { computed: {

View file

@ -1,15 +1,14 @@
<template> <template>
<Popover <Popover
trigger="click" trigger="click"
class="ReactButton"
placement="top" placement="top"
:offset="{ y: 5 }" :offset="{ y: 5 }"
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
remove-padding remove-padding
@show="focusInput"
> >
<div <template v-slot:content="{close}">
slot="content"
slot-scope="{close}"
>
<div class="reaction-picker-filter"> <div class="reaction-picker-filter">
<input <input
v-model="filterWord" v-model="filterWord"
@ -39,17 +38,18 @@
</span> </span>
<div class="reaction-bottom-fader" /> <div class="reaction-bottom-fader" />
</div> </div>
</div> </template>
<span <template v-slot:trigger>
slot="trigger" <button
class="ReactButton" class="button-unstyled popover-trigger"
:title="$t('tool_tip.add_reaction')" :title="$t('tool_tip.add_reaction')"
> >
<FAIcon <FAIcon
class="fa-scale-110 fa-old-padding" class="fa-scale-110 fa-old-padding"
:icon="['far', 'smile-beam']" :icon="['far', 'smile-beam']"
/> />
</span> </button>
</template>
</Popover> </Popover>
</template> </template>
@ -58,9 +58,11 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.ReactButton {
.reaction-picker-filter { .reaction-picker-filter {
padding: 0.5em; padding: 0.5em;
display: flex; display: flex;
input { input {
flex: 1; flex: 1;
} }
@ -90,7 +92,8 @@
linear-gradient(to top, white, white); linear-gradient(to top, white, white);
transition: mask-size 150ms; transition: mask-size 150ms;
mask-size: 100% 20px, 100% 20px, auto; mask-size: 100% 20px, 100% 20px, auto;
// Autoprefixed seem to ignore this one, and also syntax is different
/* Autoprefixed seem to ignore this one, and also syntax is different */
-webkit-mask-composite: xor; -webkit-mask-composite: xor;
mask-composite: exclude; mask-composite: exclude;
@ -107,7 +110,12 @@
} }
} }
.ReactButton { /* override of popover internal stuff */
.popover-trigger-button {
width: auto;
}
.popover-trigger {
padding: 10px; padding: 10px;
margin: -10px; margin: -10px;
@ -116,5 +124,6 @@
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
} }
}
</style> </style>

View file

@ -10,7 +10,8 @@ const registration = {
fullname: '', fullname: '',
username: '', username: '',
password: '', password: '',
confirm: '' confirm: '',
reason: ''
}, },
captcha: {} captcha: {}
}), }),
@ -24,7 +25,8 @@ const registration = {
confirm: { confirm: {
required, required,
sameAsPassword: sameAs('password') sameAsPassword: sameAs('password')
} },
reason: { required: requiredIf(() => this.accountApprovalRequired) }
} }
} }
}, },
@ -38,7 +40,10 @@ const registration = {
computed: { computed: {
token () { return this.$route.params.token }, token () { return this.$route.params.token },
bioPlaceholder () { bioPlaceholder () {
return this.$t('registration.bio_placeholder').replace(/\s*\n\s*/g, ' \n') return this.replaceNewlines(this.$t('registration.bio_placeholder'))
},
reasonPlaceholder () {
return this.replaceNewlines(this.$t('registration.reason_placeholder'))
}, },
...mapState({ ...mapState({
registrationOpen: (state) => state.instance.registrationOpen, registrationOpen: (state) => state.instance.registrationOpen,
@ -46,7 +51,8 @@ const registration = {
isPending: (state) => state.users.signUpPending, isPending: (state) => state.users.signUpPending,
serverValidationErrors: (state) => state.users.signUpErrors, serverValidationErrors: (state) => state.users.signUpErrors,
termsOfService: (state) => state.instance.tos, termsOfService: (state) => state.instance.tos,
accountActivationRequired: (state) => state.instance.accountActivationRequired accountActivationRequired: (state) => state.instance.accountActivationRequired,
accountApprovalRequired: (state) => state.instance.accountApprovalRequired
}) })
}, },
methods: { methods: {
@ -73,6 +79,9 @@ const registration = {
}, },
setCaptcha () { setCaptcha () {
this.getCaptcha().then(cpt => { this.captcha = cpt }) this.getCaptcha().then(cpt => { this.captcha = cpt })
},
replaceNewlines (str) {
return str.replace(/\s*\n\s*/g, ' \n')
} }
} }
} }

View file

@ -162,6 +162,23 @@
</ul> </ul>
</div> </div>
<div
v-if="accountApprovalRequired"
class="form-group"
>
<label
class="form--label"
for="reason"
>{{ $t('registration.reason') }}</label>
<textarea
id="reason"
v-model="user.reason"
:disabled="isPending"
class="form-control"
:placeholder="reasonPlaceholder"
/>
</div>
<div <div
v-if="captcha.type != 'none'" v-if="captcha.type != 'none'"
id="captcha-group" id="captcha-group"
@ -213,7 +230,7 @@
type="submit" type="submit"
class="btn button-default" class="btn button-default"
> >
{{ $t('general.submit') }} {{ $t('registration.register') }}
</button> </button>
</div> </div>
</div> </div>

View file

@ -0,0 +1,328 @@
import Vue from 'vue'
import { unescape, flattenDeep } from 'lodash'
import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
import StillImage from 'src/components/still-image/still-image.vue'
import MentionsLine, { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.vue'
import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
import './rich_content.scss'
/**
* RichContent, The Über-powered component for rendering Post HTML.
*
* This takes post HTML and does multiple things to it:
* - Groups all mentions into <MentionsLine>, this affects all mentions regardles
* of where they are (beginning/middle/end), even single mentions are converted
* to a <MentionsLine> containing single <MentionLink>.
* - Replaces emoji shortcodes with <StillImage>'d images.
*
* There are two problems with this component's architecture:
* 1. Parsing HTML and rendering are inseparable. Attempts to separate the two
* proven to be a massive overcomplication due to amount of things done here.
* 2. We need to output both render and some extra data, which seems to be imp-
* possible in vue. Current solution is to emit 'parseReady' event when parsing
* is done within render() function.
*
* Apart from that one small hiccup with emit in render this _should_ be vue3-ready
*/
export default Vue.component('RichContent', {
name: 'RichContent',
props: {
// Original html content
html: {
required: true,
type: String
},
attentions: {
required: false,
default: () => []
},
// Emoji object, as in status.emojis, note the "s" at the end...
emoji: {
required: true,
type: Array
},
// Whether to handle links or not (posts: yes, everything else: no)
handleLinks: {
required: false,
type: Boolean,
default: false
},
// Meme arrows
greentext: {
required: false,
type: Boolean,
default: false
}
},
// NEVER EVER TOUCH DATA INSIDE RENDER
render (h) {
// Pre-process HTML
const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
let currentMentions = null // Current chain of mentions, we group all mentions together
// This is used to recover spacing removed when parsing mentions
let lastSpacing = ''
const lastTags = [] // Tags that appear at the end of post body
const writtenMentions = [] // All mentions that appear in post body
const invisibleMentions = [] // All mentions that go beyond the limiter (see MentionsLine)
// to collapse too many mentions in a row
const writtenTags = [] // All tags that appear in post body
// unique index for vue "tag" property
let mentionIndex = 0
let tagsIndex = 0
const renderImage = (tag) => {
return <StillImage
{...{ attrs: getAttrs(tag) }}
class="img"
/>
}
const renderHashtag = (attrs, children, encounteredTextReverse) => {
const linkData = getLinkData(attrs, children, tagsIndex++)
writtenTags.push(linkData)
if (!encounteredTextReverse) {
lastTags.push(linkData)
}
return <HashtagLink {...{ props: linkData }}/>
}
const renderMention = (attrs, children) => {
const linkData = getLinkData(attrs, children, mentionIndex++)
linkData.notifying = this.attentions.some(a => a.statusnet_profile_url === linkData.url)
writtenMentions.push(linkData)
if (currentMentions === null) {
currentMentions = []
}
currentMentions.push(linkData)
if (currentMentions.length > MENTIONS_LIMIT) {
invisibleMentions.push(linkData)
}
if (currentMentions.length === 1) {
return <MentionsLine mentions={ currentMentions } />
} else {
return ''
}
}
// Processor to use with html_tree_converter
const processItem = (item, index, array, what) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
const emptyText = item.trim() === ''
if (item.includes('\n')) {
currentMentions = null
}
if (emptyText) {
// don't include spaces when processing mentions - we'll include them
// in MentionsLine
lastSpacing = item
// Don't remove last space in a container (fixes poast mentions)
return (index !== array.length - 1) && (currentMentions !== null) ? item.trim() : item
}
currentMentions = null
if (item.includes(':')) {
item = ['', processTextForEmoji(
item,
this.emoji,
({ shortcode, url }) => {
return <StillImage
class="emoji img"
src={url}
title={`:${shortcode}:`}
alt={`:${shortcode}:`}
/>
}
)]
}
return item
}
// Handle tag nodes
if (Array.isArray(item)) {
const [opener, children, closer] = item
const Tag = getTagName(opener)
const attrs = getAttrs(opener)
const previouslyMentions = currentMentions !== null
/* During grouping of mentions we trim all the empty text elements
* This padding is added to recover last space removed in case
* we have a tag right next to mentions
*/
const mentionsLinePadding =
// Padding is only needed if we just finished parsing mentions
previouslyMentions &&
// Don't add padding if content is string and has padding already
!(children && typeof children[0] === 'string' && children[0].match(/^\s/))
? lastSpacing
: ''
switch (Tag) {
case 'br':
currentMentions = null
break
case 'img': // replace images with StillImage
return ['', [mentionsLinePadding, renderImage(opener)], '']
case 'a': // replace mentions with MentionLink
if (!this.handleLinks) break
if (attrs['class'] && attrs['class'].includes('mention')) {
// Handling mentions here
return renderMention(attrs, children)
} else {
currentMentions = null
break
}
case 'span':
if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) {
return ['', children.map(processItem), '']
}
}
if (children !== undefined) {
return [
'',
[
mentionsLinePadding,
[opener, children.map(processItem), closer]
],
''
]
} else {
return ['', [mentionsLinePadding, item], '']
}
}
}
// Processor for back direction (for finding "last" stuff, just easier this way)
let encounteredTextReverse = false
const processItemReverse = (item, index, array, what) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
const emptyText = item.trim() === ''
if (emptyText) return item
if (!encounteredTextReverse) encounteredTextReverse = true
return unescape(item)
} else if (Array.isArray(item)) {
// Handle tag nodes
const [opener, children] = item
const Tag = opener === '' ? '' : getTagName(opener)
switch (Tag) {
case 'a': // replace mentions with MentionLink
if (!this.handleLinks) break
const attrs = getAttrs(opener)
// should only be this
if (
(attrs['class'] && attrs['class'].includes('hashtag')) || // Pleroma style
(attrs['rel'] === 'tag') // Mastodon style
) {
return renderHashtag(attrs, children, encounteredTextReverse)
} else {
attrs.target = '_blank'
const newChildren = [...children].reverse().map(processItemReverse).reverse()
return <a {...{ attrs }}>
{ newChildren }
</a>
}
case '':
return [...children].reverse().map(processItemReverse).reverse()
}
// Render tag as is
if (children !== undefined) {
const newChildren = Array.isArray(children)
? [...children].reverse().map(processItemReverse).reverse()
: children
return <Tag {...{ attrs: getAttrs(opener) }}>
{ newChildren }
</Tag>
} else {
return <Tag/>
}
}
return item
}
const pass1 = convertHtmlToTree(html).map(processItem)
const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
// DO NOT USE SLOTS they cause a re-render feedback loop here.
// slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
// at least until vue3?
const result = <span class="RichContent">
{ pass2 }
</span>
const event = {
lastTags,
writtenMentions,
writtenTags,
invisibleMentions
}
// DO NOT MOVE TO UPDATE. BAD IDEA.
this.$emit('parseReady', event)
return result
}
})
const getLinkData = (attrs, children, index) => {
const stripTags = (item) => {
if (typeof item === 'string') {
return item
} else {
return item[1].map(stripTags).join('')
}
}
const textContent = children.map(stripTags).join('')
return {
index,
url: attrs.href,
tag: attrs['data-tag'],
content: flattenDeep(children).join(''),
textContent
}
}
/** Pre-processing HTML
*
* Currently this does one thing:
* - add green/cyantexting
*
* @param {String} html - raw HTML to process
* @param {Boolean} greentext - whether to enable greentexting or not
*/
export const preProcessPerLine = (html, greentext) => {
const greentextHandle = new Set(['p', 'div'])
const lines = convertHtmlToLines(html)
const newHtml = lines.reverse().map((item, index, array) => {
if (!item.text) return item
const string = item.text
// Greentext stuff
if (
// Only if greentext is engaged
greentext &&
// Only handle p's and divs. Don't want to affect blockquotes, code etc
item.level.every(l => greentextHandle.has(l)) &&
// Only if line begins with '>' or '<'
(string.includes('&gt;') || string.includes('&lt;'))
) {
const cleanedString = string.replace(/<[^>]+?>/gi, '') // remove all tags
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
if (cleanedString.startsWith('&gt;')) {
return `<span class='greentext'>${string}</span>`
} else if (cleanedString.startsWith('&lt;')) {
return `<span class='cyantext'>${string}</span>`
}
}
return string
}).reverse().join('')
return { newHtml }
}

View file

@ -0,0 +1,64 @@
.RichContent {
blockquote {
margin: 0.2em 0 0.2em 2em;
font-style: italic;
}
pre {
overflow: auto;
}
code,
samp,
kbd,
var,
pre {
font-family: var(--postCodeFont, monospace);
}
p {
margin: 0 0 1em 0;
}
p:last-child {
margin: 0 0 0 0;
}
h1 {
font-size: 1.1em;
line-height: 1.2em;
margin: 1.4em 0;
}
h2 {
font-size: 1.1em;
margin: 1em 0;
}
h3 {
font-size: 1em;
margin: 1.2em 0;
}
h4 {
margin: 1.1em 0;
}
.img {
display: inline-block;
}
.emoji {
display: inline-block;
width: var(--emoji-size, 32px);
height: var(--emoji-size, 32px);
}
.img,
video {
max-width: 100%;
max-height: 400px;
vertical-align: middle;
object-fit: contain;
}
}

View file

@ -8,6 +8,7 @@
class="button-unstyled scope" class="button-unstyled scope"
:class="css.direct" :class="css.direct"
:title="$t('post_status.scope.direct')" :title="$t('post_status.scope.direct')"
type="button"
@click="changeVis('direct')" @click="changeVis('direct')"
> >
<FAIcon <FAIcon
@ -20,6 +21,7 @@
class="button-unstyled scope" class="button-unstyled scope"
:class="css.private" :class="css.private"
:title="$t('post_status.scope.private')" :title="$t('post_status.scope.private')"
type="button"
@click="changeVis('private')" @click="changeVis('private')"
> >
<FAIcon <FAIcon
@ -32,6 +34,7 @@
class="button-unstyled scope" class="button-unstyled scope"
:class="css.unlisted" :class="css.unlisted"
:title="$t('post_status.scope.unlisted')" :title="$t('post_status.scope.unlisted')"
type="button"
@click="changeVis('unlisted')" @click="changeVis('unlisted')"
> >
<FAIcon <FAIcon
@ -44,6 +47,7 @@
class="button-unstyled scope" class="button-unstyled scope"
:class="css.public" :class="css.public"
:title="$t('post_status.scope.public')" :title="$t('post_status.scope.public')"
type="button"
@click="changeVis('public')" @click="changeVis('public')"
> >
<FAIcon <FAIcon

View file

@ -15,6 +15,7 @@
> >
<button <button
class="btn button-default search-button" class="btn button-default search-button"
type="submit"
@click="newQuery(searchTerm)" @click="newQuery(searchTerm)"
> >
<FAIcon icon="search" /> <FAIcon icon="search" />

View file

@ -7,6 +7,7 @@
v-if="hidden" v-if="hidden"
class="button-unstyled nav-icon" class="button-unstyled nav-icon"
:title="$t('nav.search')" :title="$t('nav.search')"
type="button"
@click.prevent.stop="toggleHidden" @click.prevent.stop="toggleHidden"
> >
<FAIcon <FAIcon
@ -27,6 +28,7 @@
> >
<button <button
class="button-default search-button" class="button-default search-button"
type="submit"
@click="find(searchTerm)" @click="find(searchTerm)"
> >
<FAIcon <FAIcon
@ -36,6 +38,7 @@
</button> </button>
<button <button
class="button-unstyled cancel-search" class="button-unstyled cancel-search"
type="button"
@click.prevent.stop="toggleHidden" @click.prevent.stop="toggleHidden"
> >
<FAIcon <FAIcon

View file

@ -0,0 +1,21 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronDown
} from '@fortawesome/free-solid-svg-icons'
library.add(
faChevronDown
)
export default {
model: {
prop: 'value',
event: 'change'
},
props: [
'value',
'disabled',
'unstyled',
'kind'
]
}

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