Merge branch 'develop' of https://git.pleroma.social/pleroma/pleroma-fe into feat/report-notification
This commit is contained in:
		
						commit
						d0c4ad22cd
					
				
					 225 changed files with 11974 additions and 3981 deletions
				
			
		
							
								
								
									
										4
									
								
								.babelrc
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								.babelrc
									
									
									
									
									
								
							|  | @ -1,5 +1,5 @@ | |||
| { | ||||
|   "presets": ["@babel/preset-env"], | ||||
|   "plugins": ["@babel/plugin-transform-runtime", "lodash", "@vue/babel-plugin-transform-vue-jsx"], | ||||
|   "presets": ["@babel/preset-env", "@vue/babel-preset-jsx"], | ||||
|   "plugins": ["@babel/plugin-transform-runtime", "lodash"], | ||||
|   "comments": false | ||||
| } | ||||
|  |  | |||
							
								
								
									
										1
									
								
								.mailmap
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.mailmap
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| rinpatch <rin@patch.cx> <rinpatch@sdf.org> | ||||
							
								
								
									
										85
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										85
									
								
								CHANGELOG.md
									
									
									
									
									
								
							|  | @ -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/). | ||||
| 
 | ||||
| ## [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 | ||||
| - Button to remove uploaded media in post status form is now properly placed and sized. | ||||
| - Fixed shoutbox not working in mobile layout | ||||
| - Fixed missing highlighted border in expanded conversations again | ||||
| - Fixed some UI jumpiness when opening images particularly in chat view | ||||
| - Fixed chat unread badge looking weird | ||||
| - Fixed punycode names not working properly | ||||
| - Fixed notifications crashing on an invalid notification | ||||
| 
 | ||||
| ### Changed | ||||
| - Display 'people voted' instead of 'votes' for multi-choice polls | ||||
| - Changed the "Timelines" link in side panel to toggle show all timeline options inside the panel | ||||
| - Renamed "Timeline" to "Home Timeline" to be more clear | ||||
| - Optimized chat to not get horrible performance after keeping the same chat open for a long time | ||||
| - When opening emoji picker or react picker, it automatically focuses the search field | ||||
| - Language picker now uses native language names | ||||
| 
 | ||||
| ### Added | ||||
| - Added reason field for registration when approval is required | ||||
| - Group staff members by role in the About page | ||||
| 
 | ||||
| 
 | ||||
| ## [2.2.3] - 2021-01-18 | ||||
|  | @ -16,10 +94,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). | |||
| ### Fixed | ||||
| - Follows/Followers tabs on user profiles now display the content properly. | ||||
| - Handle punycode in screen names | ||||
| - Fixed local dev mode having non-functional websockets in some cases | ||||
| - Show notices for websocket events (errors, abnormal closures, reconnections) | ||||
| - Fix not being able to re-enable websocket until page refresh | ||||
| - Fix annoying issue where timeline might have few posts when streaming is enabled | ||||
| 
 | ||||
| ### Changed | ||||
| - Don't filter own posts when they hit your wordfilter | ||||
| - Language picker now uses native language names | ||||
| 
 | ||||
| 
 | ||||
| ## [2.2.2] - 2020-12-22 | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ Contributors of this project. | |||
| - Constance Variable (lambadalambda@social.heldscal.la): Code | ||||
| - Coco Snuss (cocosnuss@social.heldscal.la): Code | ||||
| - wakarimasen (wakarimasen@shitposter.club): NSFW hiding image | ||||
| - eris (eris@disqordia.space): Code | ||||
| - dtluna (dtluna@social.heldscal.la): Code | ||||
| - sonyam (sonyam@social.heldscal.la): Background images | ||||
| - hakui (hakui@freezepeach.xyz): CSS and styling | ||||
|  |  | |||
|  | @ -21,6 +21,7 @@ var compiler = webpack(webpackConfig) | |||
| 
 | ||||
| var devMiddleware = require('webpack-dev-middleware')(compiler, { | ||||
|   publicPath: webpackConfig.output.publicPath, | ||||
|   writeToDisk: true, | ||||
|   stats: { | ||||
|     colors: true, | ||||
|     chunks: false | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ var config = require('../config') | |||
| var utils = require('./utils') | ||||
| var projectRoot = path.resolve(__dirname, '../') | ||||
| var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin') | ||||
| var CopyPlugin = require('copy-webpack-plugin'); | ||||
| 
 | ||||
| var env = process.env.NODE_ENV | ||||
| // check env & config/index.js to decide weither to enable CSS Sourcemaps for the
 | ||||
|  | @ -93,6 +94,19 @@ module.exports = { | |||
|     new ServiceWorkerWebpackPlugin({ | ||||
|       entry: path.join(__dirname, '..', 'src/sw.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, | ||||
|       }, | ||||
|     }) | ||||
|   ] | ||||
| } | ||||
|  |  | |||
|  | @ -3,6 +3,11 @@ const path = require('path') | |||
| let settings = {} | ||||
| try { | ||||
|   settings = require('./local.json') | ||||
|   if (settings.target && settings.target.endsWith('/')) { | ||||
|     // replacing trailing slash since it can conflict with some apis
 | ||||
|     // and that's how actual BE reports its url
 | ||||
|     settings.target = settings.target.replace(/\/$/, '') | ||||
|   } | ||||
|   console.log('Using local dev server settings (/config/local.json):') | ||||
|   console.log(JSON.stringify(settings, null, 2)) | ||||
| } catch (e) { | ||||
|  |  | |||
							
								
								
									
										11
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								package.json
									
									
									
									
									
								
							|  | @ -32,9 +32,9 @@ | |||
|     "phoenix": "^1.3.0", | ||||
|     "portal-vue": "^2.1.4", | ||||
|     "punycode.js": "^2.1.0", | ||||
|     "ruffle-mirror": "^2021.4.10", | ||||
|     "v-click-outside": "^2.1.1", | ||||
|     "vue": "^2.6.11", | ||||
|     "vue-chat-scroll": "^1.2.1", | ||||
|     "vue-i18n": "^7.3.2", | ||||
|     "vue-router": "^3.0.1", | ||||
|     "vue-template-compiler": "^2.6.11", | ||||
|  | @ -47,8 +47,8 @@ | |||
|     "@babel/preset-env": "^7.7.6", | ||||
|     "@babel/register": "^7.7.4", | ||||
|     "@ungap/event-target": "^0.1.0", | ||||
|     "@vue/babel-helper-vue-jsx-merge-props": "^1.0.0", | ||||
|     "@vue/babel-plugin-transform-vue-jsx": "^1.1.2", | ||||
|     "@vue/babel-helper-vue-jsx-merge-props": "^1.2.1", | ||||
|     "@vue/babel-preset-jsx": "^1.2.4", | ||||
|     "@vue/test-utils": "^1.0.0-beta.26", | ||||
|     "autoprefixer": "^6.4.0", | ||||
|     "babel-eslint": "^7.0.0", | ||||
|  | @ -58,6 +58,7 @@ | |||
|     "chalk": "^1.1.3", | ||||
|     "chromedriver": "^87.0.1", | ||||
|     "connect-history-api-fallback": "^1.1.0", | ||||
|     "copy-webpack-plugin": "^6.4.1", | ||||
|     "cross-spawn": "^4.0.2", | ||||
|     "css-loader": "^0.28.0", | ||||
|     "custom-event-polyfill": "^1.0.7", | ||||
|  | @ -103,7 +104,7 @@ | |||
|     "selenium-server": "2.53.1", | ||||
|     "semver": "^5.3.0", | ||||
|     "serviceworker-webpack-plugin": "^1.0.0", | ||||
|     "shelljs": "^0.7.4", | ||||
|     "shelljs": "^0.8.4", | ||||
|     "sinon": "^2.1.0", | ||||
|     "sinon-chai": "^2.8.0", | ||||
|     "stylelint": "^13.6.1", | ||||
|  | @ -112,7 +113,7 @@ | |||
|     "url-loader": "^1.1.2", | ||||
|     "vue-loader": "^14.0.0", | ||||
|     "vue-style-loader": "^4.0.0", | ||||
|     "webpack": "^4.0.0", | ||||
|     "webpack": "^4.44.0", | ||||
|     "webpack-dev-middleware": "^3.6.0", | ||||
|     "webpack-hot-middleware": "^2.12.2", | ||||
|     "webpack-merge": "^0.14.1" | ||||
|  |  | |||
							
								
								
									
										14
									
								
								src/App.js
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								src/App.js
									
									
									
									
									
								
							|  | @ -4,7 +4,7 @@ import Notifications from './components/notifications/notifications.vue' | |||
| import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue' | ||||
| import FeaturesPanel from './components/features_panel/features_panel.vue' | ||||
| import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue' | ||||
| import 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 MediaModal from './components/media_modal/media_modal.vue' | ||||
| import SideDrawer from './components/side_drawer/side_drawer.vue' | ||||
|  | @ -26,7 +26,7 @@ export default { | |||
|     InstanceSpecificPanel, | ||||
|     FeaturesPanel, | ||||
|     WhoToFollowPanel, | ||||
|     ChatPanel, | ||||
|     ShoutPanel, | ||||
|     MediaModal, | ||||
|     SideDrawer, | ||||
|     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 }, | ||||
|     showInstanceSpecificPanel () { | ||||
|       return this.$store.state.instance.showInstanceSpecificPanel && | ||||
|  | @ -73,11 +73,17 @@ export default { | |||
|         this.$store.state.instance.instanceSpecificPanelContent | ||||
|     }, | ||||
|     showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, | ||||
|     shoutboxPosition () { | ||||
|       return this.$store.getters.mergedConfig.showNewPostButton || false | ||||
|     }, | ||||
|     hideShoutbox () { | ||||
|       return this.$store.getters.mergedConfig.hideShoutbox | ||||
|     }, | ||||
|     isMobileLayout () { return this.$store.state.interface.mobileLayout }, | ||||
|     privateMode () { return this.$store.state.instance.private }, | ||||
|     sidebarAlign () { | ||||
|       return { | ||||
|         'order': this.$store.state.instance.sidebarRight ? 99 : 0 | ||||
|         'order': this.$store.getters.mergedConfig.sidebarRight ? 99 : 0 | ||||
|       } | ||||
|     }, | ||||
|     ...mapGetters(['mergedConfig']) | ||||
|  |  | |||
							
								
								
									
										90
									
								
								src/App.scss
									
									
									
									
									
								
							
							
						
						
									
										90
									
								
								src/App.scss
									
									
									
									
									
								
							|  | @ -88,6 +88,10 @@ a { | |||
|   font-family: sans-serif; | ||||
|   font-family: var(--interfaceFont, sans-serif); | ||||
| 
 | ||||
|   &.-sublime { | ||||
|     background: transparent; | ||||
|   } | ||||
| 
 | ||||
|   i[class*=icon-], | ||||
|   .svg-inline--fa { | ||||
|     color: $fallback--text; | ||||
|  | @ -187,7 +191,7 @@ a { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| input, textarea, .select, .input { | ||||
| input, textarea, .input { | ||||
| 
 | ||||
|   &.unstyled { | ||||
|     border-radius: 0; | ||||
|  | @ -217,47 +221,11 @@ input, textarea, .select, .input { | |||
|   hyphens: none; | ||||
|   padding: 8px .5em; | ||||
| 
 | ||||
|   &.select { | ||||
|     padding: 0; | ||||
|   } | ||||
| 
 | ||||
|   &:disabled, &[disabled=disabled] { | ||||
|   &:disabled, &[disabled=disabled], &.disabled { | ||||
|     cursor: not-allowed; | ||||
|     opacity: 0.5; | ||||
|   } | ||||
| 
 | ||||
|   .select-down-icon { | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     bottom: 0; | ||||
|     right: 5px; | ||||
|     height: 100%; | ||||
|     color: $fallback--text; | ||||
|     color: var(--inputText, $fallback--text); | ||||
|     line-height: 28px; | ||||
|     z-index: 0; | ||||
|     pointer-events: none; | ||||
|   } | ||||
| 
 | ||||
|   select { | ||||
|     -webkit-appearance: none; | ||||
|     -moz-appearance: none; | ||||
|     appearance: none; | ||||
|     background: transparent; | ||||
|     border: none; | ||||
|     color: $fallback--text; | ||||
|     color: var(--inputText, --text, $fallback--text); | ||||
|     margin: 0; | ||||
|     padding: 0 2em 0 .2em; | ||||
|     font-family: sans-serif; | ||||
|     font-family: var(--inputFont, sans-serif); | ||||
|     font-size: 14px; | ||||
|     width: 100%; | ||||
|     z-index: 1; | ||||
|     height: 28px; | ||||
|     line-height: 16px; | ||||
|   } | ||||
| 
 | ||||
|   &[type=range] { | ||||
|     background: none; | ||||
|     border: none; | ||||
|  | @ -547,9 +515,21 @@ main-router { | |||
|   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 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); | ||||
|   flex: none; | ||||
|   padding: 0.6em 0.6em; | ||||
|   text-align: left; | ||||
|   line-height: 28px; | ||||
|   align-items: baseline; | ||||
|   border-width: 1px 0 0 0; | ||||
|   border-style: solid; | ||||
|   border-color: var(--border, $fallback--border); | ||||
| 
 | ||||
|   .faint { | ||||
|     color: $fallback--faint; | ||||
|  | @ -586,6 +566,7 @@ nav { | |||
|   color: var(--faint, $fallback--faint); | ||||
|   box-shadow: 0px 0px 4px rgba(0,0,0,.6); | ||||
|   box-shadow: var(--topBarShadow); | ||||
|   box-sizing: border-box; | ||||
| } | ||||
| 
 | ||||
| .fade-enter-active, .fade-leave-active { | ||||
|  | @ -705,6 +686,15 @@ nav { | |||
|       color: var(--alertWarningPanelText, $fallback--text); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &.success { | ||||
|     background-color: var(--alertSuccess, $fallback--alertWarning); | ||||
|     color: var(--alertSuccessText, $fallback--text); | ||||
| 
 | ||||
|     .panel-heading & { | ||||
|       color: var(--alertSuccessPanelText, $fallback--text); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .faint { | ||||
|  | @ -808,13 +798,6 @@ nav { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| .select-multiple { | ||||
|   display: flex; | ||||
|   .option-list { | ||||
|     margin: 0; | ||||
|     padding-left: .5em; | ||||
|   } | ||||
| } | ||||
| .setting-list, | ||||
| .option-list{ | ||||
|   list-style-type: none; | ||||
|  | @ -861,16 +844,10 @@ nav { | |||
| } | ||||
| 
 | ||||
| .new-status-notification { | ||||
|   position:relative; | ||||
|   margin-top: -1px; | ||||
|   position: relative; | ||||
|   font-size: 1.1em; | ||||
|   border-width: 1px 0 0 0; | ||||
|   border-style: solid; | ||||
|   border-color: var(--border, $fallback--border); | ||||
|   padding: 10px; | ||||
|   z-index: 1; | ||||
|   background-color: $fallback--fg; | ||||
|   background-color: var(--panel, $fallback--fg); | ||||
|   flex: 1; | ||||
| } | ||||
| 
 | ||||
| .chat-layout { | ||||
|  | @ -878,6 +855,11 @@ nav { | |||
|   overflow: hidden; | ||||
|   height: 100%; | ||||
| 
 | ||||
|   // Get rid of scrollbar on body as scrolling happens on different element | ||||
|   body { | ||||
|     overflow: hidden; | ||||
|   } | ||||
| 
 | ||||
|   // Ensures the fixed position of the mobile browser bars on scroll up / down events. | ||||
|   // Prevents the mobile browser bars from overlapping or hiding the message posting form. | ||||
|   @media all and (max-width: 800px) { | ||||
|  |  | |||
|  | @ -49,10 +49,11 @@ | |||
|       </div> | ||||
|       <media-modal /> | ||||
|     </div> | ||||
|     <chat-panel | ||||
|       v-if="currentUser && chat" | ||||
|     <shout-panel | ||||
|       v-if="currentUser && shout && !hideShoutbox" | ||||
|       :floating="true" | ||||
|       class="floating-chat mobile-hidden" | ||||
|       class="floating-shout mobile-hidden" | ||||
|       :class="{ 'left': shoutboxPosition }" | ||||
|     /> | ||||
|     <MobilePostStatusButton /> | ||||
|     <UserReportingModal /> | ||||
|  |  | |||
|  | @ -51,6 +51,7 @@ const getInstanceConfig = async ({ store }) => { | |||
|       const vapidPublicKey = data.pleroma.vapid_public_key | ||||
| 
 | ||||
|       store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit }) | ||||
|       store.dispatch('setInstanceOption', { name: 'accountApprovalRequired', value: data.approval_required }) | ||||
| 
 | ||||
|       if (vapidPublicKey) { | ||||
|         store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey }) | ||||
|  | @ -239,7 +240,7 @@ const getNodeInfo = async ({ store }) => { | |||
|       store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations }) | ||||
|       store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') }) | ||||
|       store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') }) | ||||
|       store.dispatch('setInstanceOption', { name: '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: 'gopherAvailable', value: features.includes('gopher') }) | ||||
|       store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) | ||||
|  |  | |||
|  | @ -16,7 +16,7 @@ import FollowRequests from 'components/follow_requests/follow_requests.vue' | |||
| import OAuthCallback from 'components/oauth_callback/oauth_callback.vue' | ||||
| import Notifications from 'components/notifications/notifications.vue' | ||||
| import AuthForm from 'components/auth_form/auth_form.js' | ||||
| import 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 About from 'components/about/about.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: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute }, | ||||
|     { 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: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) }, | ||||
|     { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute }, | ||||
|  |  | |||
|  | @ -6,10 +6,7 @@ | |||
|       :bound-to="{ x: 'container' }" | ||||
|       remove-padding | ||||
|     > | ||||
|       <div | ||||
|         slot="content" | ||||
|         class="account-tools-popover" | ||||
|       > | ||||
|       <template v-slot:content> | ||||
|         <div class="dropdown-menu"> | ||||
|           <template v-if="relationship.following"> | ||||
|             <button | ||||
|  | @ -59,16 +56,15 @@ | |||
|             {{ $t('user_card.message') }} | ||||
|           </button> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div | ||||
|         slot="trigger" | ||||
|         class="ellipsis-button" | ||||
|       > | ||||
|         <FAIcon | ||||
|           class="icon" | ||||
|           icon="ellipsis-v" | ||||
|         /> | ||||
|       </div> | ||||
|       </template> | ||||
|       <template v-slot:trigger> | ||||
|         <button class="button-unstyled ellipsis-button"> | ||||
|           <FAIcon | ||||
|             class="icon" | ||||
|             icon="ellipsis-v" | ||||
|           /> | ||||
|         </button> | ||||
|       </template> | ||||
|     </Popover> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -83,7 +79,6 @@ | |||
|   } | ||||
| 
 | ||||
|   .ellipsis-button { | ||||
|     cursor: pointer; | ||||
|     width: 2.5em; | ||||
|     margin: -0.5em 0; | ||||
|     padding: 0.5em 0; | ||||
|  |  | |||
|  | @ -1,4 +1,5 @@ | |||
| import StillImage from '../still-image/still-image.vue' | ||||
| import Flash from '../flash/flash.vue' | ||||
| import VideoAttachment from '../video_attachment/video_attachment.vue' | ||||
| import nsfwImage from '../../assets/nsfw.png' | ||||
| import fileTypeService from '../../services/file_type/file_type.service.js' | ||||
|  | @ -10,7 +11,12 @@ import { | |||
|   faImage, | ||||
|   faVideo, | ||||
|   faPlayCircle, | ||||
|   faTimes | ||||
|   faTimes, | ||||
|   faStop, | ||||
|   faSearchPlus, | ||||
|   faTrashAlt, | ||||
|   faPencilAlt, | ||||
|   faAlignRight | ||||
| } from '@fortawesome/free-solid-svg-icons' | ||||
| 
 | ||||
| library.add( | ||||
|  | @ -19,36 +25,64 @@ library.add( | |||
|   faImage, | ||||
|   faVideo, | ||||
|   faPlayCircle, | ||||
|   faTimes | ||||
|   faTimes, | ||||
|   faStop, | ||||
|   faSearchPlus, | ||||
|   faTrashAlt, | ||||
|   faPencilAlt, | ||||
|   faAlignRight | ||||
| ) | ||||
| 
 | ||||
| const Attachment = { | ||||
|   props: [ | ||||
|     'attachment', | ||||
|     'description', | ||||
|     'hideDescription', | ||||
|     'nsfw', | ||||
|     'size', | ||||
|     'allowPlay', | ||||
|     'setMedia', | ||||
|     'naturalSizeLoad' | ||||
|     'remove', | ||||
|     'shiftUp', | ||||
|     'shiftDn', | ||||
|     'edit' | ||||
|   ], | ||||
|   data () { | ||||
|     return { | ||||
|       localDescription: this.description || this.attachment.description, | ||||
|       nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage, | ||||
|       hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw, | ||||
|       preloadImage: this.$store.getters.mergedConfig.preloadImage, | ||||
|       loading: false, | ||||
|       img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'), | ||||
|       modalOpen: false, | ||||
|       showHidden: false | ||||
|       showHidden: false, | ||||
|       flashLoaded: false, | ||||
|       showDescription: false | ||||
|     } | ||||
|   }, | ||||
|   components: { | ||||
|     Flash, | ||||
|     StillImage, | ||||
|     VideoAttachment | ||||
|   }, | ||||
|   computed: { | ||||
|     classNames () { | ||||
|       return [ | ||||
|         { | ||||
|           '-loading': this.loading, | ||||
|           '-nsfw-placeholder': this.hidden, | ||||
|           '-editable': this.edit !== undefined | ||||
|         }, | ||||
|         '-type-' + this.type, | ||||
|         this.size && '-size-' + this.size, | ||||
|         `-${this.useContainFit ? 'contain' : 'cover'}-fit` | ||||
|       ] | ||||
|     }, | ||||
|     usePlaceholder () { | ||||
|       return this.size === 'hide' || this.type === 'unknown' | ||||
|       return this.size === 'hide' | ||||
|     }, | ||||
|     useContainFit () { | ||||
|       return this.$store.getters.mergedConfig.useContainFit | ||||
|     }, | ||||
|     placeholderName () { | ||||
|       if (this.attachment.description === '' || !this.attachment.description) { | ||||
|  | @ -72,24 +106,33 @@ const Attachment = { | |||
|       return this.nsfw && this.hideNsfwLocal && !this.showHidden | ||||
|     }, | ||||
|     isEmpty () { | ||||
|       return (this.type === 'html' && !this.attachment.oembed) || this.type === 'unknown' | ||||
|     }, | ||||
|     isSmall () { | ||||
|       return this.size === 'small' | ||||
|     }, | ||||
|     fullwidth () { | ||||
|       if (this.size === 'hide') return false | ||||
|       return this.type === 'html' || this.type === 'audio' || this.type === 'unknown' | ||||
|       return (this.type === 'html' && !this.attachment.oembed) | ||||
|     }, | ||||
|     useModal () { | ||||
|       const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio'] | ||||
|         : this.mergedConfig.playVideosInModal | ||||
|           ? ['image', 'video'] | ||||
|           : ['image'] | ||||
|       let modalTypes = [] | ||||
|       switch (this.size) { | ||||
|         case 'hide': | ||||
|         case 'small': | ||||
|           modalTypes = ['image', 'video', 'audio', 'flash'] | ||||
|           break | ||||
|         default: | ||||
|           modalTypes = this.mergedConfig.playVideosInModal | ||||
|             ? ['image', 'video', 'flash'] | ||||
|             : ['image'] | ||||
|           break | ||||
|       } | ||||
|       return modalTypes.includes(this.type) | ||||
|     }, | ||||
|     videoTag () { | ||||
|       return this.useModal ? 'button' : 'span' | ||||
|     }, | ||||
|     ...mapGetters(['mergedConfig']) | ||||
|   }, | ||||
|   watch: { | ||||
|     localDescription (newVal) { | ||||
|       this.onEdit(newVal) | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     linkClicked ({ target }) { | ||||
|       if (target.tagName === 'A') { | ||||
|  | @ -98,12 +141,37 @@ const Attachment = { | |||
|     }, | ||||
|     openModal (event) { | ||||
|       if (this.useModal) { | ||||
|         event.stopPropagation() | ||||
|         event.preventDefault() | ||||
|         this.setMedia() | ||||
|         this.$store.dispatch('setCurrent', this.attachment) | ||||
|         this.$emit('setMedia') | ||||
|         this.$store.dispatch('setCurrentMedia', this.attachment) | ||||
|       } else if (this.type === 'unknown') { | ||||
|         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) { | ||||
|       if ( | ||||
|         (this.mergedConfig.useOneClickNsfw && !this.showHidden) && | ||||
|  | @ -130,7 +198,7 @@ const Attachment = { | |||
|     onImageLoad (image) { | ||||
|       const width = image.naturalWidth | ||||
|       const height = image.naturalHeight | ||||
|       this.naturalSizeLoad && this.naturalSizeLoad({ width, height }) | ||||
|       this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height }) | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  |  | |||
							
								
								
									
										268
									
								
								src/components/attachment/attachment.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										268
									
								
								src/components/attachment/attachment.scss
									
									
									
									
									
										Normal 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; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -1,7 +1,8 @@ | |||
| <template> | ||||
|   <div | ||||
|   <button | ||||
|     v-if="usePlaceholder" | ||||
|     :class="{ 'fullwidth': fullwidth }" | ||||
|     class="Attachment -placeholder button-unstyled" | ||||
|     :class="classNames" | ||||
|     @click="openModal" | ||||
|   > | ||||
|     <a | ||||
|  | @ -13,310 +14,251 @@ | |||
|       :title="attachment.description" | ||||
|     > | ||||
|       <FAIcon :icon="placeholderIconClass" /> | ||||
|       <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }} | ||||
|       <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ edit ? '' : placeholderName }} | ||||
|     </a> | ||||
|   </div> | ||||
|     <div | ||||
|       v-if="edit || remove" | ||||
|       class="attachment-buttons" | ||||
|     > | ||||
|       <button | ||||
|         v-if="remove" | ||||
|         class="button-unstyled attachment-button" | ||||
|         @click.prevent="onRemove" | ||||
|       > | ||||
|         <FAIcon icon="trash-alt" /> | ||||
|       </button> | ||||
|     </div> | ||||
|     <div | ||||
|       v-if="size !== 'hide' && !hideDescription && (edit || localDescription || showDescription)" | ||||
|       class="description-container" | ||||
|       :class="{ '-static': !edit }" | ||||
|     > | ||||
|       <input | ||||
|         v-if="edit" | ||||
|         v-model="localDescription" | ||||
|         type="text" | ||||
|         class="description-field" | ||||
|         :placeholder="$t('post_status.media_description')" | ||||
|         @keydown.enter.prevent="" | ||||
|       > | ||||
|       <p v-else> | ||||
|         {{ localDescription }} | ||||
|       </p> | ||||
|     </div> | ||||
|   </button> | ||||
|   <div | ||||
|     v-else | ||||
|     v-show="!isEmpty" | ||||
|     class="attachment" | ||||
|     :class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}" | ||||
|     class="Attachment" | ||||
|     :class="classNames" | ||||
|   > | ||||
|     <a | ||||
|       v-if="hidden" | ||||
|       class="image-attachment" | ||||
|       :href="attachment.url" | ||||
|       :alt="attachment.description" | ||||
|       :title="attachment.description" | ||||
|       @click.prevent.stop="toggleHidden" | ||||
|     > | ||||
|       <img | ||||
|         :key="nsfwImage" | ||||
|         class="nsfw" | ||||
|         :src="nsfwImage" | ||||
|         :class="{'small': isSmall}" | ||||
|       > | ||||
|       <FAIcon | ||||
|         v-if="type === 'video'" | ||||
|         class="play-icon" | ||||
|         icon="play-circle" | ||||
|       /> | ||||
|     </a> | ||||
|     <button | ||||
|       v-if="nsfw && hideNsfwLocal && !hidden" | ||||
|       class="button-unstyled hider" | ||||
|       @click.prevent="toggleHidden" | ||||
|     > | ||||
|       <FAIcon icon="times" /> | ||||
|     </button> | ||||
| 
 | ||||
|     <a | ||||
|       v-if="type === 'image' && (!hidden || preloadImage)" | ||||
|       class="image-attachment" | ||||
|       :class="{'hidden': hidden && preloadImage }" | ||||
|       :href="attachment.url" | ||||
|       target="_blank" | ||||
|       @click="openModal" | ||||
|     > | ||||
|       <StillImage | ||||
|         class="image" | ||||
|         :referrerpolicy="referrerpolicy" | ||||
|         :mimetype="attachment.mimetype" | ||||
|         :src="attachment.large_thumb_url || attachment.url" | ||||
|         :image-load-handler="onImageLoad" | ||||
|         :alt="attachment.description" | ||||
|       /> | ||||
|     </a> | ||||
| 
 | ||||
|     <a | ||||
|       v-if="type === 'video' && !hidden" | ||||
|       class="video-container" | ||||
|       :class="{'small': isSmall}" | ||||
|       :href="allowPlay ? undefined : attachment.url" | ||||
|       @click="openModal" | ||||
|     > | ||||
|       <VideoAttachment | ||||
|         class="video" | ||||
|         :attachment="attachment" | ||||
|         :controls="allowPlay" | ||||
|         @play="$emit('play')" | ||||
|         @pause="$emit('pause')" | ||||
|       /> | ||||
|       <FAIcon | ||||
|         v-if="!allowPlay" | ||||
|         class="play-icon" | ||||
|         icon="play-circle" | ||||
|       /> | ||||
|     </a> | ||||
| 
 | ||||
|     <audio | ||||
|       v-if="type === 'audio'" | ||||
|       :src="attachment.url" | ||||
|       :alt="attachment.description" | ||||
|       :title="attachment.description" | ||||
|       controls | ||||
|       @play="$emit('play')" | ||||
|       @pause="$emit('pause')" | ||||
|     /> | ||||
| 
 | ||||
|     <div | ||||
|       v-if="type === 'html' && attachment.oembed" | ||||
|       class="oembed" | ||||
|       @click.prevent="linkClicked" | ||||
|       v-show="!isEmpty" | ||||
|       class="attachment-wrapper" | ||||
|     > | ||||
|       <div | ||||
|         v-if="attachment.thumb_url" | ||||
|         class="image" | ||||
|       <a | ||||
|         v-if="hidden" | ||||
|         class="image-container" | ||||
|         :href="attachment.url" | ||||
|         :alt="attachment.description" | ||||
|         :title="attachment.description" | ||||
|         @click.prevent.stop="toggleHidden" | ||||
|       > | ||||
|         <img :src="attachment.thumb_url"> | ||||
|         <img | ||||
|           :key="nsfwImage" | ||||
|           class="nsfw" | ||||
|           :src="nsfwImage" | ||||
|         > | ||||
|         <FAIcon | ||||
|           v-if="type === 'video'" | ||||
|           class="play-icon" | ||||
|           icon="play-circle" | ||||
|         /> | ||||
|       </a> | ||||
|       <div | ||||
|         v-if="!hidden" | ||||
|         class="attachment-buttons" | ||||
|       > | ||||
|         <button | ||||
|           v-if="type === 'flash' && flashLoaded" | ||||
|           class="button-unstyled attachment-button" | ||||
|           @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" | ||||
|           :title="$t('status.hide_attachment')" | ||||
|         > | ||||
|           <FAIcon icon="times" /> | ||||
|         </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> | ||||
|       <div class="text"> | ||||
|         <!-- eslint-disable vue/no-v-html --> | ||||
|         <h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1> | ||||
|         <div v-html="attachment.oembed.oembedHTML" /> | ||||
|         <!-- eslint-enable vue/no-v-html --> | ||||
| 
 | ||||
|       <a | ||||
|         v-if="type === 'image' && (!hidden || preloadImage)" | ||||
|         class="image-container" | ||||
|         :class="{'-hidden': hidden && preloadImage }" | ||||
|         :href="attachment.url" | ||||
|         target="_blank" | ||||
|         @click.stop.prevent="openModal" | ||||
|       > | ||||
|         <StillImage | ||||
|           class="image" | ||||
|           :referrerpolicy="referrerpolicy" | ||||
|           :mimetype="attachment.mimetype" | ||||
|           :src="attachment.large_thumb_url || attachment.url" | ||||
|           :image-load-handler="onImageLoad" | ||||
|           :alt="attachment.description" | ||||
|         /> | ||||
|       </a> | ||||
| 
 | ||||
|       <a | ||||
|         v-if="type === 'unknown' && !hidden" | ||||
|         class="placeholder-container" | ||||
|         :href="attachment.url" | ||||
|         target="_blank" | ||||
|       > | ||||
|         <FAIcon size="5x" :icon="placeholderIconClass" /> | ||||
|         <p> | ||||
|           {{ localDescription }} | ||||
|         </p> | ||||
|       </a> | ||||
| 
 | ||||
|       <component | ||||
|         :is="videoTag" | ||||
|         v-if="type === 'video' && !hidden" | ||||
|         class="video-container" | ||||
|         :class="{ 'button-unstyled': 'isModal' }" | ||||
|         :href="attachment.url" | ||||
|         @click.stop.prevent="openModal" | ||||
|       > | ||||
|         <VideoAttachment | ||||
|           class="video" | ||||
|           :attachment="attachment" | ||||
|           :controls="!useModal" | ||||
|           @play="$emit('play')" | ||||
|           @pause="$emit('pause')" | ||||
|         /> | ||||
|         <FAIcon | ||||
|           v-if="useModal" | ||||
|           class="play-icon" | ||||
|           icon="play-circle" | ||||
|         /> | ||||
|       </component> | ||||
| 
 | ||||
|       <span | ||||
|         v-if="type === 'audio' && !hidden" | ||||
|         class="audio-container" | ||||
|         :href="attachment.url" | ||||
|         @click.stop.prevent="openModal" | ||||
|       > | ||||
|         <audio | ||||
|           v-if="type === 'audio'" | ||||
|           :src="attachment.url" | ||||
|           :alt="attachment.description" | ||||
|           :title="attachment.description" | ||||
|           controls | ||||
|           @play="$emit('play')" | ||||
|           @pause="$emit('pause')" | ||||
|         /> | ||||
|       </span> | ||||
| 
 | ||||
|       <div | ||||
|         v-if="type === 'html' && attachment.oembed" | ||||
|         class="oembed-container" | ||||
|         @click.prevent="linkClicked" | ||||
|       > | ||||
|         <div | ||||
|           v-if="attachment.thumb_url" | ||||
|           class="image" | ||||
|         > | ||||
|           <img :src="attachment.thumb_url"> | ||||
|         </div> | ||||
|         <div class="text"> | ||||
|           <!-- eslint-disable vue/no-v-html --> | ||||
|           <h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1> | ||||
|           <div v-html="attachment.oembed.oembedHTML" /> | ||||
|           <!-- eslint-enable vue/no-v-html --> | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
|       <span | ||||
|         v-if="type === 'flash' && !hidden" | ||||
|         class="flash-container" | ||||
|         :href="attachment.url" | ||||
|         @click.stop.prevent="openModal" | ||||
|       > | ||||
|         <Flash | ||||
|           ref="flash" | ||||
|           class="flash" | ||||
|           :src="attachment.large_thumb_url || attachment.url" | ||||
|           @playerOpened="setFlashLoaded(true)" | ||||
|           @playerClosed="setFlashLoaded(false)" | ||||
|         /> | ||||
|       </span> | ||||
|     </div> | ||||
|     <div | ||||
|       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> | ||||
| </template> | ||||
| 
 | ||||
| <script src="./attachment.js"></script> | ||||
| 
 | ||||
| <style lang="scss"> | ||||
| @import '../../_variables.scss'; | ||||
| 
 | ||||
| .attachments { | ||||
|   display: flex; | ||||
|   flex-wrap: wrap; | ||||
| 
 | ||||
|   .non-gallery { | ||||
|     max-width: 100%; | ||||
|   } | ||||
| 
 | ||||
|   .placeholder { | ||||
|     display: inline-block; | ||||
|     padding: 0.3em 1em 0.3em 0; | ||||
|     color: $fallback--link; | ||||
|     color: var(--postLink, $fallback--link); | ||||
|     overflow: hidden; | ||||
|     white-space: nowrap; | ||||
|     text-overflow: ellipsis; | ||||
|     max-width: 100%; | ||||
| 
 | ||||
|     svg { | ||||
|       color: inherit; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .nsfw-placeholder { | ||||
|     cursor: pointer; | ||||
| 
 | ||||
|     &.loading { | ||||
|       cursor: progress; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .attachment { | ||||
|     position: relative; | ||||
|     margin-top: 0.5em; | ||||
|     align-self: flex-start; | ||||
|     line-height: 0; | ||||
| 
 | ||||
|     border-style: solid; | ||||
|     border-width: 1px; | ||||
|     border-radius: $fallback--attachmentRadius; | ||||
|     border-radius: var(--attachmentRadius, $fallback--attachmentRadius); | ||||
|     border-color: $fallback--border; | ||||
|     border-color: var(--border, $fallback--border); | ||||
|     overflow: hidden; | ||||
|   } | ||||
| 
 | ||||
|   .non-gallery.attachment { | ||||
|     &.video { | ||||
|       flex: 1 0 40%; | ||||
|     } | ||||
|     .nsfw { | ||||
|       height: 260px; | ||||
|     } | ||||
|     .small { | ||||
|       height: 120px; | ||||
|       flex-grow: 0; | ||||
|     } | ||||
|     .video { | ||||
|       height: 260px; | ||||
|       display: flex; | ||||
|     } | ||||
|     video { | ||||
|       max-height: 100%; | ||||
|       object-fit: contain; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .fullwidth { | ||||
|     flex-basis: 100%; | ||||
|   } | ||||
|   // fixes small gap below video | ||||
|   &.video { | ||||
|     line-height: 0; | ||||
|   } | ||||
| 
 | ||||
|   .video-container { | ||||
|     display: flex; | ||||
|     max-height: 100%; | ||||
|   } | ||||
| 
 | ||||
|   .video { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|   } | ||||
| 
 | ||||
|   .play-icon { | ||||
|     position: absolute; | ||||
|     font-size: 64px; | ||||
|     top: calc(50% - 32px); | ||||
|     left: calc(50% - 32px); | ||||
|     color: rgba(255, 255, 255, 0.75); | ||||
|     text-shadow: 0 0 2px rgba(0, 0, 0, 0.4); | ||||
|   } | ||||
| 
 | ||||
|   .play-icon::before { | ||||
|     margin: 0; | ||||
|   } | ||||
| 
 | ||||
|   &.html { | ||||
|     flex-basis: 90%; | ||||
|     width: 100%; | ||||
|     display: flex; | ||||
|   } | ||||
| 
 | ||||
|   .hider { | ||||
|     position: absolute; | ||||
|     right: 0; | ||||
|     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> | ||||
| <style src="./attachment.scss" lang="scss"></style> | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| import UserCard from '../user_card/user_card.vue' | ||||
| import UserAvatar from '../user_avatar/user_avatar.vue' | ||||
| import RichContent from 'src/components/rich_content/rich_content.jsx' | ||||
| import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' | ||||
| 
 | ||||
| const BasicUserCard = { | ||||
|  | @ -13,7 +14,8 @@ const BasicUserCard = { | |||
|   }, | ||||
|   components: { | ||||
|     UserCard, | ||||
|     UserAvatar | ||||
|     UserAvatar, | ||||
|     RichContent | ||||
|   }, | ||||
|   methods: { | ||||
|     toggleUserExpanded () { | ||||
|  |  | |||
|  | @ -25,24 +25,18 @@ | |||
|         :title="user.name" | ||||
|         class="basic-user-card-user-name" | ||||
|       > | ||||
|         <!-- eslint-disable vue/no-v-html --> | ||||
|         <span | ||||
|           v-if="user.name_html" | ||||
|         <RichContent | ||||
|           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> | ||||
|         <router-link | ||||
|           class="basic-user-card-screen-name" | ||||
|           :to="userProfileLink(user)" | ||||
|         > | ||||
|           @{{ user.screen_name }} | ||||
|           @{{ user.screen_name_ui }} | ||||
|         </router-link> | ||||
|       </div> | ||||
|       <slot /> | ||||
|  |  | |||
|  | @ -73,7 +73,7 @@ const Chat = { | |||
|     }, | ||||
|     formPlaceholder () { | ||||
|       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 { | ||||
|         return '' | ||||
|       } | ||||
|  | @ -234,6 +234,13 @@ const Chat = { | |||
|       const scrollable = this.$refs.scrollable | ||||
|       return scrollable && scrollable.scrollTop <= 0 | ||||
|     }, | ||||
|     cullOlderCheck () { | ||||
|       window.setTimeout(() => { | ||||
|         if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { | ||||
|           this.$store.dispatch('cullOlderMessages', this.currentChatMessageService.chatId) | ||||
|         } | ||||
|       }, 5000) | ||||
|     }, | ||||
|     handleScroll: _.throttle(function () { | ||||
|       if (!this.currentChat) { return } | ||||
| 
 | ||||
|  | @ -241,6 +248,7 @@ const Chat = { | |||
|         this.fetchChat({ maxId: this.currentChatMessageService.minId }) | ||||
|       } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { | ||||
|         this.jumpToBottomButtonVisible = false | ||||
|         this.cullOlderCheck() | ||||
|         if (this.newMessageCount > 0) { | ||||
|           // Use a delay before marking as read to prevent situation where new messages
 | ||||
|           // arrive just as you're leaving the view and messages that you didn't actually
 | ||||
|  |  | |||
|  | @ -98,10 +98,10 @@ | |||
|     .unread-message-count { | ||||
|       font-size: 0.8em; | ||||
|       left: 50%; | ||||
|       transform: translate(-50%, 0); | ||||
|       border-radius: 100%; | ||||
|       margin-top: -1rem; | ||||
|       padding: 0; | ||||
|       padding: 0.1em; | ||||
|       border-radius: 50px; | ||||
|       position: absolute; | ||||
|     } | ||||
| 
 | ||||
|     .chat-loading-error { | ||||
|  |  | |||
|  | @ -23,10 +23,7 @@ | |||
|         class="timeline" | ||||
|       > | ||||
|         <List :items="sortedChatList"> | ||||
|           <template | ||||
|             slot="item" | ||||
|             slot-scope="{item}" | ||||
|           > | ||||
|           <template v-slot:item="{item}"> | ||||
|             <ChatListItem | ||||
|               :key="item.id" | ||||
|               :compact="false" | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| 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 UserAvatar from '../user_avatar/user_avatar.vue' | ||||
| import AvatarList from '../avatar_list/avatar_list.vue' | ||||
|  | @ -16,7 +16,7 @@ const ChatListItem = { | |||
|     AvatarList, | ||||
|     Timeago, | ||||
|     ChatTitle, | ||||
|     StatusContent | ||||
|     StatusBody | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapState({ | ||||
|  | @ -38,12 +38,14 @@ const ChatListItem = { | |||
|     }, | ||||
|     messageForStatusContent () { | ||||
|       const message = this.chat.lastMessage | ||||
|       const messageEmojis = message ? message.emojis : [] | ||||
|       const isYou = message && message.account_id === this.currentUser.id | ||||
|       const content = message ? (this.attachmentInfo || message.content) : '' | ||||
|       const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content | ||||
|       return { | ||||
|         summary: '', | ||||
|         statusnet_html: messagePreview, | ||||
|         emojis: messageEmojis, | ||||
|         raw_html: messagePreview, | ||||
|         text: messagePreview, | ||||
|         attachments: [] | ||||
|       } | ||||
|  |  | |||
|  | @ -77,18 +77,15 @@ | |||
|     border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); | ||||
|   } | ||||
| 
 | ||||
|   .StatusContent { | ||||
|     img.emoji { | ||||
|       width: 1.4em; | ||||
|       height: 1.4em; | ||||
|     } | ||||
|   .chat-preview-body { | ||||
|     --emoji-size: 1.4em; | ||||
|   } | ||||
| 
 | ||||
|   .time-wrapper { | ||||
|     line-height: 1.4em; | ||||
|   } | ||||
| 
 | ||||
|   .single-line { | ||||
|   .chat-preview-body { | ||||
|     padding-right: 1em; | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -29,7 +29,8 @@ | |||
|         </div> | ||||
|       </div> | ||||
|       <div class="chat-preview"> | ||||
|         <StatusContent | ||||
|         <StatusBody | ||||
|           class="chat-preview-body" | ||||
|           :status="messageForStatusContent" | ||||
|           :single-line="true" | ||||
|         /> | ||||
|  |  | |||
|  | @ -57,8 +57,9 @@ const ChatMessage = { | |||
|     messageForStatusContent () { | ||||
|       return { | ||||
|         summary: '', | ||||
|         statusnet_html: this.message.content, | ||||
|         text: this.message.content, | ||||
|         emojis: this.message.emojis, | ||||
|         raw_html: this.message.content || '', | ||||
|         text: this.message.content || '', | ||||
|         attachments: this.message.attachments | ||||
|       } | ||||
|     }, | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| @import '../../_variables.scss'; | ||||
| 
 | ||||
| .chat-message-wrapper { | ||||
| 
 | ||||
|   &.hovered-message-chain { | ||||
|     .animated.Avatar { | ||||
|       canvas { | ||||
|  | @ -40,6 +41,12 @@ | |||
|   .chat-message { | ||||
|     display: flex; | ||||
|     padding-bottom: 0.5em; | ||||
| 
 | ||||
|     .status-body:hover { | ||||
|       --_still-image-img-visibility: visible; | ||||
|       --_still-image-canvas-visibility: hidden; | ||||
|       --_still-image-label-visibility: hidden; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .avatar-wrapper { | ||||
|  | @ -62,10 +69,6 @@ | |||
|     &.with-media { | ||||
|       width: 100%; | ||||
| 
 | ||||
|       .gallery-row { | ||||
|         overflow: hidden; | ||||
|       } | ||||
| 
 | ||||
|       .status { | ||||
|         width: 100%; | ||||
|       } | ||||
|  | @ -89,8 +92,9 @@ | |||
|   } | ||||
| 
 | ||||
|   .without-attachment { | ||||
|     .status-content { | ||||
|       &::after { | ||||
|     .message-content { | ||||
|       // TODO figure out how to do it properly | ||||
|       .RichContent::after { | ||||
|         margin-right: 5.4em; | ||||
|         content: " "; | ||||
|         display: inline-block; | ||||
|  | @ -162,6 +166,7 @@ | |||
|   .visible { | ||||
|     opacity: 1; | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| .chat-message-date-separator { | ||||
|  |  | |||
|  | @ -50,7 +50,7 @@ | |||
|                 @show="menuOpened = true" | ||||
|                 @close="menuOpened = false" | ||||
|               > | ||||
|                 <div slot="content"> | ||||
|                 <template v-slot:content> | ||||
|                   <div class="dropdown-menu"> | ||||
|                     <button | ||||
|                       class="button-default dropdown-item dropdown-item-icon" | ||||
|  | @ -59,26 +59,29 @@ | |||
|                       <FAIcon icon="times" /> {{ $t("chats.delete") }} | ||||
|                     </button> | ||||
|                   </div> | ||||
|                 </div> | ||||
|                 <button | ||||
|                   slot="trigger" | ||||
|                   class="button-default menu-icon" | ||||
|                   :title="$t('chats.more')" | ||||
|                 > | ||||
|                   <FAIcon icon="ellipsis-h" /> | ||||
|                 </button> | ||||
|                 </template> | ||||
|                 <template v-slot:trigger> | ||||
|                   <button | ||||
|                     class="button-default menu-icon" | ||||
|                     :title="$t('chats.more')" | ||||
|                   > | ||||
|                     <FAIcon icon="ellipsis-h" /> | ||||
|                   </button> | ||||
|                 </template> | ||||
|               </Popover> | ||||
|             </div> | ||||
|             <StatusContent | ||||
|               class="message-content" | ||||
|               :status="messageForStatusContent" | ||||
|               :full-content="true" | ||||
|             > | ||||
|               <span | ||||
|                 slot="footer" | ||||
|                 class="created-at" | ||||
|               > | ||||
|                 {{ createdAt }} | ||||
|               </span> | ||||
|               <template v-slot:footer> | ||||
|                 <span | ||||
|                   class="created-at" | ||||
|                 > | ||||
|                   {{ createdAt }} | ||||
|                 </span> | ||||
|               </template> | ||||
|             </StatusContent> | ||||
|           </div> | ||||
|         </div> | ||||
|  |  | |||
|  | @ -5,6 +5,8 @@ | |||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| import localeService from 'src/services/locale/locale.service.js' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'Timeago', | ||||
|   props: ['date'], | ||||
|  | @ -16,7 +18,7 @@ export default { | |||
|       if (this.date.getTime() === today.getTime()) { | ||||
|         return this.$t('display_date.today') | ||||
|       } else { | ||||
|         return this.date.toLocaleDateString('en', { day: 'numeric', month: 'long' }) | ||||
|         return this.date.toLocaleDateString(localeService.internalToBrowserLocale(this.$i18n.locale), { day: 'numeric', month: 'long' }) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  |  | |||
|  | @ -12,7 +12,7 @@ export default Vue.component('chat-title', { | |||
|   ], | ||||
|   computed: { | ||||
|     title () { | ||||
|       return this.user ? this.user.screen_name : '' | ||||
|       return this.user ? this.user.screen_name_ui : '' | ||||
|     }, | ||||
|     htmlTitle () { | ||||
|       return this.user ? this.user.name_html : '' | ||||
|  |  | |||
|  | @ -1,5 +1,4 @@ | |||
| <template> | ||||
|   <!-- eslint-disable vue/no-v-html --> | ||||
|   <div | ||||
|     class="chat-title" | ||||
|     :title="title" | ||||
|  | @ -14,12 +13,13 @@ | |||
|         height="23px" | ||||
|       /> | ||||
|     </router-link> | ||||
|     <span | ||||
|     <RichContent | ||||
|       class="username" | ||||
|       v-html="htmlTitle" | ||||
|       :title="'@'+user.screen_name_ui" | ||||
|       :html="htmlTitle" | ||||
|       :emoji="user.emoji" | ||||
|     /> | ||||
|   </div> | ||||
|   <!-- eslint-enable vue/no-v-html --> | ||||
| </template> | ||||
| 
 | ||||
| <script src="./chat_title.js"></script> | ||||
|  | @ -34,6 +34,8 @@ | |||
|   white-space: nowrap; | ||||
|   align-items: center; | ||||
| 
 | ||||
|   --emoji-size: 14px; | ||||
| 
 | ||||
|   .username { | ||||
|     max-width: 100%; | ||||
|     text-overflow: ellipsis; | ||||
|  | @ -41,14 +43,6 @@ | |||
|     display: inline; | ||||
|     word-wrap: break-word; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
| 
 | ||||
|     .emoji { | ||||
|       width: 14px; | ||||
|       height: 14px; | ||||
|       vertical-align: middle; | ||||
|       object-fit: contain | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .Avatar { | ||||
|  |  | |||
|  | @ -50,7 +50,6 @@ | |||
| 
 | ||||
| .Conversation { | ||||
|   .conversation-status { | ||||
|     border-left: none; | ||||
|     border-bottom-width: 1px; | ||||
|     border-bottom-style: solid; | ||||
|     border-bottom-color: var(--border, $fallback--border); | ||||
|  |  | |||
|  | @ -52,6 +52,7 @@ | |||
|           href="/pleroma/admin/#/login-pleroma" | ||||
|           class="nav-icon" | ||||
|           target="_blank" | ||||
|           @click.stop | ||||
|         > | ||||
|           <FAIcon | ||||
|             fixed-width | ||||
|  |  | |||
|  | @ -9,7 +9,7 @@ | |||
|       class="btn button-default" | ||||
|     > | ||||
|       {{ $t('domain_mute_card.unmute') }} | ||||
|       <template slot="progress"> | ||||
|       <template v-slot:progress> | ||||
|         {{ $t('domain_mute_card.unmute_progress') }} | ||||
|       </template> | ||||
|     </ProgressButton> | ||||
|  | @ -19,7 +19,7 @@ | |||
|       class="btn button-default" | ||||
|     > | ||||
|       {{ $t('domain_mute_card.mute') }} | ||||
|       <template slot="progress"> | ||||
|       <template v-slot:progress> | ||||
|         {{ $t('domain_mute_card.mute_progress') }} | ||||
|       </template> | ||||
|     </ProgressButton> | ||||
|  |  | |||
|  | @ -57,6 +57,7 @@ const EmojiInput = { | |||
|       required: true, | ||||
|       type: Function | ||||
|     }, | ||||
|     // TODO VUE3: change to modelValue, change 'input' event to 'input'
 | ||||
|     value: { | ||||
|       /** | ||||
|        * Used for v-model | ||||
|  | @ -143,32 +144,31 @@ const EmojiInput = { | |||
|     } | ||||
|   }, | ||||
|   mounted () { | ||||
|     const slots = this.$slots.default | ||||
|     if (!slots || slots.length === 0) return | ||||
|     const input = slots.find(slot => ['input', 'textarea'].includes(slot.tag)) | ||||
|     const { root } = this.$refs | ||||
|     const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea') | ||||
|     if (!input) return | ||||
|     this.input = input | ||||
|     this.resize() | ||||
|     input.elm.addEventListener('blur', this.onBlur) | ||||
|     input.elm.addEventListener('focus', this.onFocus) | ||||
|     input.elm.addEventListener('paste', this.onPaste) | ||||
|     input.elm.addEventListener('keyup', this.onKeyUp) | ||||
|     input.elm.addEventListener('keydown', this.onKeyDown) | ||||
|     input.elm.addEventListener('click', this.onClickInput) | ||||
|     input.elm.addEventListener('transitionend', this.onTransition) | ||||
|     input.elm.addEventListener('input', this.onInput) | ||||
|     input.addEventListener('blur', this.onBlur) | ||||
|     input.addEventListener('focus', this.onFocus) | ||||
|     input.addEventListener('paste', this.onPaste) | ||||
|     input.addEventListener('keyup', this.onKeyUp) | ||||
|     input.addEventListener('keydown', this.onKeyDown) | ||||
|     input.addEventListener('click', this.onClickInput) | ||||
|     input.addEventListener('transitionend', this.onTransition) | ||||
|     input.addEventListener('input', this.onInput) | ||||
|   }, | ||||
|   unmounted () { | ||||
|     const { input } = this | ||||
|     if (input) { | ||||
|       input.elm.removeEventListener('blur', this.onBlur) | ||||
|       input.elm.removeEventListener('focus', this.onFocus) | ||||
|       input.elm.removeEventListener('paste', this.onPaste) | ||||
|       input.elm.removeEventListener('keyup', this.onKeyUp) | ||||
|       input.elm.removeEventListener('keydown', this.onKeyDown) | ||||
|       input.elm.removeEventListener('click', this.onClickInput) | ||||
|       input.elm.removeEventListener('transitionend', this.onTransition) | ||||
|       input.elm.removeEventListener('input', this.onInput) | ||||
|       input.removeEventListener('blur', this.onBlur) | ||||
|       input.removeEventListener('focus', this.onFocus) | ||||
|       input.removeEventListener('paste', this.onPaste) | ||||
|       input.removeEventListener('keyup', this.onKeyUp) | ||||
|       input.removeEventListener('keydown', this.onKeyDown) | ||||
|       input.removeEventListener('click', this.onClickInput) | ||||
|       input.removeEventListener('transitionend', this.onTransition) | ||||
|       input.removeEventListener('input', this.onInput) | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|  | @ -194,11 +194,18 @@ const EmojiInput = { | |||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     focusPickerInput () { | ||||
|       const pickerEl = this.$refs.picker.$el | ||||
|       if (!pickerEl) return | ||||
|       const pickerInput = pickerEl.querySelector('input') | ||||
|       if (pickerInput) pickerInput.focus() | ||||
|     }, | ||||
|     triggerShowPicker () { | ||||
|       this.showPicker = true | ||||
|       this.$refs.picker.startEmojiLoad() | ||||
|       this.$nextTick(() => { | ||||
|         this.scrollIntoView() | ||||
|         this.focusPickerInput() | ||||
|       }) | ||||
|       // This temporarily disables "click outside" handler
 | ||||
|       // since external trigger also means click originates
 | ||||
|  | @ -209,11 +216,12 @@ const EmojiInput = { | |||
|       }, 0) | ||||
|     }, | ||||
|     togglePicker () { | ||||
|       this.input.elm.focus() | ||||
|       this.input.focus() | ||||
|       this.showPicker = !this.showPicker | ||||
|       if (this.showPicker) { | ||||
|         this.scrollIntoView() | ||||
|         this.$refs.picker.startEmojiLoad() | ||||
|         this.$nextTick(this.focusPickerInput) | ||||
|       } | ||||
|     }, | ||||
|     replace (replacement) { | ||||
|  | @ -254,13 +262,13 @@ const EmojiInput = { | |||
|       this.$emit('input', newValue) | ||||
|       const position = this.caret + (insertion + spaceAfter + spaceBefore).length | ||||
|       if (!keepOpen) { | ||||
|         this.input.elm.focus() | ||||
|         this.input.focus() | ||||
|       } | ||||
| 
 | ||||
|       this.$nextTick(function () { | ||||
|         // Re-focus inputbox after clicking suggestion
 | ||||
|         // Set selection right after the replacement instead of the very end
 | ||||
|         this.input.elm.setSelectionRange(position, position) | ||||
|         this.input.setSelectionRange(position, position) | ||||
|         this.caret = position | ||||
|       }) | ||||
|     }, | ||||
|  | @ -277,9 +285,9 @@ const EmojiInput = { | |||
| 
 | ||||
|         this.$nextTick(function () { | ||||
|           // Re-focus inputbox after clicking suggestion
 | ||||
|           this.input.elm.focus() | ||||
|           this.input.focus() | ||||
|           // 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 | ||||
|         }) | ||||
|         e.preventDefault() | ||||
|  | @ -341,7 +349,7 @@ const EmojiInput = { | |||
|       } | ||||
| 
 | ||||
|       this.$nextTick(() => { | ||||
|         const { offsetHeight } = this.input.elm | ||||
|         const { offsetHeight } = this.input | ||||
|         const { picker } = this.$refs | ||||
|         const pickerBottom = picker.$el.getBoundingClientRect().bottom | ||||
|         if (pickerBottom > window.innerHeight) { | ||||
|  | @ -406,8 +414,8 @@ const EmojiInput = { | |||
| 
 | ||||
|         // Scroll the input element to the position of the cursor
 | ||||
|         this.$nextTick(() => { | ||||
|           this.input.elm.blur() | ||||
|           this.input.elm.focus() | ||||
|           this.input.blur() | ||||
|           this.input.focus() | ||||
|         }) | ||||
|       } | ||||
|       // Disable suggestions hotkeys if suggestions are hidden
 | ||||
|  | @ -436,7 +444,7 @@ const EmojiInput = { | |||
|       // de-focuses the element (i.e. default browser behavior)
 | ||||
|       if (key === 'Escape') { | ||||
|         if (!this.temporarilyHideSuggestions) { | ||||
|           this.input.elm.focus() | ||||
|           this.input.focus() | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|  | @ -472,7 +480,7 @@ const EmojiInput = { | |||
|       if (!panel) return | ||||
|       const picker = this.$refs.picker.$el | ||||
|       const panelBody = this.$refs['panel-body'] | ||||
|       const { offsetHeight, offsetTop } = this.input.elm | ||||
|       const { offsetHeight, offsetTop } = this.input | ||||
|       const offsetBottom = offsetTop + offsetHeight | ||||
| 
 | ||||
|       this.setPlacement(panelBody, panel, offsetBottom) | ||||
|  | @ -486,7 +494,7 @@ const EmojiInput = { | |||
| 
 | ||||
|       if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) { | ||||
|         target.style.top = 'auto' | ||||
|         target.style.bottom = this.input.elm.offsetHeight + 'px' | ||||
|         target.style.bottom = this.input.offsetHeight + 'px' | ||||
|       } | ||||
|     }, | ||||
|     overflowsBottom (el) { | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| <template> | ||||
|   <div | ||||
|     ref="root" | ||||
|     v-click-outside="onClickOutside" | ||||
|     class="emoji-input" | ||||
|     :class="{ 'with-picker': !hideEmojiButton }" | ||||
|  | @ -9,6 +10,7 @@ | |||
|       <button | ||||
|         v-if="!hideEmojiButton" | ||||
|         class="button-unstyled emoji-picker-icon" | ||||
|         type="button" | ||||
|         @click.prevent="togglePicker" | ||||
|       > | ||||
|         <FAIcon :icon="['far', 'smile-beam']" /> | ||||
|  |  | |||
|  | @ -116,8 +116,8 @@ export const suggestUsers = ({ dispatch, state }) => { | |||
| 
 | ||||
|       return diff + nameAlphabetically + screenNameAlphabetically | ||||
|       /* eslint-disable camelcase */ | ||||
|     }).map(({ screen_name, name, profile_image_url_original }) => ({ | ||||
|       displayText: screen_name, | ||||
|     }).map(({ screen_name, screen_name_ui, name, profile_image_url_original }) => ({ | ||||
|       displayText: screen_name_ui, | ||||
|       detailText: name, | ||||
|       imageUrl: profile_image_url_original, | ||||
|       replacement: '@' + screen_name + ' ' | ||||
|  |  | |||
|  | @ -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> | ||||
|  | @ -7,10 +7,7 @@ | |||
|     :bound-to="{ x: 'container' }" | ||||
|     remove-padding | ||||
|   > | ||||
|     <div | ||||
|       slot="content" | ||||
|       slot-scope="{close}" | ||||
|     > | ||||
|     <template v-slot:content="{close}"> | ||||
|       <div class="dropdown-menu"> | ||||
|         <button | ||||
|           v-if="canMute && !status.thread_muted" | ||||
|  | @ -120,16 +117,15 @@ | |||
|           /><span>{{ $t("user_card.report") }}</span> | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|     <span | ||||
|       slot="trigger" | ||||
|       class="popover-trigger" | ||||
|     > | ||||
|       <FAIcon | ||||
|         class="fa-scale-110 fa-old-padding" | ||||
|         icon="ellipsis-h" | ||||
|       /> | ||||
|     </span> | ||||
|     </template> | ||||
|     <template v-slot:trigger> | ||||
|       <button class="button-unstyled popover-trigger"> | ||||
|         <FAIcon | ||||
|           class="fa-scale-110 fa-old-padding" | ||||
|           icon="ellipsis-h" | ||||
|         /> | ||||
|       </button> | ||||
|     </template> | ||||
|   </Popover> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -139,6 +135,11 @@ | |||
| @import '../../_variables.scss'; | ||||
| 
 | ||||
| .ExtraButtons { | ||||
|   /* override of popover internal stuff */ | ||||
|   .popover-trigger-button { | ||||
|     width: auto; | ||||
|   } | ||||
| 
 | ||||
|   .popover-trigger { | ||||
|     position: static; | ||||
|     padding: 10px; | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ import fileSizeFormatService from '../../services/file_size_format/file_size_for | |||
| 
 | ||||
| const FeaturesPanel = { | ||||
|   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 }, | ||||
|     gopher: function () { return this.$store.state.instance.gopherAvailable }, | ||||
|     whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled }, | ||||
|  |  | |||
|  | @ -8,8 +8,8 @@ | |||
|       </div> | ||||
|       <div class="panel-body features-panel"> | ||||
|         <ul> | ||||
|           <li v-if="chat"> | ||||
|             {{ $t('features_panel.chat') }} | ||||
|           <li v-if="shout"> | ||||
|             {{ $t('features_panel.shout') }} | ||||
|           </li> | ||||
|           <li v-if="pleromaChatMessages"> | ||||
|             {{ $t('features_panel.pleroma_chat_messages') }} | ||||
|  |  | |||
							
								
								
									
										53
									
								
								src/components/flash/flash.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								src/components/flash/flash.js
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										84
									
								
								src/components/flash/flash.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/components/flash/flash.vue
									
									
									
									
									
										Normal 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> | ||||
|  | @ -1,6 +1,6 @@ | |||
| import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' | ||||
| export default { | ||||
|   props: ['relationship', 'labelFollowing', 'buttonClass'], | ||||
|   props: ['relationship', 'user', 'labelFollowing', 'buttonClass'], | ||||
|   data () { | ||||
|     return { | ||||
|       inProgress: false | ||||
|  | @ -14,7 +14,7 @@ export default { | |||
|       if (this.inProgress || this.relationship.following) { | ||||
|         return this.$t('user_card.follow_unfollow') | ||||
|       } else if (this.relationship.requested) { | ||||
|         return this.$t('user_card.follow_again') | ||||
|         return this.$t('user_card.follow_cancel') | ||||
|       } else { | ||||
|         return this.$t('user_card.follow') | ||||
|       } | ||||
|  | @ -29,11 +29,14 @@ export default { | |||
|       } else { | ||||
|         return this.$t('user_card.follow') | ||||
|       } | ||||
|     }, | ||||
|     disabled () { | ||||
|       return this.inProgress || this.user.deactivated | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     onClick () { | ||||
|       this.relationship.following ? this.unfollow() : this.follow() | ||||
|       this.relationship.following || this.relationship.requested ? this.unfollow() : this.follow() | ||||
|     }, | ||||
|     follow () { | ||||
|       this.inProgress = true | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ | |||
|   <button | ||||
|     class="btn button-default follow-button" | ||||
|     :class="{ toggled: isPressed }" | ||||
|     :disabled="inProgress" | ||||
|     :disabled="disabled" | ||||
|     :title="title" | ||||
|     @click="onClick" | ||||
|   > | ||||
|  |  | |||
|  | @ -20,6 +20,7 @@ | |||
|           :relationship="relationship" | ||||
|           :label-following="$t('user_card.follow_unfollow')" | ||||
|           class="follow-card-follow-button" | ||||
|           :user="user" | ||||
|         /> | ||||
|       </template> | ||||
|     </div> | ||||
|  |  | |||
|  | @ -1,14 +1,10 @@ | |||
| import { set } from 'vue' | ||||
| import { library } from '@fortawesome/fontawesome-svg-core' | ||||
| import { | ||||
|   faChevronDown | ||||
| } from '@fortawesome/free-solid-svg-icons' | ||||
| 
 | ||||
| library.add( | ||||
|   faChevronDown | ||||
| ) | ||||
| import Select from '../select/select.vue' | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     Select | ||||
|   }, | ||||
|   props: [ | ||||
|     'name', 'label', 'value', 'fallback', 'options', 'no-inherit' | ||||
|   ], | ||||
|  |  | |||
|  | @ -22,30 +22,20 @@ | |||
|       class="opt-l" | ||||
|       :for="name + '-o'" | ||||
|     /> | ||||
|     <label | ||||
|       :for="name + '-font-switcher'" | ||||
|       class="select" | ||||
|     <Select | ||||
|       :id="name + '-font-switcher'" | ||||
|       v-model="preset" | ||||
|       :disabled="!present" | ||||
|       class="font-switcher" | ||||
|     > | ||||
|       <select | ||||
|         :id="name + '-font-switcher'" | ||||
|         v-model="preset" | ||||
|         :disabled="!present" | ||||
|         class="font-switcher" | ||||
|       <option | ||||
|         v-for="option in availableOptions" | ||||
|         :key="option" | ||||
|         :value="option" | ||||
|       > | ||||
|         <option | ||||
|           v-for="option in availableOptions" | ||||
|           :key="option" | ||||
|           :value="option" | ||||
|         > | ||||
|           {{ option === 'custom' ? $t('settings.style.fonts.custom') : option }} | ||||
|         </option> | ||||
|       </select> | ||||
|       <FAIcon | ||||
|         class="select-down-icon" | ||||
|         icon="chevron-down" | ||||
|       /> | ||||
|     </label> | ||||
|         {{ option === 'custom' ? $t('settings.style.fonts.custom') : option }} | ||||
|       </option> | ||||
|     </Select> | ||||
|     <input | ||||
|       v-if="isCustom" | ||||
|       :id="name" | ||||
|  | @ -65,7 +55,8 @@ | |||
|     min-width: 10em; | ||||
|   } | ||||
|   &.custom { | ||||
|     .select { | ||||
|     /* TODO Should make proper joiners... */ | ||||
|     .font-switcher { | ||||
|       border-top-right-radius: 0; | ||||
|       border-bottom-right-radius: 0; | ||||
|     } | ||||
|  |  | |||
|  | @ -1,15 +1,26 @@ | |||
| import Attachment from '../attachment/attachment.vue' | ||||
| import { chunk, last, dropRight, sumBy } from 'lodash' | ||||
| import { sumBy } from 'lodash' | ||||
| 
 | ||||
| const Gallery = { | ||||
|   props: [ | ||||
|     'attachments', | ||||
|     'limitRows', | ||||
|     'descriptions', | ||||
|     'limit', | ||||
|     'nsfw', | ||||
|     'setMedia' | ||||
|     'setMedia', | ||||
|     'size', | ||||
|     'editable', | ||||
|     'removeAttachment', | ||||
|     'shiftUpAttachment', | ||||
|     'shiftDnAttachment', | ||||
|     'editAttachment', | ||||
|     'grid' | ||||
|   ], | ||||
|   data () { | ||||
|     return { | ||||
|       sizes: {} | ||||
|       sizes: {}, | ||||
|       hidingLong: true | ||||
|     } | ||||
|   }, | ||||
|   components: { Attachment }, | ||||
|  | @ -18,26 +29,70 @@ const Gallery = { | |||
|       if (!this.attachments) { | ||||
|         return [] | ||||
|       } | ||||
|       const rows = chunk(this.attachments, 3) | ||||
|       if (last(rows).length === 1 && rows.length > 1) { | ||||
|         // if 1 attachment on last row -> add it to the previous row instead
 | ||||
|         const lastAttachment = last(rows)[0] | ||||
|         const allButLastRow = dropRight(rows) | ||||
|         last(allButLastRow).push(lastAttachment) | ||||
|         return allButLastRow | ||||
|       const attachments = this.limit > 0 | ||||
|         ? this.attachments.slice(0, this.limit) | ||||
|         : this.attachments | ||||
|       if (this.size === 'hide') { | ||||
|         return attachments.map(item => ({ minimal: true, items: [item] })) | ||||
|       } | ||||
|       const rows = 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 | ||||
|     }, | ||||
|     useContainFit () { | ||||
|       return this.$store.getters.mergedConfig.useContainFit | ||||
|     attachmentsDimensionalScore () { | ||||
|       return this.rows.reduce((acc, row) => { | ||||
|         let size = 0 | ||||
|         if (row.minimal) { | ||||
|           size += 1 / 8 | ||||
|         } else if (row.audio) { | ||||
|           size += 1 / 4 | ||||
|         } else { | ||||
|           size += 1 / (row.items.length + 0.6) | ||||
|         } | ||||
|         return acc + size | ||||
|       }, 0) | ||||
|     }, | ||||
|     tooManyAttachments () { | ||||
|       if (this.editable || this.size === 'small') { | ||||
|         return false | ||||
|       } else if (this.size === 'hide') { | ||||
|         return this.attachments.length > 8 | ||||
|       } else { | ||||
|         return this.attachmentsDimensionalScore > 1 | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     onNaturalSizeLoad (id, size) { | ||||
|       this.$set(this.sizes, id, size) | ||||
|     onNaturalSizeLoad ({ id, width, height }) { | ||||
|       this.$set(this.sizes, id, { width, height }) | ||||
|     }, | ||||
|     rowStyle (itemsPerRow) { | ||||
|       return { 'padding-bottom': `${(100 / (itemsPerRow + 0.6))}%` } | ||||
|     rowStyle (row) { | ||||
|       if (row.audio) { | ||||
|         return { 'padding-bottom': '25%' } // fixed reduced height for audio
 | ||||
|       } else if (!row.minimal && !row.grid) { | ||||
|         return { 'padding-bottom': `${(100 / (row.items.length + 0.6))}%` } | ||||
|       } | ||||
|     }, | ||||
|     itemStyle (id, row) { | ||||
|       const total = sumBy(row, item => this.getAspectRatio(item.id)) | ||||
|  | @ -46,6 +101,16 @@ const Gallery = { | |||
|     getAspectRatio (id) { | ||||
|       const size = this.sizes[id] | ||||
|       return size ? size.width / size.height : 1 | ||||
|     }, | ||||
|     toggleHidingLong (event) { | ||||
|       this.hidingLong = event | ||||
|     }, | ||||
|     openGallery () { | ||||
|       this.$store.dispatch('setMedia', this.attachments) | ||||
|       this.$store.dispatch('setCurrentMedia', this.attachments[0]) | ||||
|     }, | ||||
|     onMedia () { | ||||
|       this.$store.dispatch('setMedia', this.attachments) | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -1,26 +1,84 @@ | |||
| <template> | ||||
|   <div | ||||
|     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 | ||||
|           class="gallery-row-inner" | ||||
|           :class="{ '-grid': grid }" | ||||
|         > | ||||
|           <Attachment | ||||
|             v-for="(attachment, attachmentIndex) in row.items" | ||||
|             :key="attachment.id" | ||||
|             class="gallery-item" | ||||
|             :nsfw="nsfw" | ||||
|             :attachment="attachment" | ||||
|             :allow-play="false" | ||||
|             :size="size" | ||||
|             :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 | ||||
|       v-for="(row, index) in rows" | ||||
|       :key="index" | ||||
|       class="gallery-row" | ||||
|       :style="rowStyle(row.length)" | ||||
|       :class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }" | ||||
|       v-if="tooManyAttachments" | ||||
|       class="many-attachments" | ||||
|     > | ||||
|       <div class="gallery-row-inner"> | ||||
|         <attachment | ||||
|           v-for="attachment in row" | ||||
|           :key="attachment.id" | ||||
|           :set-media="setMedia" | ||||
|           :nsfw="nsfw" | ||||
|           :attachment="attachment" | ||||
|           :allow-play="false" | ||||
|           :natural-size-load="onNaturalSizeLoad.bind(null, attachment.id)" | ||||
|           :style="itemStyle(attachment.id, row)" | ||||
|         /> | ||||
|       <div 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> | ||||
|  | @ -31,12 +89,66 @@ | |||
| <style lang="scss"> | ||||
| @import '../../_variables.scss'; | ||||
| 
 | ||||
| .gallery-row { | ||||
|   position: relative; | ||||
|   height: 0; | ||||
|   width: 100%; | ||||
|   flex-grow: 1; | ||||
|   margin-top: 0.5em; | ||||
| .Gallery { | ||||
|   .gallery-rows { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|   } | ||||
| 
 | ||||
|   .gallery-row { | ||||
|     position: relative; | ||||
|     height: 0; | ||||
|     width: 100%; | ||||
|     flex-grow: 1; | ||||
| 
 | ||||
|     &:not(:first-child) { | ||||
|       margin-top: 0.5em; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &.-long { | ||||
|     .gallery-rows { | ||||
|       max-height: 25em; | ||||
|       overflow: hidden; | ||||
|       mask: | ||||
|         linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat, | ||||
|         linear-gradient(to top, white, white); | ||||
| 
 | ||||
|       /* Autoprefixed seem to ignore this one, and also syntax is different */ | ||||
|       -webkit-mask-composite: xor; | ||||
|       mask-composite: exclude; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .many-attachments-text { | ||||
|     text-align: center; | ||||
|     line-height: 2; | ||||
|   } | ||||
| 
 | ||||
|   .many-attachments-buttons { | ||||
|     display: flex; | ||||
|   } | ||||
| 
 | ||||
|   .many-attachments-button { | ||||
|     display: flex; | ||||
|     flex: 1; | ||||
|     justify-content: center; | ||||
|     line-height: 2; | ||||
| 
 | ||||
|     button { | ||||
|       padding: 0 2em; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .gallery-row { | ||||
|     &.-grid, | ||||
|     &.-minimal { | ||||
|       height: auto; | ||||
|       .gallery-row-inner { | ||||
|         position: relative; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .gallery-row-inner { | ||||
|     position: absolute; | ||||
|  | @ -48,9 +160,24 @@ | |||
|     flex-direction: row; | ||||
|     flex-wrap: nowrap; | ||||
|     align-content: stretch; | ||||
| 
 | ||||
|     &.-grid { | ||||
|       width: 100%; | ||||
|       height: auto; | ||||
|       position: relative; | ||||
|       display: grid; | ||||
|       grid-column-gap: 0.5em; | ||||
|       grid-row-gap: 0.5em; | ||||
|       grid-template-columns: repeat(auto-fill, minmax(15em, 1fr)); | ||||
| 
 | ||||
|       .gallery-item { | ||||
|         margin: 0; | ||||
|         height: 200px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .gallery-row-inner .attachment { | ||||
|   .gallery-item { | ||||
|     margin: 0 0.5em 0 0; | ||||
|     flex-grow: 1; | ||||
|     height: 100%; | ||||
|  | @ -61,32 +188,5 @@ | |||
|       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> | ||||
|  |  | |||
|  | @ -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 { | ||||
|     background-color: var(--alertPopupNeutral, $fallback--fg); | ||||
|     color: var(--alertPopupNeutralText, $fallback--text); | ||||
|  |  | |||
							
								
								
									
										36
									
								
								src/components/hashtag_link/hashtag_link.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								src/components/hashtag_link/hashtag_link.js
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										6
									
								
								src/components/hashtag_link/hashtag_link.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/components/hashtag_link/hashtag_link.scss
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | |||
| .HashtagLink { | ||||
|   position: relative; | ||||
|   white-space: normal; | ||||
|   display: inline-block; | ||||
|   color: var(--link); | ||||
| } | ||||
							
								
								
									
										19
									
								
								src/components/hashtag_link/hashtag_link.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/components/hashtag_link/hashtag_link.vue
									
									
									
									
									
										Normal 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"/> | ||||
|  | @ -3,27 +3,18 @@ | |||
|     <label for="interface-language-switcher"> | ||||
|       {{ $t('settings.interfaceLanguage') }} | ||||
|     </label> | ||||
|     <label | ||||
|       for="interface-language-switcher" | ||||
|       class="select" | ||||
|     <Select | ||||
|       id="interface-language-switcher" | ||||
|       v-model="language" | ||||
|     > | ||||
|       <select | ||||
|         id="interface-language-switcher" | ||||
|         v-model="language" | ||||
|       <option | ||||
|         v-for="lang in languages" | ||||
|         :key="lang.code" | ||||
|         :value="lang.code" | ||||
|       > | ||||
|         <option | ||||
|           v-for="lang in languages" | ||||
|           :key="lang.code" | ||||
|           :value="lang.code" | ||||
|         > | ||||
|           {{ lang.name }} | ||||
|         </option> | ||||
|       </select> | ||||
|       <FAIcon | ||||
|         class="select-down-icon" | ||||
|         icon="chevron-down" | ||||
|       /> | ||||
|     </label> | ||||
|         {{ lang.name }} | ||||
|       </option> | ||||
|     </Select> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -32,16 +23,12 @@ import languagesObject from '../../i18n/messages' | |||
| import localeService from '../../services/locale/locale.service.js' | ||||
| import ISO6391 from 'iso-639-1' | ||||
| import _ from 'lodash' | ||||
| import { library } from '@fortawesome/fontawesome-svg-core' | ||||
| import { | ||||
|   faChevronDown | ||||
| } from '@fortawesome/free-solid-svg-icons' | ||||
| 
 | ||||
| library.add( | ||||
|   faChevronDown | ||||
| ) | ||||
| import Select from '../select/select.vue' | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     Select | ||||
|   }, | ||||
|   computed: { | ||||
|     languages () { | ||||
|       return _.map(languagesObject.languages, (code) => ({ code: code, name: this.getLanguageName(code) })).sort((a, b) => a.name.localeCompare(b.name)) | ||||
|  |  | |||
|  | @ -3,22 +3,31 @@ import VideoAttachment from '../video_attachment/video_attachment.vue' | |||
| import Modal from '../modal/modal.vue' | ||||
| import fileTypeService from '../../services/file_type/file_type.service.js' | ||||
| import GestureService from '../../services/gesture_service/gesture_service' | ||||
| import Flash from 'src/components/flash/flash.vue' | ||||
| import { library } from '@fortawesome/fontawesome-svg-core' | ||||
| import { | ||||
|   faChevronLeft, | ||||
|   faChevronRight | ||||
|   faChevronRight, | ||||
|   faCircleNotch | ||||
| } from '@fortawesome/free-solid-svg-icons' | ||||
| 
 | ||||
| library.add( | ||||
|   faChevronLeft, | ||||
|   faChevronRight | ||||
|   faChevronRight, | ||||
|   faCircleNotch | ||||
| ) | ||||
| 
 | ||||
| const MediaModal = { | ||||
|   components: { | ||||
|     StillImage, | ||||
|     VideoAttachment, | ||||
|     Modal | ||||
|     Modal, | ||||
|     Flash | ||||
|   }, | ||||
|   data () { | ||||
|     return { | ||||
|       loading: false | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     showing () { | ||||
|  | @ -27,6 +36,9 @@ const MediaModal = { | |||
|     media () { | ||||
|       return this.$store.state.mediaViewer.media | ||||
|     }, | ||||
|     description () { | ||||
|       return this.currentMedia.description | ||||
|     }, | ||||
|     currentIndex () { | ||||
|       return this.$store.state.mediaViewer.currentIndex | ||||
|     }, | ||||
|  | @ -37,7 +49,7 @@ const MediaModal = { | |||
|       return this.media.length > 1 | ||||
|     }, | ||||
|     type () { | ||||
|       return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null | ||||
|       return this.currentMedia ? this.getType(this.currentMedia) : null | ||||
|     } | ||||
|   }, | ||||
|   created () { | ||||
|  | @ -53,6 +65,9 @@ const MediaModal = { | |||
|     ) | ||||
|   }, | ||||
|   methods: { | ||||
|     getType (media) { | ||||
|       return fileTypeService.fileType(media.mimetype) | ||||
|     }, | ||||
|     mediaTouchStart (e) { | ||||
|       GestureService.beginSwipe(e, this.mediaSwipeGestureRight) | ||||
|       GestureService.beginSwipe(e, this.mediaSwipeGestureLeft) | ||||
|  | @ -67,15 +82,26 @@ const MediaModal = { | |||
|     goPrev () { | ||||
|       if (this.canNavigate) { | ||||
|         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 () { | ||||
|       if (this.canNavigate) { | ||||
|         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) { | ||||
|       if (this.showing && e.keyCode === 27) { // escape
 | ||||
|         this.hide() | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ | |||
|   > | ||||
|     <img | ||||
|       v-if="type === 'image'" | ||||
|       :class="{ loading }" | ||||
|       class="modal-image" | ||||
|       :src="currentMedia.url" | ||||
|       :alt="currentMedia.description" | ||||
|  | @ -13,6 +14,7 @@ | |||
|       @touchstart.stop="mediaTouchStart" | ||||
|       @touchmove.stop="mediaTouchMove" | ||||
|       @click="hide" | ||||
|       @load="onImageLoaded" | ||||
|     > | ||||
|     <VideoAttachment | ||||
|       v-if="type === 'video'" | ||||
|  | @ -28,6 +30,13 @@ | |||
|       :title="currentMedia.description" | ||||
|       controls | ||||
|     /> | ||||
|     <Flash | ||||
|       v-if="type === 'flash'" | ||||
|       class="modal-image" | ||||
|       :src="currentMedia.url" | ||||
|       :alt="currentMedia.description" | ||||
|       :title="currentMedia.description" | ||||
|     /> | ||||
|     <button | ||||
|       v-if="canNavigate" | ||||
|       :title="$t('media_modal.previous')" | ||||
|  | @ -50,6 +59,27 @@ | |||
|         icon="chevron-right" | ||||
|       /> | ||||
|     </button> | ||||
|     <span | ||||
|       v-if="description" | ||||
|       class="description" | ||||
|     > | ||||
|       {{ description }} | ||||
|     </span> | ||||
|     <span | ||||
|       class="counter" | ||||
|     > | ||||
|       {{ $tc('media_modal.counter', currentIndex + 1, { current: currentIndex + 1, total: media.length }) }} | ||||
|     </span> | ||||
|     <span | ||||
|       v-if="loading" | ||||
|       class="loading-spinner" | ||||
|     > | ||||
|       <FAIcon | ||||
|         spin | ||||
|         icon="circle-notch" | ||||
|         size="5x" | ||||
|       /> | ||||
|     </span> | ||||
|   </Modal> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -58,6 +88,7 @@ | |||
| <style lang="scss"> | ||||
| .modal-view.media-modal-view { | ||||
|   z-index: 1001; | ||||
|   flex-direction: column; | ||||
| 
 | ||||
|   .modal-view-button-arrow { | ||||
|     opacity: 0.75; | ||||
|  | @ -67,59 +98,108 @@ | |||
|       outline: none; | ||||
|       box-shadow: none; | ||||
|     } | ||||
| 
 | ||||
|     &:hover { | ||||
|       opacity: 1; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .modal-image { | ||||
|   max-width: 90%; | ||||
|   max-height: 90%; | ||||
|   box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5); | ||||
|   image-orientation: from-image; // NOTE: only FF supports this | ||||
| } | ||||
| 
 | ||||
| .modal-view-button-arrow { | ||||
|   position: absolute; | ||||
|   display: block; | ||||
|   top: 50%; | ||||
|   margin-top: -50px; | ||||
|   width: 70px; | ||||
|   height: 100px; | ||||
|   border: 0; | ||||
|   padding: 0; | ||||
|   opacity: 0; | ||||
|   box-shadow: none; | ||||
|   background: none; | ||||
|   appearance: none; | ||||
|   overflow: visible; | ||||
|   cursor: pointer; | ||||
|   transition: opacity 333ms cubic-bezier(.4,0,.22,1); | ||||
| 
 | ||||
|   .arrow-icon { | ||||
|     position: absolute; | ||||
|     top: 35px; | ||||
|     height: 30px; | ||||
|     width: 32px; | ||||
|     font-size: 14px; | ||||
|     line-height: 30px; | ||||
|     color: #FFF; | ||||
|     text-align: center; | ||||
|     background-color: rgba(0,0,0,.3); | ||||
|   } | ||||
| 
 | ||||
|   &--prev { | ||||
|     left: 0; | ||||
|     .arrow-icon { | ||||
|       left: 6px; | ||||
| .media-modal-view { | ||||
|   @keyframes media-fadein { | ||||
|     from { | ||||
|       opacity: 0; | ||||
|     } | ||||
|     to { | ||||
|       opacity: 1; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &--next { | ||||
|     right: 0; | ||||
|   .description, | ||||
|   .counter { | ||||
|     /* Hardcoded since background is also hardcoded */ | ||||
|     color: white; | ||||
|     margin-top: 1em; | ||||
|     text-shadow: 0 0 10px black, 0 0 10px black; | ||||
|     padding: 0.2em 2em; | ||||
|   } | ||||
| 
 | ||||
|   .description { | ||||
|     flex: 0 0 auto; | ||||
|     overflow-y: auto; | ||||
|     min-height: 1em; | ||||
|     max-width: 500px; | ||||
|     max-height: 9.5em; | ||||
|     word-break: break-all; | ||||
|   } | ||||
| 
 | ||||
|   .modal-image { | ||||
|     max-width: 90%; | ||||
|     max-height: 90%; | ||||
|     box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5); | ||||
|     image-orientation: from-image; // NOTE: only FF supports this | ||||
|     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 { | ||||
|     position: absolute; | ||||
|     display: block; | ||||
|     top: 50%; | ||||
|     margin-top: -50px; | ||||
|     width: 70px; | ||||
|     height: 100px; | ||||
|     border: 0; | ||||
|     padding: 0; | ||||
|     opacity: 0; | ||||
|     box-shadow: none; | ||||
|     background: none; | ||||
|     appearance: none; | ||||
|     overflow: visible; | ||||
|     cursor: pointer; | ||||
|     transition: opacity 333ms cubic-bezier(.4,0,.22,1); | ||||
| 
 | ||||
|     .arrow-icon { | ||||
|       right: 6px; | ||||
|       position: absolute; | ||||
|       top: 35px; | ||||
|       height: 30px; | ||||
|       width: 32px; | ||||
|       font-size: 14px; | ||||
|       line-height: 30px; | ||||
|       color: #FFF; | ||||
|       text-align: center; | ||||
|       background-color: rgba(0,0,0,.3); | ||||
|     } | ||||
| 
 | ||||
|     &--prev { | ||||
|       left: 0; | ||||
|       .arrow-icon { | ||||
|         left: 6px; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     &--next { | ||||
|       right: 0; | ||||
|       .arrow-icon { | ||||
|         right: 6px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  |  | |||
							
								
								
									
										134
									
								
								src/components/mention_link/mention_link.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								src/components/mention_link/mention_link.js
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										116
									
								
								src/components/mention_link/mention_link.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								src/components/mention_link/mention_link.scss
									
									
									
									
									
										Normal 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); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										75
									
								
								src/components/mention_link/mention_link.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/components/mention_link/mention_link.vue
									
									
									
									
									
										Normal 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"/> | ||||
							
								
								
									
										37
									
								
								src/components/mentions_line/mentions_line.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/components/mentions_line/mentions_line.js
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										13
									
								
								src/components/mentions_line/mentions_line.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/components/mentions_line/mentions_line.scss
									
									
									
									
									
										Normal 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); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										43
									
								
								src/components/mentions_line/mentions_line.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/components/mentions_line/mentions_line.vue
									
									
									
									
									
										Normal 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" /> | ||||
|  | @ -25,6 +25,7 @@ | |||
|             <div> | ||||
|               <button | ||||
|                 class="button-unstyled -link" | ||||
|                 type="button" | ||||
|                 @click.prevent="requireTOTP" | ||||
|               > | ||||
|                 {{ $t('login.enter_two_factor_code') }} | ||||
|  | @ -32,6 +33,7 @@ | |||
|               <br> | ||||
|               <button | ||||
|                 class="button-unstyled -link" | ||||
|                 type="button" | ||||
|                 @click.prevent="abortMFA" | ||||
|               > | ||||
|                 {{ $t('general.cancel') }} | ||||
|  |  | |||
|  | @ -27,6 +27,7 @@ | |||
|             <div> | ||||
|               <button | ||||
|                 class="button-unstyled -link" | ||||
|                 type="button" | ||||
|                 @click.prevent="requireRecovery" | ||||
|               > | ||||
|                 {{ $t('login.enter_recovery_code') }} | ||||
|  | @ -34,6 +35,7 @@ | |||
|               <br> | ||||
|               <button | ||||
|                 class="button-unstyled -link" | ||||
|                 type="button" | ||||
|                 @click.prevent="abortMFA" | ||||
|               > | ||||
|                 {{ $t('general.cancel') }} | ||||
|  |  | |||
|  | @ -44,6 +44,9 @@ const MobilePostStatusButton = { | |||
| 
 | ||||
|       return this.autohideFloatingPostButton && (this.hidden || this.inputActive) | ||||
|     }, | ||||
|     isPersistent () { | ||||
|       return !!this.$store.getters.mergedConfig.showNewPostButton | ||||
|     }, | ||||
|     autohideFloatingPostButton () { | ||||
|       return !!this.$store.getters.mergedConfig.autohideFloatingPostButton | ||||
|     } | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ | |||
|   <div v-if="isLoggedIn"> | ||||
|     <button | ||||
|       class="button-default new-status-button" | ||||
|       :class="{ 'hidden': isHidden }" | ||||
|       :class="{ 'hidden': isHidden, 'always-show': isPersistent }" | ||||
|       @click="openPostForm" | ||||
|     > | ||||
|       <FAIcon icon="pen" /> | ||||
|  | @ -47,7 +47,7 @@ | |||
| } | ||||
| 
 | ||||
| @media all and (min-width: 801px) { | ||||
|   .new-status-button { | ||||
|   .new-status-button:not(.always-show) { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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 Popover from '../popover/popover.vue' | ||||
| 
 | ||||
| library.add(faChevronDown) | ||||
| 
 | ||||
| const FORCE_NSFW = 'mrf_tag:media-force-nsfw' | ||||
| const STRIP_MEDIA = 'mrf_tag:media-strip' | ||||
| const FORCE_UNLISTED = 'mrf_tag:force-unlisted' | ||||
|  |  | |||
|  | @ -8,7 +8,7 @@ | |||
|       @show="setToggled(true)" | ||||
|       @close="setToggled(false)" | ||||
|     > | ||||
|       <div slot="content"> | ||||
|       <template v-slot:content> | ||||
|         <div class="dropdown-menu"> | ||||
|           <span v-if="user.is_local"> | ||||
|             <button | ||||
|  | @ -50,96 +50,98 @@ | |||
|               class="button-default dropdown-item" | ||||
|               @click="toggleTag(tags.FORCE_NSFW)" | ||||
|             > | ||||
|               {{ $t('user_card.admin_menu.force_nsfw') }} | ||||
|               <span | ||||
|                 class="menu-checkbox" | ||||
|                 :class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }" | ||||
|               /> | ||||
|               {{ $t('user_card.admin_menu.force_nsfw') }} | ||||
|             </button> | ||||
|             <button | ||||
|               class="button-default dropdown-item" | ||||
|               @click="toggleTag(tags.STRIP_MEDIA)" | ||||
|             > | ||||
|               {{ $t('user_card.admin_menu.strip_media') }} | ||||
|               <span | ||||
|                 class="menu-checkbox" | ||||
|                 :class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }" | ||||
|               /> | ||||
|               {{ $t('user_card.admin_menu.strip_media') }} | ||||
|             </button> | ||||
|             <button | ||||
|               class="button-default dropdown-item" | ||||
|               @click="toggleTag(tags.FORCE_UNLISTED)" | ||||
|             > | ||||
|               {{ $t('user_card.admin_menu.force_unlisted') }} | ||||
|               <span | ||||
|                 class="menu-checkbox" | ||||
|                 :class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }" | ||||
|               /> | ||||
|               {{ $t('user_card.admin_menu.force_unlisted') }} | ||||
|             </button> | ||||
|             <button | ||||
|               class="button-default dropdown-item" | ||||
|               @click="toggleTag(tags.SANDBOX)" | ||||
|             > | ||||
|               {{ $t('user_card.admin_menu.sandbox') }} | ||||
|               <span | ||||
|                 class="menu-checkbox" | ||||
|                 :class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }" | ||||
|               /> | ||||
|               {{ $t('user_card.admin_menu.sandbox') }} | ||||
|             </button> | ||||
|             <button | ||||
|               v-if="user.is_local" | ||||
|               class="button-default dropdown-item" | ||||
|               @click="toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)" | ||||
|             > | ||||
|               {{ $t('user_card.admin_menu.disable_remote_subscription') }} | ||||
|               <span | ||||
|                 class="menu-checkbox" | ||||
|                 :class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }" | ||||
|               /> | ||||
|               {{ $t('user_card.admin_menu.disable_remote_subscription') }} | ||||
|             </button> | ||||
|             <button | ||||
|               v-if="user.is_local" | ||||
|               class="button-default dropdown-item" | ||||
|               @click="toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)" | ||||
|             > | ||||
|               {{ $t('user_card.admin_menu.disable_any_subscription') }} | ||||
|               <span | ||||
|                 class="menu-checkbox" | ||||
|                 :class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }" | ||||
|               /> | ||||
|               {{ $t('user_card.admin_menu.disable_any_subscription') }} | ||||
|             </button> | ||||
|             <button | ||||
|               v-if="user.is_local" | ||||
|               class="button-default dropdown-item" | ||||
|               @click="toggleTag(tags.QUARANTINE)" | ||||
|             > | ||||
|               {{ $t('user_card.admin_menu.quarantine') }} | ||||
|               <span | ||||
|                 class="menu-checkbox" | ||||
|                 :class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }" | ||||
|               /> | ||||
|               {{ $t('user_card.admin_menu.quarantine') }} | ||||
|             </button> | ||||
|           </span> | ||||
|         </div> | ||||
|       </div> | ||||
|       <button | ||||
|         slot="trigger" | ||||
|         class="btn button-default btn-block" | ||||
|         :class="{ toggled }" | ||||
|       > | ||||
|         {{ $t('user_card.admin_menu.moderation') }} | ||||
|       </button> | ||||
|       </template> | ||||
|       <template v-slot:trigger> | ||||
|         <button | ||||
|           class="btn button-default btn-block moderation-tools-button" | ||||
|           :class="{ toggled }" | ||||
|         > | ||||
|           {{ $t('user_card.admin_menu.moderation') }} | ||||
|           <FAIcon icon="chevron-down" /> | ||||
|         </button> | ||||
|       </template> | ||||
|     </Popover> | ||||
|     <portal to="modal"> | ||||
|       <DialogModal | ||||
|         v-if="showDeleteUserDialog" | ||||
|         :on-cancel="deleteUserDialog.bind(this, false)" | ||||
|       > | ||||
|         <template slot="header"> | ||||
|         <template v-slot:header> | ||||
|           {{ $t('user_card.admin_menu.delete_user') }} | ||||
|         </template> | ||||
|         <p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p> | ||||
|         <template slot="footer"> | ||||
|         <template v-slot:footer> | ||||
|           <button | ||||
|             class="btn button-default" | ||||
|             @click="deleteUserDialog(false)" | ||||
|  | @ -163,25 +165,6 @@ | |||
| <style lang="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 { | ||||
|   height: 100%; | ||||
|   .trigger { | ||||
|  | @ -189,4 +172,10 @@ | |||
|     height: 100%; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .moderation-tools-button { | ||||
|   svg,i { | ||||
|     font-size: 0.8em; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -1,17 +1,56 @@ | |||
| import { mapState } from 'vuex' | ||||
| 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 = { | ||||
|   computed: { | ||||
|     ...mapState({ | ||||
|       federationPolicy: state => get(state, 'instance.federationPolicy'), | ||||
|       mrfPolicies: state => get(state, 'instance.federationPolicy.mrf_policies', []), | ||||
|       quarantineInstances: state => get(state, 'instance.federationPolicy.quarantined_instances', []), | ||||
|       acceptInstances: state => get(state, 'instance.federationPolicy.mrf_simple.accept', []), | ||||
|       rejectInstances: state => get(state, 'instance.federationPolicy.mrf_simple.reject', []), | ||||
|       ftlRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []), | ||||
|       mediaNsfwInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []), | ||||
|       mediaRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_removal', []), | ||||
|       quarantineInstances: state => toInstanceReasonObject( | ||||
|         get(state, 'instance.federationPolicy.quarantined_instances', []), | ||||
|         get(state, 'instance.federationPolicy.quarantined_instances_info', []), | ||||
|         'quarantined_instances' | ||||
|       ), | ||||
|       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', []), | ||||
|       keywordsReject: state => get(state, 'instance.federationPolicy.mrf_keyword.reject', []), | ||||
|       keywordsReplace: state => get(state, 'instance.federationPolicy.mrf_keyword.replace', []) | ||||
|  |  | |||
|  | @ -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; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -31,13 +31,24 @@ | |||
| 
 | ||||
|             <p>{{ $t("about.mrf.simple.accept_desc") }}</p> | ||||
| 
 | ||||
|             <ul> | ||||
|               <li | ||||
|                 v-for="instance in acceptInstances" | ||||
|                 :key="instance" | ||||
|                 v-text="instance" | ||||
|               /> | ||||
|             </ul> | ||||
|             <table> | ||||
|               <tr> | ||||
|                 <th>{{ $t("about.mrf.simple.instance") }}</th> | ||||
|                 <th>{{ $t("about.mrf.simple.reason") }}</th> | ||||
|               </tr> | ||||
|               <tr | ||||
|                 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 v-if="rejectInstances.length"> | ||||
|  | @ -45,13 +56,24 @@ | |||
| 
 | ||||
|             <p>{{ $t("about.mrf.simple.reject_desc") }}</p> | ||||
| 
 | ||||
|             <ul> | ||||
|               <li | ||||
|                 v-for="instance in rejectInstances" | ||||
|                 :key="instance" | ||||
|                 v-text="instance" | ||||
|               /> | ||||
|             </ul> | ||||
|             <table> | ||||
|               <tr> | ||||
|                 <th>{{ $t("about.mrf.simple.instance") }}</th> | ||||
|                 <th>{{ $t("about.mrf.simple.reason") }}</th> | ||||
|               </tr> | ||||
|               <tr | ||||
|                 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 v-if="quarantineInstances.length"> | ||||
|  | @ -59,13 +81,24 @@ | |||
| 
 | ||||
|             <p>{{ $t("about.mrf.simple.quarantine_desc") }}</p> | ||||
| 
 | ||||
|             <ul> | ||||
|               <li | ||||
|                 v-for="instance in quarantineInstances" | ||||
|                 :key="instance" | ||||
|                 v-text="instance" | ||||
|               /> | ||||
|             </ul> | ||||
|             <table> | ||||
|               <tr> | ||||
|                 <th>{{ $t("about.mrf.simple.instance") }}</th> | ||||
|                 <th>{{ $t("about.mrf.simple.reason") }}</th> | ||||
|               </tr> | ||||
|               <tr | ||||
|                 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 v-if="ftlRemovalInstances.length"> | ||||
|  | @ -73,13 +106,24 @@ | |||
| 
 | ||||
|             <p>{{ $t("about.mrf.simple.ftl_removal_desc") }}</p> | ||||
| 
 | ||||
|             <ul> | ||||
|               <li | ||||
|                 v-for="instance in ftlRemovalInstances" | ||||
|                 :key="instance" | ||||
|                 v-text="instance" | ||||
|               /> | ||||
|             </ul> | ||||
|             <table> | ||||
|               <tr> | ||||
|                 <th>{{ $t("about.mrf.simple.instance") }}</th> | ||||
|                 <th>{{ $t("about.mrf.simple.reason") }}</th> | ||||
|               </tr> | ||||
|               <tr | ||||
|                 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 v-if="mediaNsfwInstances.length"> | ||||
|  | @ -87,13 +131,24 @@ | |||
| 
 | ||||
|             <p>{{ $t("about.mrf.simple.media_nsfw_desc") }}</p> | ||||
| 
 | ||||
|             <ul> | ||||
|               <li | ||||
|                 v-for="instance in mediaNsfwInstances" | ||||
|                 :key="instance" | ||||
|                 v-text="instance" | ||||
|               /> | ||||
|             </ul> | ||||
|             <table> | ||||
|               <tr> | ||||
|                 <th>{{ $t("about.mrf.simple.instance") }}</th> | ||||
|                 <th>{{ $t("about.mrf.simple.reason") }}</th> | ||||
|               </tr> | ||||
|               <tr | ||||
|                 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 v-if="mediaRemovalInstances.length"> | ||||
|  | @ -101,13 +156,24 @@ | |||
| 
 | ||||
|             <p>{{ $t("about.mrf.simple.media_removal_desc") }}</p> | ||||
| 
 | ||||
|             <ul> | ||||
|               <li | ||||
|                 v-for="instance in mediaRemovalInstances" | ||||
|                 :key="instance" | ||||
|                 v-text="instance" | ||||
|               /> | ||||
|             </ul> | ||||
|             <table> | ||||
|               <tr> | ||||
|                 <th>{{ $t("about.mrf.simple.instance") }}</th> | ||||
|                 <th>{{ $t("about.mrf.simple.reason") }}</th> | ||||
|               </tr> | ||||
|               <tr | ||||
|                 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> | ||||
| 
 | ||||
|           <h2 v-if="hasKeywordPolicies"> | ||||
|  | @ -161,7 +227,6 @@ | |||
| <script src="./mrf_transparency_panel.js"></script> | ||||
| 
 | ||||
| <style lang="scss"> | ||||
| .mrf-section { | ||||
|   margin: 1em; | ||||
| } | ||||
| @import '../../_variables.scss'; | ||||
| @import './mrf_transparency_panel.scss'; | ||||
| </style> | ||||
|  |  | |||
|  | @ -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 { library } from '@fortawesome/fontawesome-svg-core' | ||||
|  | @ -7,10 +7,12 @@ import { | |||
|   faGlobe, | ||||
|   faBookmark, | ||||
|   faEnvelope, | ||||
|   faHome, | ||||
|   faChevronDown, | ||||
|   faChevronUp, | ||||
|   faComments, | ||||
|   faBell, | ||||
|   faInfoCircle | ||||
|   faInfoCircle, | ||||
|   faStream | ||||
| } from '@fortawesome/free-solid-svg-icons' | ||||
| 
 | ||||
| library.add( | ||||
|  | @ -18,10 +20,12 @@ library.add( | |||
|   faGlobe, | ||||
|   faBookmark, | ||||
|   faEnvelope, | ||||
|   faHome, | ||||
|   faChevronDown, | ||||
|   faChevronUp, | ||||
|   faComments, | ||||
|   faBell, | ||||
|   faInfoCircle | ||||
|   faInfoCircle, | ||||
|   faStream | ||||
| ) | ||||
| 
 | ||||
| const NavPanel = { | ||||
|  | @ -30,16 +34,20 @@ const NavPanel = { | |||
|       this.$store.dispatch('startFetchingFollowRequests') | ||||
|     } | ||||
|   }, | ||||
|   components: { | ||||
|     TimelineMenuContent | ||||
|   }, | ||||
|   data () { | ||||
|     return { | ||||
|       showTimelines: false | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     toggleTimelines () { | ||||
|       this.showTimelines = !this.showTimelines | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     onTimelineRoute () { | ||||
|       return !!timelineNames()[this.$route.name] | ||||
|     }, | ||||
|     timelinesRoute () { | ||||
|       if (this.$store.state.interface.lastTimeline) { | ||||
|         return this.$store.state.interface.lastTimeline | ||||
|       } | ||||
|       return this.currentUser ? 'friends' : 'public-timeline' | ||||
|     }, | ||||
|     ...mapState({ | ||||
|       currentUser: state => state.users.currentUser, | ||||
|       followRequestCount: state => state.api.followRequests.length, | ||||
|  |  | |||
|  | @ -3,19 +3,33 @@ | |||
|     <div class="panel panel-default"> | ||||
|       <ul> | ||||
|         <li v-if="currentUser || !privateMode"> | ||||
|           <router-link | ||||
|             :to="{ name: timelinesRoute }" | ||||
|             :class="onTimelineRoute && 'router-link-active'" | ||||
|           <button | ||||
|             class="button-unstyled menu-item" | ||||
|             @click="toggleTimelines" | ||||
|           > | ||||
|             <FAIcon | ||||
|               fixed-width | ||||
|               class="fa-scale-110" | ||||
|               icon="home" | ||||
|               icon="stream" | ||||
|             />{{ $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 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 | ||||
|               fixed-width | ||||
|               class="fa-scale-110" | ||||
|  | @ -24,7 +38,10 @@ | |||
|           </router-link> | ||||
|         </li> | ||||
|         <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 | ||||
|               v-if="unreadChatCount" | ||||
|               class="badge badge-notification" | ||||
|  | @ -39,7 +56,10 @@ | |||
|           </router-link> | ||||
|         </li> | ||||
|         <li v-if="currentUser && currentUser.locked"> | ||||
|           <router-link :to="{ name: 'friend-requests' }"> | ||||
|           <router-link | ||||
|             class="menu-item" | ||||
|             :to="{ name: 'friend-requests' }" | ||||
|           > | ||||
|             <FAIcon | ||||
|               fixed-width | ||||
|               class="fa-scale-110" | ||||
|  | @ -54,7 +74,10 @@ | |||
|           </router-link> | ||||
|         </li> | ||||
|         <li> | ||||
|           <router-link :to="{ name: 'about' }"> | ||||
|           <router-link | ||||
|             class="menu-item" | ||||
|             :to="{ name: 'about' }" | ||||
|           > | ||||
|             <FAIcon | ||||
|               fixed-width | ||||
|               class="fa-scale-110" | ||||
|  | @ -91,14 +114,14 @@ | |||
|     border-color: var(--border, $fallback--border); | ||||
|     padding: 0; | ||||
| 
 | ||||
|     &:first-child a { | ||||
|     &:first-child .menu-item { | ||||
|       border-top-right-radius: $fallback--panelRadius; | ||||
|       border-top-right-radius: var(--panelRadius, $fallback--panelRadius); | ||||
|       border-top-left-radius: $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: var(--panelRadius, $fallback--panelRadius); | ||||
|       border-bottom-left-radius: $fallback--panelRadius; | ||||
|  | @ -110,13 +133,15 @@ | |||
|     border: none; | ||||
|   } | ||||
| 
 | ||||
|   a { | ||||
|   .menu-item { | ||||
|     display: block; | ||||
|     box-sizing: border-box; | ||||
|     align-items: stretch; | ||||
|     height: 3.5em; | ||||
|     line-height: 3.5em; | ||||
|     padding: 0 1em; | ||||
|     width: 100%; | ||||
|     color: $fallback--link; | ||||
|     color: var(--link, $fallback--link); | ||||
| 
 | ||||
|     &:hover { | ||||
|       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 { | ||||
|     margin-right: 0.8em; | ||||
|   } | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ import UserAvatar from '../user_avatar/user_avatar.vue' | |||
| import UserCard from '../user_card/user_card.vue' | ||||
| import Timeago from '../timeago/timeago.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 { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' | ||||
| import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' | ||||
|  | @ -46,7 +47,8 @@ const Notification = { | |||
|     UserCard, | ||||
|     Timeago, | ||||
|     Status, | ||||
|     Report | ||||
|     Report, | ||||
|     RichContent | ||||
|   }, | ||||
|   methods: { | ||||
|     toggleUserExpanded () { | ||||
|  |  | |||
|  | @ -2,6 +2,19 @@ | |||
| 
 | ||||
| // TODO Copypaste from Status, should unify it somehow | ||||
| .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 { | ||||
|     padding: 0.25em 0.6em; | ||||
|     height: 1.2em; | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| <template> | ||||
|   <Status | ||||
|     v-if="notification.type === 'mention'" | ||||
|     class="Notification" | ||||
|     :compact="true" | ||||
|     :statusoid="notification.status" | ||||
|   /> | ||||
|  | @ -11,7 +12,7 @@ | |||
|     > | ||||
|       <small> | ||||
|         <router-link :to="userProfileLink"> | ||||
|           {{ notification.from_profile.screen_name }} | ||||
|           {{ notification.from_profile.screen_name_ui }} | ||||
|         </router-link> | ||||
|       </small> | ||||
|       <button | ||||
|  | @ -51,17 +52,19 @@ | |||
|         <span class="notification-details"> | ||||
|           <div class="name-and-action"> | ||||
|             <!-- eslint-disable vue/no-v-html --> | ||||
|             <bdi | ||||
|               v-if="!!notification.from_profile.name_html" | ||||
|               class="username" | ||||
|               :title="'@'+notification.from_profile.screen_name" | ||||
|               v-html="notification.from_profile.name_html" | ||||
|             /> | ||||
|             <bdi v-if="!!notification.from_profile.name_html"> | ||||
|               <RichContent | ||||
|                 class="username" | ||||
|                 :title="'@'+notification.from_profile.screen_name_ui" | ||||
|                 :html="notification.from_profile.name_html" | ||||
|                 :emoji="notification.from_profile.emoji" | ||||
|               /> | ||||
|             </bdi> | ||||
|             <!-- eslint-enable vue/no-v-html --> | ||||
|             <span | ||||
|               v-else | ||||
|               class="username" | ||||
|               :title="'@'+notification.from_profile.screen_name" | ||||
|               :title="'@'+notification.from_profile.screen_name_ui" | ||||
|             >{{ notification.from_profile.name }}</span> | ||||
|             <span v-if="notification.type === 'like'"> | ||||
|               <FAIcon | ||||
|  | @ -155,7 +158,7 @@ | |||
|             :to="userProfileLink" | ||||
|             class="follow-name" | ||||
|           > | ||||
|             @{{ notification.from_profile.screen_name }} | ||||
|             @{{ notification.from_profile.screen_name_ui }} | ||||
|           </router-link> | ||||
|           <div | ||||
|             v-if="notification.type === 'follow_request'" | ||||
|  | @ -180,7 +183,7 @@ | |||
|           class="move-text" | ||||
|         > | ||||
|           <router-link :to="targetUserProfileLink"> | ||||
|             @{{ notification.target.screen_name }} | ||||
|             @{{ notification.target.screen_name_ui }} | ||||
|           </router-link> | ||||
|         </div> | ||||
|         <Report | ||||
|  | @ -188,8 +191,9 @@ | |||
|           :report-id="notification.report.id" | ||||
|         /> | ||||
|         <template v-else> | ||||
|           <status-content | ||||
|           <StatusContent | ||||
|             class="faint" | ||||
|             :compact="true" | ||||
|             :status="notification.action" | ||||
|           /> | ||||
|         </template> | ||||
|  |  | |||
							
								
								
									
										122
									
								
								src/components/notifications/notification_filters.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								src/components/notifications/notification_filters.vue
									
									
									
									
									
										Normal 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> | ||||
|  | @ -1,5 +1,6 @@ | |||
| import { mapGetters } from 'vuex' | ||||
| import Notification from '../notification/notification.vue' | ||||
| import NotificationFilters from './notification_filters.vue' | ||||
| import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js' | ||||
| import { | ||||
|   notificationsFromStore, | ||||
|  | @ -17,6 +18,10 @@ library.add( | |||
| const DEFAULT_SEEN_TO_DISPLAY_COUNT = 30 | ||||
| 
 | ||||
| const Notifications = { | ||||
|   components: { | ||||
|     Notification, | ||||
|     NotificationFilters | ||||
|   }, | ||||
|   props: { | ||||
|     // Disables display of panel header
 | ||||
|     noHeading: Boolean, | ||||
|  | @ -35,11 +40,6 @@ const Notifications = { | |||
|       seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT | ||||
|     } | ||||
|   }, | ||||
|   created () { | ||||
|     const store = this.$store | ||||
|     const credentials = store.state.users.currentUser.credentials | ||||
|     notificationsFetcher.fetchAndUpdate({ store, credentials }) | ||||
|   }, | ||||
|   computed: { | ||||
|     mainClass () { | ||||
|       return this.minimalMode ? '' : 'panel panel-default' | ||||
|  | @ -70,9 +70,6 @@ const Notifications = { | |||
|     }, | ||||
|     ...mapGetters(['unreadChatCount']) | ||||
|   }, | ||||
|   components: { | ||||
|     Notification | ||||
|   }, | ||||
|   watch: { | ||||
|     unseenCountTitle (count) { | ||||
|       if (count > 0) { | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| @import '../../_variables.scss'; | ||||
| 
 | ||||
| .notifications { | ||||
| .Notifications { | ||||
|   &:not(.minimal) { | ||||
|     // a bit of a hack to allow scrolling below notifications | ||||
|     padding-bottom: 15em; | ||||
|  | @ -11,6 +11,10 @@ | |||
|     color: var(--text, $fallback--text); | ||||
|   } | ||||
| 
 | ||||
|   .notifications-footer { | ||||
|     border: none; | ||||
|   } | ||||
| 
 | ||||
|   .notification { | ||||
|     position: relative; | ||||
| 
 | ||||
|  | @ -33,11 +37,6 @@ | |||
| 
 | ||||
| .notification { | ||||
|   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 { | ||||
|     canvas { | ||||
|  | @ -84,7 +83,6 @@ | |||
|     } | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
|   .follow-text, .move-text { | ||||
|     padding: 0.5em 0; | ||||
|     overflow-wrap: break-word; | ||||
|  | @ -147,13 +145,6 @@ | |||
|       max-width: 100%; | ||||
|       text-overflow: ellipsis; | ||||
|       white-space: nowrap; | ||||
| 
 | ||||
|       img { | ||||
|         width: 14px; | ||||
|         height: 14px; | ||||
|         vertical-align: middle; | ||||
|         object-fit: contain | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     .timeago { | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| <template> | ||||
|   <div | ||||
|     :class="{ minimal: minimalMode }" | ||||
|     class="notifications" | ||||
|     class="Notifications" | ||||
|   > | ||||
|     <div :class="mainClass"> | ||||
|       <div | ||||
|  | @ -22,6 +22,7 @@ | |||
|         > | ||||
|           {{ $t('notifications.read') }} | ||||
|         </button> | ||||
|         <NotificationFilters /> | ||||
|       </div> | ||||
|       <div class="panel-body"> | ||||
|         <div | ||||
|  | @ -34,10 +35,10 @@ | |||
|           <notification :notification="notification" /> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="panel-footer"> | ||||
|       <div class="panel-footer notifications-footer"> | ||||
|         <div | ||||
|           v-if="bottomedOut" | ||||
|           class="new-status-notification text-center panel-footer faint" | ||||
|           class="new-status-notification text-center faint" | ||||
|         > | ||||
|           {{ $t('notifications.no_more_notifications') }} | ||||
|         </div> | ||||
|  | @ -46,13 +47,13 @@ | |||
|           class="button-unstyled -link -fullwidth" | ||||
|           @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') }} | ||||
|           </div> | ||||
|         </button> | ||||
|         <div | ||||
|           v-else | ||||
|           class="new-status-notification text-center panel-footer" | ||||
|           class="new-status-notification text-center" | ||||
|         > | ||||
|           <FAIcon | ||||
|             icon="circle-notch" | ||||
|  |  | |||
|  | @ -53,7 +53,7 @@ | |||
|                 type="submit" | ||||
|                 class="btn button-default btn-block" | ||||
|               > | ||||
|                 {{ $t('general.submit') }} | ||||
|                 {{ $t('settings.save') }} | ||||
|               </button> | ||||
|             </div> | ||||
|           </div> | ||||
|  |  | |||
|  | @ -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' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'Poll', | ||||
|   props: ['basePoll'], | ||||
|   components: { Timeago }, | ||||
|   props: ['basePoll', 'emoji'], | ||||
|   components: { | ||||
|     Timeago, | ||||
|     RichContent | ||||
|   }, | ||||
|   data () { | ||||
|     return { | ||||
|       loading: false, | ||||
|  |  | |||
|  | @ -17,8 +17,11 @@ | |||
|           <span class="result-percentage"> | ||||
|             {{ percentageForOption(option.votes_count) }}% | ||||
|           </span> | ||||
|           <!-- eslint-disable-next-line vue/no-v-html --> | ||||
|           <span v-html="option.title_html" /> | ||||
|           <RichContent | ||||
|             :html="option.title_html" | ||||
|             :handle-links="false" | ||||
|             :emoji="emoji" | ||||
|           /> | ||||
|         </div> | ||||
|         <div | ||||
|           class="result-fill" | ||||
|  | @ -42,8 +45,11 @@ | |||
|           :value="index" | ||||
|         > | ||||
|         <label class="option-vote"> | ||||
|           <!-- eslint-disable-next-line vue/no-v-html --> | ||||
|           <div v-html="option.title_html" /> | ||||
|           <RichContent | ||||
|             :html="option.title_html" | ||||
|             :handle-links="false" | ||||
|             :emoji="emoji" | ||||
|           /> | ||||
|         </label> | ||||
|       </div> | ||||
|     </div> | ||||
|  | @ -58,7 +64,12 @@ | |||
|         {{ $t('polls.vote') }} | ||||
|       </button> | ||||
|       <div class="total"> | ||||
|         {{ totalVotesCount }} {{ $t("polls.votes") }} ·  | ||||
|         <template v-if="typeof poll.voters_count === 'number'"> | ||||
|           {{ $tc("polls.people_voted_count", poll.voters_count, { count: poll.voters_count }) }} ·  | ||||
|         </template> | ||||
|         <template v-else> | ||||
|           {{ $tc("polls.votes_count", poll.votes_count, { count: poll.votes_count }) }} ·  | ||||
|         </template> | ||||
|       </div> | ||||
|       <i18n :path="expired ? 'polls.expired' : 'polls.expires_in'"> | ||||
|         <Timeago | ||||
|  |  | |||
|  | @ -1,19 +1,21 @@ | |||
| import * as DateUtils from 'src/services/date_utils/date_utils.js' | ||||
| import { uniq } from 'lodash' | ||||
| import { library } from '@fortawesome/fontawesome-svg-core' | ||||
| import Select from '../select/select.vue' | ||||
| import { | ||||
|   faTimes, | ||||
|   faChevronDown, | ||||
|   faPlus | ||||
| } from '@fortawesome/free-solid-svg-icons' | ||||
| 
 | ||||
| library.add( | ||||
|   faTimes, | ||||
|   faChevronDown, | ||||
|   faPlus | ||||
| ) | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     Select | ||||
|   }, | ||||
|   name: 'PollForm', | ||||
|   props: ['visible'], | ||||
|   data: () => ({ | ||||
|  |  | |||
|  | @ -46,23 +46,19 @@ | |||
|         class="poll-type" | ||||
|         :title="$t('polls.type')" | ||||
|       > | ||||
|         <label | ||||
|           for="poll-type-selector" | ||||
|           class="select" | ||||
|         <Select | ||||
|           v-model="pollType" | ||||
|           class="poll-type-select" | ||||
|           unstyled="true" | ||||
|           @change="updatePollToParent" | ||||
|         > | ||||
|           <select | ||||
|             v-model="pollType" | ||||
|             class="select" | ||||
|             @change="updatePollToParent" | ||||
|           > | ||||
|             <option value="single">{{ $t('polls.single_choice') }}</option> | ||||
|             <option value="multiple">{{ $t('polls.multiple_choices') }}</option> | ||||
|           </select> | ||||
|           <FAIcon | ||||
|             class="select-down-icon" | ||||
|             icon="chevron-down" | ||||
|           /> | ||||
|         </label> | ||||
|           <option value="single"> | ||||
|             {{ $t('polls.single_choice') }} | ||||
|           </option> | ||||
|           <option value="multiple"> | ||||
|             {{ $t('polls.multiple_choices') }} | ||||
|           </option> | ||||
|         </Select> | ||||
|       </div> | ||||
|       <div | ||||
|         class="poll-expiry" | ||||
|  | @ -76,24 +72,20 @@ | |||
|           :max="maxExpirationInCurrentUnit" | ||||
|           @change="expiryAmountChange" | ||||
|         > | ||||
|         <label class="expiry-unit select"> | ||||
|           <select | ||||
|             v-model="expiryUnit" | ||||
|             @change="expiryAmountChange" | ||||
|         <Select | ||||
|           v-model="expiryUnit" | ||||
|           unstyled="true" | ||||
|           class="expiry-unit" | ||||
|           @change="expiryAmountChange" | ||||
|         > | ||||
|           <option | ||||
|             v-for="unit in expiryUnits" | ||||
|             :key="unit" | ||||
|             :value="unit" | ||||
|           > | ||||
|             <option | ||||
|               v-for="unit in expiryUnits" | ||||
|               :key="unit" | ||||
|               :value="unit" | ||||
|             > | ||||
|               {{ $t(`time.${unit}_short`, ['']) }} | ||||
|             </option> | ||||
|           </select> | ||||
|           <FAIcon | ||||
|             class="select-down-icon" | ||||
|             icon="chevron-down" | ||||
|           /> | ||||
|         </label> | ||||
|             {{ $t(`time.${unit}_short`, ['']) }} | ||||
|           </option> | ||||
|         </Select> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|  | @ -147,10 +139,9 @@ | |||
|   .poll-type { | ||||
|     margin-right: 0.75em; | ||||
|     flex: 1 1 60%; | ||||
|     .select { | ||||
|       border: none; | ||||
|       box-shadow: none; | ||||
|       background-color: transparent; | ||||
| 
 | ||||
|     .poll-type-select { | ||||
|       padding-right: 0.75em; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | @ -161,12 +152,6 @@ | |||
|       width: 3em; | ||||
|       text-align: right; | ||||
|     } | ||||
| 
 | ||||
|     .expiry-unit { | ||||
|       border: none; | ||||
|       box-shadow: none; | ||||
|       background-color: transparent; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -3,25 +3,32 @@ const Popover = { | |||
|   props: { | ||||
|     // Action to trigger popover: either 'hover' or 'click'
 | ||||
|     trigger: String, | ||||
| 
 | ||||
|     // Either 'top' or 'bottom'
 | ||||
|     placement: String, | ||||
| 
 | ||||
|     // Takes object with properties 'x' and 'y', values of these can be
 | ||||
|     // 'container' for using offsetParent as boundaries for either axis
 | ||||
|     // or 'viewport'
 | ||||
|     boundTo: Object, | ||||
| 
 | ||||
|     // Takes a selector to use as a replacement for the parent container
 | ||||
|     // for getting boundaries for x an y axis
 | ||||
|     boundToSelector: String, | ||||
| 
 | ||||
|     // Takes a top/bottom/left/right object, how much space to leave
 | ||||
|     // between boundary and popover element
 | ||||
|     margin: Object, | ||||
| 
 | ||||
|     // Takes a x/y object and tells how many pixels to offset from
 | ||||
|     // anchor point on either axis
 | ||||
|     offset: Object, | ||||
| 
 | ||||
|     // Replaces the classes you may want for the popover container.
 | ||||
|     // Use 'popover-default' in addition to get the default popover
 | ||||
|     // styles with your custom class.
 | ||||
|     popoverClass: String, | ||||
| 
 | ||||
|     // If true, subtract padding when calculating position for the popover,
 | ||||
|     // use it when popover offset looks to be different on top vs bottom.
 | ||||
|     removePadding: Boolean | ||||
|  | @ -47,8 +54,11 @@ const Popover = { | |||
|       } | ||||
| 
 | ||||
|       // 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 | ||||
|       // SVGs don't have offsetWidth/Height, use fallback
 | ||||
|       const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth | ||||
|       const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight | ||||
|       const screenBox = anchorEl.getBoundingClientRect() | ||||
|       // Screen position of the origin point for popover
 | ||||
|       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 translateY = usingTop | ||||
|         ? -anchorEl.offsetHeight + vPadding - yOffset - content.offsetHeight | ||||
|         ? -anchorHeight + vPadding - yOffset - content.offsetHeight | ||||
|         : yOffset | ||||
| 
 | ||||
|       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,
 | ||||
|       // single translate or translate3d resulted in blurry text.
 | ||||
|  | @ -121,9 +131,12 @@ const Popover = { | |||
|       } | ||||
|     }, | ||||
|     showPopover () { | ||||
|       if (this.hidden) this.$emit('show') | ||||
|       const wasHidden = this.hidden | ||||
|       this.hidden = false | ||||
|       this.$nextTick(this.updateStyles) | ||||
|       this.$nextTick(() => { | ||||
|         if (wasHidden) this.$emit('show') | ||||
|         this.updateStyles() | ||||
|       }) | ||||
|     }, | ||||
|     hidePopover () { | ||||
|       if (!this.hidden) this.$emit('close') | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ | |||
|     <button | ||||
|       ref="trigger" | ||||
|       class="button-unstyled -fullwidth popover-trigger-button" | ||||
|       type="button" | ||||
|       @click="onClick" | ||||
|     > | ||||
|       <slot name="trigger" /> | ||||
|  | @ -32,7 +33,7 @@ | |||
| @import '../../_variables.scss'; | ||||
| 
 | ||||
| .popover-trigger-button { | ||||
|   display: block; | ||||
|   display: inline-block; | ||||
| } | ||||
| 
 | ||||
| .popover { | ||||
|  | @ -81,10 +82,9 @@ | |||
| 
 | ||||
|   .dropdown-item { | ||||
|     line-height: 21px; | ||||
|     margin-right: 5px; | ||||
|     overflow: auto; | ||||
|     display: block; | ||||
|     padding: .25rem 1.0rem .25rem 1.5rem; | ||||
|     padding: .5em 0.75em; | ||||
|     clear: both; | ||||
|     font-weight: 400; | ||||
|     text-align: inherit; | ||||
|  | @ -100,10 +100,9 @@ | |||
|     --btnText: var(--popoverText, $fallback--text); | ||||
| 
 | ||||
|     &-icon { | ||||
|       padding-left: 0.5rem; | ||||
| 
 | ||||
|       svg { | ||||
|         margin-right: 0.25rem; | ||||
|         width: 22px; | ||||
|         margin-right: 0.75rem; | ||||
|         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> | ||||
|  |  | |||
|  | @ -4,6 +4,7 @@ import ScopeSelector from '../scope_selector/scope_selector.vue' | |||
| import EmojiInput from '../emoji_input/emoji_input.vue' | ||||
| import PollForm from '../poll/poll_form.vue' | ||||
| import Attachment from '../attachment/attachment.vue' | ||||
| import Gallery from 'src/components/gallery/gallery.vue' | ||||
| import StatusContent from '../status_content/status_content.vue' | ||||
| import fileTypeService from '../../services/file_type/file_type.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 { mapGetters, mapState } from 'vuex' | ||||
| import Checkbox from '../checkbox/checkbox.vue' | ||||
| import Select from '../select/select.vue' | ||||
| 
 | ||||
| import { library } from '@fortawesome/fontawesome-svg-core' | ||||
| import { | ||||
|   faChevronDown, | ||||
|   faSmileBeam, | ||||
|   faPollH, | ||||
|   faUpload, | ||||
|  | @ -24,7 +25,6 @@ import { | |||
| } from '@fortawesome/free-solid-svg-icons' | ||||
| 
 | ||||
| library.add( | ||||
|   faChevronDown, | ||||
|   faSmileBeam, | ||||
|   faPollH, | ||||
|   faUpload, | ||||
|  | @ -84,8 +84,10 @@ const PostStatusForm = { | |||
|     PollForm, | ||||
|     ScopeSelector, | ||||
|     Checkbox, | ||||
|     Select, | ||||
|     Attachment, | ||||
|     StatusContent | ||||
|     StatusContent, | ||||
|     Gallery | ||||
|   }, | ||||
|   mounted () { | ||||
|     this.updateIdempotencyKey() | ||||
|  | @ -115,7 +117,7 @@ const PostStatusForm = { | |||
|       ? this.copyMessageScope | ||||
|       : this.$store.state.users.currentUser.default_scope | ||||
| 
 | ||||
|     const { postContentType: contentType } = this.$store.getters.mergedConfig | ||||
|     const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig | ||||
| 
 | ||||
|     return { | ||||
|       dropFiles: [], | ||||
|  | @ -126,7 +128,7 @@ const PostStatusForm = { | |||
|       newStatus: { | ||||
|         spoilerText: this.subject || '', | ||||
|         status: statusText, | ||||
|         nsfw: false, | ||||
|         nsfw: !!sensitiveByDefault, | ||||
|         files: [], | ||||
|         poll: {}, | ||||
|         mediaDescriptions: {}, | ||||
|  | @ -388,6 +390,21 @@ const PostStatusForm = { | |||
|       this.newStatus.files.splice(index, 1) | ||||
|       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) { | ||||
|       templateArgs = templateArgs || {} | ||||
|       this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs) | ||||
|  |  | |||
|  | @ -189,28 +189,19 @@ | |||
|             v-if="postFormats.length > 1" | ||||
|             class="text-format" | ||||
|           > | ||||
|             <label | ||||
|               for="post-content-type" | ||||
|               class="select" | ||||
|             <Select | ||||
|               id="post-content-type" | ||||
|               v-model="newStatus.contentType" | ||||
|               class="form-control" | ||||
|             > | ||||
|               <select | ||||
|                 id="post-content-type" | ||||
|                 v-model="newStatus.contentType" | ||||
|                 class="form-control" | ||||
|               <option | ||||
|                 v-for="postFormat in postFormats" | ||||
|                 :key="postFormat" | ||||
|                 :value="postFormat" | ||||
|               > | ||||
|                 <option | ||||
|                   v-for="postFormat in postFormats" | ||||
|                   :key="postFormat" | ||||
|                   :value="postFormat" | ||||
|                 > | ||||
|                   {{ $t(`post_status.content_type["${postFormat}"]`) }} | ||||
|                 </option> | ||||
|               </select> | ||||
|               <FAIcon | ||||
|                 class="select-down-icon" | ||||
|                 icon="chevron-down" | ||||
|               /> | ||||
|             </label> | ||||
|                 {{ $t(`post_status.content_type["${postFormat}"]`) }} | ||||
|               </option> | ||||
|             </Select> | ||||
|           </div> | ||||
|           <div | ||||
|             v-if="postFormats.length === 1 && postFormats[0] !== 'text/plain'" | ||||
|  | @ -272,7 +263,7 @@ | |||
|           disabled | ||||
|           class="btn button-default" | ||||
|         > | ||||
|           {{ $t('general.submit') }} | ||||
|           {{ $t('post_status.post') }} | ||||
|         </button> | ||||
|         <!-- touchstart is used to keep the OSK at the same position after a message send --> | ||||
|         <button | ||||
|  | @ -282,7 +273,7 @@ | |||
|           @touchstart.stop.prevent="postStatus($event, newStatus)" | ||||
|           @click.stop.prevent="postStatus($event, newStatus)" | ||||
|         > | ||||
|           {{ $t('general.submit') }} | ||||
|           {{ $t('post_status.post') }} | ||||
|         </button> | ||||
|       </div> | ||||
|       <div | ||||
|  | @ -296,32 +287,22 @@ | |||
|           @click="clearError" | ||||
|         /> | ||||
|       </div> | ||||
|       <div class="attachments"> | ||||
|         <div | ||||
|           v-for="file in newStatus.files" | ||||
|           :key="file.url" | ||||
|           class="media-upload-wrapper" | ||||
|         > | ||||
|           <button | ||||
|             class="button-unstyled hider" | ||||
|             @click="removeMediaFile(file)" | ||||
|           > | ||||
|             <FAIcon icon="times" /> | ||||
|           </button> | ||||
|           <attachment | ||||
|             :attachment="file" | ||||
|             :set-media="() => $store.dispatch('setMedia', newStatus.files)" | ||||
|             size="small" | ||||
|             allow-play="false" | ||||
|           /> | ||||
|           <input | ||||
|             v-model="newStatus.mediaDescriptions[file.id]" | ||||
|             type="text" | ||||
|             :placeholder="$t('post_status.media_description')" | ||||
|             @keydown.enter.prevent="" | ||||
|           > | ||||
|         </div> | ||||
|       </div> | ||||
|       <gallery | ||||
|         v-if="newStatus.files && newStatus.files.length > 0" | ||||
|         class="attachments" | ||||
|         :grid="true" | ||||
|         :nsfw="false" | ||||
|         :attachments="newStatus.files" | ||||
|         :descriptions="newStatus.mediaDescriptions" | ||||
|         :set-media="() => $store.dispatch('setMedia', newStatus.files)" | ||||
|         :editable="true" | ||||
|         :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)" | ||||
|       /> | ||||
|       <div | ||||
|         v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox" | ||||
|         class="upload_settings" | ||||
|  | @ -339,26 +320,13 @@ | |||
| <style lang="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 { | ||||
|   position: relative; | ||||
| 
 | ||||
|   .attachments { | ||||
|     margin-bottom: 0.5em; | ||||
|   } | ||||
| 
 | ||||
|   .form-bottom { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|  | @ -516,15 +484,6 @@ | |||
|     flex-direction: column; | ||||
|   } | ||||
| 
 | ||||
|    .attachments .media-upload-wrapper { | ||||
|     position: relative; | ||||
| 
 | ||||
|     .attachment { | ||||
|       margin: 0; | ||||
|       padding: 0; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .btn { | ||||
|     cursor: pointer; | ||||
|   } | ||||
|  | @ -625,11 +584,4 @@ | |||
|     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> | ||||
|  |  | |||
|  | @ -23,6 +23,12 @@ const ReactButton = { | |||
|         this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji }) | ||||
|       } | ||||
|       close() | ||||
|     }, | ||||
|     focusInput () { | ||||
|       this.$nextTick(() => { | ||||
|         const input = this.$el.querySelector('input') | ||||
|         if (input) input.focus() | ||||
|       }) | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|  |  | |||
|  | @ -1,15 +1,14 @@ | |||
| <template> | ||||
|   <Popover | ||||
|     trigger="click" | ||||
|     class="ReactButton" | ||||
|     placement="top" | ||||
|     :offset="{ y: 5 }" | ||||
|     :bound-to="{ x: 'container' }" | ||||
|     remove-padding | ||||
|     @show="focusInput" | ||||
|   > | ||||
|     <div | ||||
|       slot="content" | ||||
|       slot-scope="{close}" | ||||
|     > | ||||
|     <template v-slot:content="{close}"> | ||||
|       <div class="reaction-picker-filter"> | ||||
|         <input | ||||
|           v-model="filterWord" | ||||
|  | @ -39,17 +38,18 @@ | |||
|         </span> | ||||
|         <div class="reaction-bottom-fader" /> | ||||
|       </div> | ||||
|     </div> | ||||
|     <span | ||||
|       slot="trigger" | ||||
|       class="ReactButton" | ||||
|       :title="$t('tool_tip.add_reaction')" | ||||
|     > | ||||
|       <FAIcon | ||||
|         class="fa-scale-110 fa-old-padding" | ||||
|         :icon="['far', 'smile-beam']" | ||||
|       /> | ||||
|     </span> | ||||
|     </template> | ||||
|     <template v-slot:trigger> | ||||
|       <button | ||||
|         class="button-unstyled popover-trigger" | ||||
|         :title="$t('tool_tip.add_reaction')" | ||||
|       > | ||||
|         <FAIcon | ||||
|           class="fa-scale-110 fa-old-padding" | ||||
|           :icon="['far', 'smile-beam']" | ||||
|         /> | ||||
|       </button> | ||||
|     </template> | ||||
|   </Popover> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -58,63 +58,72 @@ | |||
| <style lang="scss"> | ||||
| @import '../../_variables.scss'; | ||||
| 
 | ||||
| .reaction-picker-filter { | ||||
|   padding: 0.5em; | ||||
|   display: flex; | ||||
|   input { | ||||
|     flex: 1; | ||||
| .ReactButton { | ||||
|   .reaction-picker-filter { | ||||
|     padding: 0.5em; | ||||
|     display: flex; | ||||
| 
 | ||||
|     input { | ||||
|       flex: 1; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .reaction-picker-divider { | ||||
|   height: 1px; | ||||
|   width: 100%; | ||||
|   margin: 0.5em; | ||||
|   background-color: var(--border, $fallback--border); | ||||
| } | ||||
|   .reaction-picker-divider { | ||||
|     height: 1px; | ||||
|     width: 100%; | ||||
|     margin: 0.5em; | ||||
|     background-color: var(--border, $fallback--border); | ||||
|   } | ||||
| 
 | ||||
| .reaction-picker { | ||||
|   width: 10em; | ||||
|   height: 9em; | ||||
|   font-size: 1.5em; | ||||
|   overflow-y: scroll; | ||||
|   display: flex; | ||||
|   flex-wrap: wrap; | ||||
|   padding: 0.5em; | ||||
|   text-align: center; | ||||
|   align-content: flex-start; | ||||
|   user-select: none; | ||||
|   .reaction-picker { | ||||
|     width: 10em; | ||||
|     height: 9em; | ||||
|     font-size: 1.5em; | ||||
|     overflow-y: scroll; | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|     padding: 0.5em; | ||||
|     text-align: center; | ||||
|     align-content: flex-start; | ||||
|     user-select: none; | ||||
| 
 | ||||
|   mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat, | ||||
|     linear-gradient(to bottom, white 0, transparent 100%) top no-repeat, | ||||
|     linear-gradient(to top, white, white); | ||||
|   transition: mask-size 150ms; | ||||
|   mask-size: 100% 20px, 100% 20px, auto; | ||||
|   // Autoprefixed seem to ignore this one, and also syntax is different | ||||
|   -webkit-mask-composite: xor; | ||||
|   mask-composite: exclude; | ||||
|     mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat, | ||||
|           linear-gradient(to bottom, white 0, transparent 100%) top no-repeat, | ||||
|           linear-gradient(to top, white, white); | ||||
|     transition: mask-size 150ms; | ||||
|     mask-size: 100% 20px, 100% 20px, auto; | ||||
| 
 | ||||
|   .emoji-button { | ||||
|     cursor: pointer; | ||||
|     /* Autoprefixed seem to ignore this one, and also syntax is different */ | ||||
|     -webkit-mask-composite: xor; | ||||
|     mask-composite: exclude; | ||||
| 
 | ||||
|     flex-basis: 20%; | ||||
|     line-height: 1.5em; | ||||
|     align-content: center; | ||||
|     .emoji-button { | ||||
|       cursor: pointer; | ||||
| 
 | ||||
|     &:hover { | ||||
|       transform: scale(1.25); | ||||
|       flex-basis: 20%; | ||||
|       line-height: 1.5em; | ||||
|       align-content: center; | ||||
| 
 | ||||
|       &:hover { | ||||
|         transform: scale(1.25); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /* override of popover internal stuff */ | ||||
|   .popover-trigger-button { | ||||
|     width: auto; | ||||
|   } | ||||
| 
 | ||||
|   .popover-trigger { | ||||
|     padding: 10px; | ||||
|     margin: -10px; | ||||
| 
 | ||||
|     &:hover .svg-inline--fa { | ||||
|       color: $fallback--text; | ||||
|       color: var(--text, $fallback--text); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .ReactButton { | ||||
|   padding: 10px; | ||||
|   margin: -10px; | ||||
| 
 | ||||
|   &:hover .svg-inline--fa { | ||||
|     color: $fallback--text; | ||||
|     color: var(--text, $fallback--text); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| </style> | ||||
|  |  | |||
|  | @ -10,7 +10,8 @@ const registration = { | |||
|       fullname: '', | ||||
|       username: '', | ||||
|       password: '', | ||||
|       confirm: '' | ||||
|       confirm: '', | ||||
|       reason: '' | ||||
|     }, | ||||
|     captcha: {} | ||||
|   }), | ||||
|  | @ -24,7 +25,8 @@ const registration = { | |||
|         confirm: { | ||||
|           required, | ||||
|           sameAsPassword: sameAs('password') | ||||
|         } | ||||
|         }, | ||||
|         reason: { required: requiredIf(() => this.accountApprovalRequired) } | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|  | @ -38,7 +40,10 @@ const registration = { | |||
|   computed: { | ||||
|     token () { return this.$route.params.token }, | ||||
|     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({ | ||||
|       registrationOpen: (state) => state.instance.registrationOpen, | ||||
|  | @ -46,7 +51,8 @@ const registration = { | |||
|       isPending: (state) => state.users.signUpPending, | ||||
|       serverValidationErrors: (state) => state.users.signUpErrors, | ||||
|       termsOfService: (state) => state.instance.tos, | ||||
|       accountActivationRequired: (state) => state.instance.accountActivationRequired | ||||
|       accountActivationRequired: (state) => state.instance.accountActivationRequired, | ||||
|       accountApprovalRequired: (state) => state.instance.accountApprovalRequired | ||||
|     }) | ||||
|   }, | ||||
|   methods: { | ||||
|  | @ -73,6 +79,9 @@ const registration = { | |||
|     }, | ||||
|     setCaptcha () { | ||||
|       this.getCaptcha().then(cpt => { this.captcha = cpt }) | ||||
|     }, | ||||
|     replaceNewlines (str) { | ||||
|       return str.replace(/\s*\n\s*/g, ' \n') | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -162,6 +162,23 @@ | |||
|               </ul> | ||||
|             </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 | ||||
|               v-if="captcha.type != 'none'" | ||||
|               id="captcha-group" | ||||
|  | @ -213,7 +230,7 @@ | |||
|                 type="submit" | ||||
|                 class="btn button-default" | ||||
|               > | ||||
|                 {{ $t('general.submit') }} | ||||
|                 {{ $t('registration.register') }} | ||||
|               </button> | ||||
|             </div> | ||||
|           </div> | ||||
|  |  | |||
							
								
								
									
										328
									
								
								src/components/rich_content/rich_content.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										328
									
								
								src/components/rich_content/rich_content.jsx
									
									
									
									
									
										Normal 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('>') || string.includes('<')) | ||||
|     ) { | ||||
|       const cleanedString = string.replace(/<[^>]+?>/gi, '') // remove all tags | ||||
|         .replace(/@\w+/gi, '') // remove mentions (even failed ones) | ||||
|         .trim() | ||||
|       if (cleanedString.startsWith('>')) { | ||||
|         return `<span class='greentext'>${string}</span>` | ||||
|       } else if (cleanedString.startsWith('<')) { | ||||
|         return `<span class='cyantext'>${string}</span>` | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return string | ||||
|   }).reverse().join('') | ||||
| 
 | ||||
|   return { newHtml } | ||||
| } | ||||
							
								
								
									
										64
									
								
								src/components/rich_content/rich_content.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								src/components/rich_content/rich_content.scss
									
									
									
									
									
										Normal 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; | ||||
|   } | ||||
| } | ||||
|  | @ -8,6 +8,7 @@ | |||
|       class="button-unstyled scope" | ||||
|       :class="css.direct" | ||||
|       :title="$t('post_status.scope.direct')" | ||||
|       type="button" | ||||
|       @click="changeVis('direct')" | ||||
|     > | ||||
|       <FAIcon | ||||
|  | @ -20,6 +21,7 @@ | |||
|       class="button-unstyled scope" | ||||
|       :class="css.private" | ||||
|       :title="$t('post_status.scope.private')" | ||||
|       type="button" | ||||
|       @click="changeVis('private')" | ||||
|     > | ||||
|       <FAIcon | ||||
|  | @ -32,6 +34,7 @@ | |||
|       class="button-unstyled scope" | ||||
|       :class="css.unlisted" | ||||
|       :title="$t('post_status.scope.unlisted')" | ||||
|       type="button" | ||||
|       @click="changeVis('unlisted')" | ||||
|     > | ||||
|       <FAIcon | ||||
|  | @ -44,6 +47,7 @@ | |||
|       class="button-unstyled scope" | ||||
|       :class="css.public" | ||||
|       :title="$t('post_status.scope.public')" | ||||
|       type="button" | ||||
|       @click="changeVis('public')" | ||||
|     > | ||||
|       <FAIcon | ||||
|  |  | |||
|  | @ -15,6 +15,7 @@ | |||
|       > | ||||
|       <button | ||||
|         class="btn button-default search-button" | ||||
|         type="submit" | ||||
|         @click="newQuery(searchTerm)" | ||||
|       > | ||||
|         <FAIcon icon="search" /> | ||||
|  |  | |||
|  | @ -7,6 +7,7 @@ | |||
|       v-if="hidden" | ||||
|       class="button-unstyled nav-icon" | ||||
|       :title="$t('nav.search')" | ||||
|       type="button" | ||||
|       @click.prevent.stop="toggleHidden" | ||||
|     > | ||||
|       <FAIcon | ||||
|  | @ -27,6 +28,7 @@ | |||
|       > | ||||
|       <button | ||||
|         class="button-default search-button" | ||||
|         type="submit" | ||||
|         @click="find(searchTerm)" | ||||
|       > | ||||
|         <FAIcon | ||||
|  | @ -36,6 +38,7 @@ | |||
|       </button> | ||||
|       <button | ||||
|         class="button-unstyled cancel-search" | ||||
|         type="button" | ||||
|         @click.prevent.stop="toggleHidden" | ||||
|       > | ||||
|         <FAIcon | ||||
|  |  | |||
							
								
								
									
										21
									
								
								src/components/select/select.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/components/select/select.js
									
									
									
									
									
										Normal 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
		Loading…
	
		Reference in a new issue
	
	 Ilja
						Ilja