Merge branch 'develop' into features/favicons

This commit is contained in:
Shpuld Shpuldson 2020-06-18 16:47:37 +03:00
commit 8a9654b511
140 changed files with 7916 additions and 4330 deletions

View File

@ -2,8 +2,56 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Changed
- Greentext now has separate color slot for it
- Removed the use of with_move parameters when fetching notifications
### Fixed
- Weird bug related to post being sent seemingly after pasting with keyboard (hopefully)
- Multiple issues with muted statuses/notifications
## [Unreleased patch]
### Add
- Added private notifications option for push notifications
- 'Copy link' button for statuses (in the ellipsis menu)
- Autocomplete domains from list of known instances
### Changed
- Registration page no longer requires email if the server is configured not to require it
- Change heart to thumbs up in reaction picker
- Close the media modal on navigation events
- Add colons to the emoji alt text, to make them copyable
- Add better visual indication for drag-and-drop for files
### Fixed
- Status ellipsis menu closes properly when selecting certain options
- Cropped images look correct in Chrome
- Newlines in the muted words settings work again
- Clicking on non-latin hashtags won't open a new window
- Uploading and drag-dropping multiple files works correctly now.
- Subject field now appears disabled when posting
- Fix status ellipsis menu being cut off in notifications column
## [2.0.3] - 2020-05-02
### Fixed
- Show more/less works correctly with auto-collapsed subjects and long posts
- RTL characters won't look messed up in notifications
### Changed
- Emoji autocomplete will match any part of the word and not just start, for example :drool will now helpfully suggest :blobcatdrool: and :blobcatdroolreach:
### Add
- Follow request notification support
## [2.0.2] - 2020-04-08
### Fixed
- Favorite/Repeat avatars not showing up on private instances/non-public posts
- Autocorrect getting triggered in the captcha field
- Overflow on long domains in follow/move notifications
### Changed
- Polish translation updated
## [2.0.0] - 2020-02-28 ## [2.0.0] - 2020-02-28
### Added ### Added
@ -28,6 +76,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Notifications column now cleans itself up to optimize performance when tab is left open for a long time - Notifications column now cleans itself up to optimize performance when tab is left open for a long time
- 403 messaging - 403 messaging
### Fixed ### Fixed
- Fixed loader-spinner not disappearing when a status preview fails to load
- anon viewers won't get theme data saved to local storage, so admin changing default theme will have an effect for users coming back to instance. - anon viewers won't get theme data saved to local storage, so admin changing default theme will have an effect for users coming back to instance.
- Single notifications left unread when hitting read on another device/tab - Single notifications left unread when hitting read on another device/tab
- Registration fixed - Registration fixed

View File

@ -1,6 +1,6 @@
# pleroma_fe # Pleroma-FE
> A single column frontend for both Pleroma and GS servers. > A single column frontend designed for Pleroma.
![screenshot](https://i.imgur.com/DJVqSJ0.png) ![screenshot](https://i.imgur.com/DJVqSJ0.png)
@ -11,7 +11,6 @@ To translate Pleroma-FE, add your language to [src/i18n/messages.js](https://git
# FOR ADMINS # FOR ADMINS
You don't need to build Pleroma-FE yourself. Those using the Pleroma backend will be able to use it out of the box. You don't need to build Pleroma-FE yourself. Those using the Pleroma backend will be able to use it out of the box.
For the GNU social backend, check out https://git.pleroma.social/pleroma/pleroma-fe/wikis/dual-boot-with-qvitter to see how to run Pleroma-FE and Qvitter at the same time.
## Build Setup ## Build Setup

View File

@ -19,32 +19,69 @@ There's currently no mechanism for user-settings synchronization across several
## Options ## Options
### `theme` ### `alwaysShowSubjectInput`
Default theme used for new users. De-facto instance-default, user can change theme. `true` - will always show subject line input, `false` - only show when it's not empty (i.e. replying). To hide subject line input completely, set it to `false` and `subjectLineBehavior` to `"noop"`
### `background` ### `background`
Default image background. Be aware of using too big images as they may take longer to load. Currently image is fitted with `background-size: cover` which means "scaled and cropped", currently left-aligned. De-facto instance default, user can choose their own background, if they remove their own background, instance default will be used instead. Default image background. Be aware of using too big images as they may take longer to load. Currently image is fitted with `background-size: cover` which means "scaled and cropped", currently left-aligned. De-facto instance default, user can choose their own background, if they remove their own background, instance default will be used instead.
### `collapseMessageWithSubject`
Collapse post content when post has a subject line (content warning). Instance-default.
### `disableChat`
hides the chat (TODO: even if it's enabled on backend)
### `greentext`
Changes lines prefixed with the `>` character to have a green text color
### `hideFilteredStatuses`
Removes filtered statuses from timelines.
### `hideMutedPosts`
Removes muted statuses from timelines.
### `hidePostStats`
Hide repeats/favorites counters for posts.
### `hideSitename`
Hide instance name in header.
### `hideUserStats`
Hide followers/friends counters for users.
### `loginMethod`
`"password"` - show simple password field
`"token"` - show button to log in with external method (will redirect to login form, more details in BE documentation)
### `logo`, `logoMask`, `logoMargin` ### `logo`, `logoMask`, `logoMargin`
Instance `logo`, could be any image, including svg. By default it assumes logo used will be monochrome-with-alpha one, this is done to be compatible with both light and dark themes, so that white logo designed with dark theme in mind won't be invisible over light theme, this is done via [CSS3 Masking](https://www.html5rocks.com/en/tutorials/masking/adobe/). Basically - it will take alpha channel of the image and fill non-transparent areas of it with solid color. If you really want colorful logo - it can be done by setting `logoMask` to `false`. Instance `logo`, could be any image, including svg. By default it assumes logo used will be monochrome-with-alpha one, this is done to be compatible with both light and dark themes, so that white logo designed with dark theme in mind won't be invisible over light theme, this is done via [CSS3 Masking](https://www.html5rocks.com/en/tutorials/masking/adobe/). Basically - it will take alpha channel of the image and fill non-transparent areas of it with solid color. If you really want colorful logo - it can be done by setting `logoMask` to `false`.
`logoMargin` allows you to adjust vertical margins between logo boundary and navbar borders. The idea is that to have logo's image without any extra margins and instead adjust them to your need in layout. `logoMargin` allows you to adjust vertical margins between logo boundary and navbar borders. The idea is that to have logo's image without any extra margins and instead adjust them to your need in layout.
### `minimalScopesMode`
Limit scope selection to *Direct*, *User default* and *Scope of post replying to*. This also makes it impossible to reply to a DM with a non-DM post from PleromaFE.
### `nsfwCensorImage`
Use custom image for NSFW'd images
### `postContentType`
Default post formatting option (markdown/bbcode/plaintext/etc...)
### `redirectRootNoLogin`, `redirectRootLogin` ### `redirectRootNoLogin`, `redirectRootLogin`
These two settings should point to where FE should redirect visitor when they login/open up website root These two settings should point to where FE should redirect visitor when they login/open up website root
### `chatDisabled` ### `scopeCopy`
hides the chat (TODO: even if it's enabled on backend) Copy post scope (visibility) when replying to a post. Instance-default.
### `sidebarRight`
Change alignment of sidebar and panels to the right. Defaults to `false`.
### `showFeaturesPanel`
Show panel showcasing instance features/settings to logged-out visitors
### `showInstanceSpecificPanel` ### `showInstanceSpecificPanel`
This allows you to include arbitrary HTML content in a panel below navigation menu. PleromaFE looks for an html page `instance/panel.html`, by default it's not provided in FE, but BE bundles some [default one](https://git.pleroma.social/pleroma/pleroma/blob/develop/priv/static/instance/panel.html). De-facto instance-defaults, since user can hide instance-specific panel. This allows you to include arbitrary HTML content in a panel below navigation menu. PleromaFE looks for an html page `instance/panel.html`, by default it's not provided in FE, but BE bundles some [default one](https://git.pleroma.social/pleroma/pleroma/blob/develop/priv/static/instance/panel.html). De-facto instance-defaults, since user can hide instance-specific panel.
### `collapseMessageWithSubject`
Collapse post content when post has a subject line (content warning). Instance-default.
### `scopeCopy`
Copy post scope (visibility) when replying to a post. Instance-default.
### `subjectLineBehavior` ### `subjectLineBehavior`
How to handle subject line (CW) when replying to a post. How to handle subject line (CW) when replying to a post.
* `"email"` - like EMail - prepend `re: ` to subject line if it doesn't already start with it. * `"email"` - like EMail - prepend `re: ` to subject line if it doesn't already start with it.
@ -52,39 +89,22 @@ How to handle subject line (CW) when replying to a post.
* `"noop"` - do not copy * `"noop"` - do not copy
Instance-default. Instance-default.
### `postContentType` ### `theme`
Default post formatting option (markdown/bbcode/plaintext/etc...) Default theme used for new users. De-facto instance-default, user can change theme.
### `alwaysShowSubjectInput`
`true` - will always show subject line input, `false` - only show when it's not empty (i.e. replying). To hide subject line input completely, set it to `false` and `subjectLineBehavior` to `"noop"`
### `hidePostStats` and `hideUserStats`
Hide counters for posts and users respectively, i.e. hiding repeats/favorites counts for posts, hiding followers/friends counts for users. This is just cosmetic and aimed to ease pressure and bias imposed by stat numbers of people and/or posts. (as an example: so that people care less about how many followers someone has since they can't see that info)
### `loginMethod`
`"password"` - show simple password field
`"token"` - show button to log in with external method (will redirect to login form, more details in BE documentation)
### `webPushNotifications` ### `webPushNotifications`
Enables [PushAPI](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) - based notifications for users. Instance-default. Enables [PushAPI](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) - based notifications for users. Instance-default.
### `noAttachmentLinks`
**TODO Currently doesn't seem to be doing anything code-wise**, but implication is to disable adding links for attachments, which looks nicer but breaks compatibility with old GNU/Social servers.
### `nsfwCensorImage`
Use custom image for NSFW'd images
### `showFeaturesPanel`
Show panel showcasing instance features/settings to logged-out visitors
### `hideSitename`
Hide instance name in header
## Indirect configuration ## Indirect configuration
Some features are configured depending on how backend is configured. In general the approach is "if backend allows it there's no need to hide it, if backend doesn't allow it there's no need to show it. Some features are configured depending on how backend is configured. In general the approach is "if backend allows it there's no need to hide it, if backend doesn't allow it there's no need to show it.
### Chat ### Chat
**TODO somewhat broken, see: chatDisabled** chat can be disabled by disabling it in backend **TODO somewhat broken, see: disableChat** chat can be disabled by disabling it in backend
### Private Mode
If the `private` instance setting is enabled in the backend, features that are not accessible without authentication, such as the timelines and search will be disabled for unauthenticated users.
### Rich text formatting in post formatting ### Rich text formatting in post formatting
Rich text formatting options are displayed depending on how many formatting options are enabled on backend, if you don't want your users to use rich text at all you can only allow "text/plain" one, frontend then will only display post text format as a label instead of dropdown (just so that users know for example if you only allow Markdown, only BBCode or only Plain text) Rich text formatting options are displayed depending on how many formatting options are enabled on backend, if you don't want your users to use rich text at all you can only allow "text/plain" one, frontend then will only display post text format as a label instead of dropdown (just so that users know for example if you only allow Markdown, only BBCode or only Plain text)
@ -92,13 +112,3 @@ Rich text formatting options are displayed depending on how many formatting opti
### Who to follow ### Who to follow
This is a panel intended for users to find people to follow based on randomness or on post contents. Being potentially privacy unfriendly feature it needs to be enabled and configured in backend to be enabled. This is a panel intended for users to find people to follow based on randomness or on post contents. Being potentially privacy unfriendly feature it needs to be enabled and configured in backend to be enabled.
### Safe DM message display
Setting this will change the warning text that is displayed for direct messages.
ATTENTION: If you actually want the behavior to change. You will need to set the appropriate option at the backend. See the backend documentation for information about that.
DO NOT activate this without checking the backend configuration first!
### Private Mode
If the `private` instance setting is enabled in the backend, features that are not accessible without authentication, such as the timelines and search will be disabled for unauthenticated users.

View File

@ -33,7 +33,7 @@ will become
Note that you can only use emoji defined on your instance, you cannot "copy" someone else's emoji, and will have to ask your administrator to copy emoji from other instance to yours. Note that you can only use emoji defined on your instance, you cannot "copy" someone else's emoji, and will have to ask your administrator to copy emoji from other instance to yours.
Lastly, there's two convenience options for emoji: an emoji picker (smiley face to the right of "submit" button) and autocomplete suggestions - when you start typing :shortcode: it will automatically try to suggest you emoj and complete the shortcode for you if you select one. **Note** that if emoji doesn't show up in suggestions nor in emoji picker it means there's no such emoji on your instance, if shortcode doesn't match any defined emoji it will appear as text. Lastly, there's two convenience options for emoji: an emoji picker (smiley face to the right of "submit" button) and autocomplete suggestions - when you start typing :shortcode: it will automatically try to suggest you emoj and complete the shortcode for you if you select one. **Note** that if emoji doesn't show up in suggestions nor in emoji picker it means there's no such emoji on your instance, if shortcode doesn't match any defined emoji it will appear as text.
* **Attachments** are fairly simple - you can attach any file to a post as long as the file is within maximum size limits. If you're uploading explicit material you can mark all of your attachments as sensitive (or add `#nsfw` tag) - it will hide the images and videos behind a warning so that it won't be displayed instantly. * **Attachments** are fairly simple - you can attach any file to a post as long as the file is within maximum size limits. If you're uploading explicit material you can mark all of your attachments as sensitive (or add `#nsfw` tag) - it will hide the images and videos behind a warning so that it won't be displayed instantly.
* **Subject line** also known as **CW** (Content Warning) could be used as a header to the post and/or to warn others about contents of the post having something that might upset somebody or something among those lines. Several applications allow to hide post content leaving only subject line visible. As a side-effect using subject line will also mark your images as sensitive (see above). * **Subject line** also known as **CW** (Content Warning) could be used as a header to the post and/or to warn others about contents of the post having something that might upset somebody or something among those lines. Several applications allow to hide post content leaving only subject line visible. Using a subject line will not mark your images as sensitive, you will have to do that explicitly (see above).
* **Visiblity scope** controls who will be able to see your posts. There are four scopes available: * **Visiblity scope** controls who will be able to see your posts. There are four scopes available:
1. `Public`: This is the default, and some fediverse software like GNU Social only supports this. This means that your post is accessible by anyone and will be shown in the public timelines. 1. `Public`: This is the default, and some fediverse software like GNU Social only supports this. This means that your post is accessible by anyone and will be shown in the public timelines.

View File

@ -22,23 +22,20 @@
"cropperjs": "^1.4.3", "cropperjs": "^1.4.3",
"diff": "^3.0.1", "diff": "^3.0.1",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"karma-mocha-reporter": "^2.2.1",
"localforage": "^1.5.0", "localforage": "^1.5.0",
"object-path": "^0.11.3",
"phoenix": "^1.3.0", "phoenix": "^1.3.0",
"portal-vue": "^2.1.4", "portal-vue": "^2.1.4",
"sanitize-html": "^1.13.0",
"v-click-outside": "^2.1.1", "v-click-outside": "^2.1.1",
"vue": "^2.5.13", "vue": "^2.6.11",
"vue-chat-scroll": "^1.2.1", "vue-chat-scroll": "^1.2.1",
"vue-i18n": "^7.3.2", "vue-i18n": "^7.3.2",
"vue-router": "^3.0.1", "vue-router": "^3.0.1",
"vue-template-compiler": "^2.3.4", "vue-template-compiler": "^2.6.11",
"vuelidate": "^0.7.4", "vuelidate": "^0.7.4",
"vuex": "^3.0.1", "vuex": "^3.0.1"
"whatwg-fetch": "^2.0.3"
}, },
"devDependencies": { "devDependencies": {
"karma-mocha-reporter": "^2.2.1",
"@babel/core": "^7.7.5", "@babel/core": "^7.7.5",
"@babel/plugin-transform-runtime": "^7.7.6", "@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.6", "@babel/preset-env": "^7.7.6",

View File

@ -6,6 +6,7 @@ import InstanceSpecificPanel from './components/instance_specific_panel/instance
import FeaturesPanel from './components/features_panel/features_panel.vue' import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue' import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import ChatPanel from './components/chat_panel/chat_panel.vue' import ChatPanel from './components/chat_panel/chat_panel.vue'
import SettingsModal from './components/settings_modal/settings_modal.vue'
import MediaModal from './components/media_modal/media_modal.vue' import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue' import SideDrawer from './components/side_drawer/side_drawer.vue'
import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue' import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
@ -29,6 +30,7 @@ export default {
SideDrawer, SideDrawer,
MobilePostStatusButton, MobilePostStatusButton,
MobileNav, MobileNav,
SettingsModal,
UserReportingModal, UserReportingModal,
PostStatusModal PostStatusModal
}, },
@ -45,7 +47,8 @@ export default {
}), }),
created () { created () {
// Load the locale from the storage // Load the locale from the storage
this.$i18n.locale = this.$store.getters.mergedConfig.interfaceLanguage const val = this.$store.getters.mergedConfig.interfaceLanguage
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
window.addEventListener('resize', this.updateMobileState) window.addEventListener('resize', this.updateMobileState)
}, },
destroyed () { destroyed () {
@ -99,7 +102,12 @@ export default {
}, },
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
isMobileLayout () { return this.$store.state.interface.mobileLayout }, isMobileLayout () { return this.$store.state.interface.mobileLayout },
privateMode () { return this.$store.state.instance.private } privateMode () { return this.$store.state.instance.private },
sidebarAlign () {
return {
'order': this.$store.state.instance.sidebarRight ? 99 : 0
}
}
}, },
methods: { methods: {
scrollToTop () { scrollToTop () {
@ -112,6 +120,9 @@ export default {
onSearchBarToggled (hidden) { onSearchBarToggled (hidden) {
this.searchBarHidden = hidden this.searchBarHidden = hidden
}, },
openSettingsModal () {
this.$store.dispatch('openSettingsModal')
},
updateMobileState () { updateMobileState () {
const mobileLayout = windowWidth() <= 800 const mobileLayout = windowWidth() <= 800
const changed = mobileLayout !== this.isMobileLayout const changed = mobileLayout !== this.isMobileLayout

View File

@ -566,7 +566,7 @@ main-router {
min-height: 0; min-height: 0;
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
margin-left: .25em; margin-left: .5em;
min-width: 1px; min-width: 1px;
align-self: stretch; align-self: stretch;
} }
@ -860,51 +860,6 @@ nav {
} }
} }
.setting-item {
border-bottom: 2px solid var(--fg, $fallback--fg);
margin: 1em 1em 1.4em;
padding-bottom: 1.4em;
> div {
margin-bottom: .5em;
&:last-child {
margin-bottom: 0;
}
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 1em;
}
select {
min-width: 10em;
}
textarea {
width: 100%;
max-width: 100%;
height: 100px;
}
.unavailable,
.unavailable i {
color: var(--cRed, $fallback--cRed);
color: $fallback--cRed;
}
.btn {
min-height: 28px;
min-width: 10em;
padding: 0 2em;
}
.number-input {
max-width: 6em;
}
}
.select-multiple { .select-multiple {
display: flex; display: flex;
.option-list { .option-list {

View File

@ -46,15 +46,16 @@
@toggled="onSearchBarToggled" @toggled="onSearchBarToggled"
@click.stop.native @click.stop.native
/> />
<router-link <a
href="#"
class="mobile-hidden" class="mobile-hidden"
:to="{ name: 'settings'}" @click.stop="openSettingsModal"
> >
<i <i
class="button-icon icon-cog nav-icon" class="button-icon icon-cog nav-icon"
:title="$t('nav.preferences')" :title="$t('nav.preferences')"
/> />
</router-link> </a>
<a <a
v-if="currentUser && currentUser.role === 'admin'" v-if="currentUser && currentUser.role === 'admin'"
href="/pleroma/admin/#/login-pleroma" href="/pleroma/admin/#/login-pleroma"
@ -80,7 +81,10 @@
id="content" id="content"
class="container underlay" class="container underlay"
> >
<div class="sidebar-flexer mobile-hidden"> <div
class="sidebar-flexer mobile-hidden"
:style="sidebarAlign"
>
<div class="sidebar-bounds"> <div class="sidebar-bounds">
<div class="sidebar-scroller"> <div class="sidebar-scroller">
<div class="sidebar"> <div class="sidebar">
@ -122,6 +126,7 @@
<MobilePostStatusButton /> <MobilePostStatusButton />
<UserReportingModal /> <UserReportingModal />
<PostStatusModal /> <PostStatusModal />
<SettingsModal />
<portal-target name="modal" /> <portal-target name="modal" />
</div> </div>
</template> </template>

View File

@ -108,9 +108,9 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('subjectLineBehavior') copyInstanceOption('subjectLineBehavior')
copyInstanceOption('postContentType') copyInstanceOption('postContentType')
copyInstanceOption('alwaysShowSubjectInput') copyInstanceOption('alwaysShowSubjectInput')
copyInstanceOption('noAttachmentLinks')
copyInstanceOption('showFeaturesPanel') copyInstanceOption('showFeaturesPanel')
copyInstanceOption('hideSitename') copyInstanceOption('hideSitename')
copyInstanceOption('sidebarRight')
return store.dispatch('setTheme', config['theme']) return store.dispatch('setTheme', config['theme'])
} }
@ -241,6 +241,9 @@ const getNodeInfo = async ({ store }) => {
: federation.enabled : federation.enabled
}) })
const accountActivationRequired = metadata.accountActivationRequired
store.dispatch('setInstanceOption', { name: 'accountActivationRequired', value: accountActivationRequired })
const accounts = metadata.staffAccounts const accounts = metadata.staffAccounts
resolveStaffAccounts({ store, accounts }) resolveStaffAccounts({ store, accounts })
} else { } else {
@ -304,6 +307,9 @@ const afterStoreSetup = async ({ store, i18n }) => {
getNodeInfo({ store }) getNodeInfo({ store })
]) ])
// Start fetching things that don't need to block the UI
store.dispatch('fetchMutes')
const router = new VueRouter({ const router = new VueRouter({
mode: 'history', mode: 'history',
routes: routes(store), routes: routes(store),

View File

@ -7,10 +7,8 @@ import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue' import DMs from 'components/dm_timeline/dm_timeline.vue'
import UserProfile from 'components/user_profile/user_profile.vue' import UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue' import Search from 'components/search/search.vue'
import Settings from 'components/settings/settings.vue'
import Registration from 'components/registration/registration.vue' import Registration from 'components/registration/registration.vue'
import PasswordReset from 'components/password_reset/password_reset.vue' import PasswordReset from 'components/password_reset/password_reset.vue'
import UserSettings from 'components/user_settings/user_settings.vue'
import FollowRequests from 'components/follow_requests/follow_requests.vue' import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue' import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import Notifications from 'components/notifications/notifications.vue' import Notifications from 'components/notifications/notifications.vue'
@ -56,12 +54,10 @@ export default (store) => {
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile }, { name: 'external-user-profile', path: '/users/:id', component: UserProfile },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute }, { name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute }, { name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
{ name: 'settings', path: '/settings', component: Settings },
{ name: 'registration', path: '/registration', component: Registration }, { name: 'registration', path: '/registration', component: Registration },
{ name: 'password-reset', path: '/password-reset', component: PasswordReset, props: true }, { name: 'password-reset', path: '/password-reset', component: PasswordReset, props: true },
{ name: 'registration-token', path: '/registration/:token', component: Registration }, { name: 'registration-token', path: '/registration/:token', component: Registration },
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute }, { name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
{ name: 'user-settings', path: '/user-settings', component: UserSettings, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute }, { name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm }, { name: 'login', path: '/login', component: AuthForm },
{ name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) }, { name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },

View File

@ -3,7 +3,7 @@ import Popover from '../popover/popover.vue'
const AccountActions = { const AccountActions = {
props: [ props: [
'user' 'user', 'relationship'
], ],
data () { data () {
return { } return { }

View File

@ -3,22 +3,23 @@
<Popover <Popover
trigger="click" trigger="click"
placement="bottom" placement="bottom"
:bound-to="{ x: 'container' }"
> >
<div <div
slot="content" slot="content"
class="account-tools-popover" class="account-tools-popover"
> >
<div class="dropdown-menu"> <div class="dropdown-menu">
<template v-if="user.following"> <template v-if="relationship.following">
<button <button
v-if="user.showing_reblogs" v-if="relationship.showing_reblogs"
class="btn btn-default dropdown-item" class="btn btn-default dropdown-item"
@click="hideRepeats" @click="hideRepeats"
> >
{{ $t('user_card.hide_repeats') }} {{ $t('user_card.hide_repeats') }}
</button> </button>
<button <button
v-if="!user.showing_reblogs" v-if="!relationship.showing_reblogs"
class="btn btn-default dropdown-item" class="btn btn-default dropdown-item"
@click="showRepeats" @click="showRepeats"
> >
@ -30,7 +31,7 @@
/> />
</template> </template>
<button <button
v-if="user.statusnet_blocking" v-if="relationship.blocking"
class="btn btn-default btn-block dropdown-item" class="btn btn-default btn-block dropdown-item"
@click="unblockUser" @click="unblockUser"
> >

View File

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

View File

@ -12,7 +12,7 @@
class="basic-user-card-expanded-content" class="basic-user-card-expanded-content"
> >
<UserCard <UserCard
:user="user" :user-id="user.id"
:rounded="true" :rounded="true"
:bordered="true" :bordered="true"
/> />

View File

@ -11,8 +11,11 @@ const BlockCard = {
user () { user () {
return this.$store.getters.findUser(this.userId) return this.$store.getters.findUser(this.userId)
}, },
relationship () {
return this.$store.getters.relationship(this.userId)
},
blocked () { blocked () {
return this.user.statusnet_blocking return this.relationship.blocking
} }
}, },
components: { components: {

View File

@ -5,9 +5,20 @@ const DomainMuteCard = {
components: { components: {
ProgressButton ProgressButton
}, },
computed: {
user () {
return this.$store.state.users.currentUser
},
muted () {
return this.user.domainMutes.includes(this.domain)
}
},
methods: { methods: {
unmuteDomain () { unmuteDomain () {
return this.$store.dispatch('unmuteDomain', this.domain) return this.$store.dispatch('unmuteDomain', this.domain)
},
muteDomain () {
return this.$store.dispatch('muteDomain', this.domain)
} }
} }
} }

View File

@ -4,6 +4,7 @@
{{ domain }} {{ domain }}
</div> </div>
<ProgressButton <ProgressButton
v-if="muted"
:click="unmuteDomain" :click="unmuteDomain"
class="btn btn-default" class="btn btn-default"
> >
@ -12,6 +13,16 @@
{{ $t('domain_mute_card.unmute_progress') }} {{ $t('domain_mute_card.unmute_progress') }}
</template> </template>
</ProgressButton> </ProgressButton>
<ProgressButton
v-else
:click="muteDomain"
class="btn btn-default"
>
{{ $t('domain_mute_card.mute') }}
<template slot="progress">
{{ $t('domain_mute_card.mute_progress') }}
</template>
</ProgressButton>
</div> </div>
</template> </template>
@ -34,5 +45,9 @@
button { button {
width: 10em; width: 10em;
} }
.autosuggest-results & {
padding-left: 1em;
}
} }
</style> </style>

View File

@ -29,17 +29,29 @@ export default data => input => {
export const suggestEmoji = emojis => input => { export const suggestEmoji = emojis => input => {
const noPrefix = input.toLowerCase().substr(1) const noPrefix = input.toLowerCase().substr(1)
return emojis return emojis
.filter(({ displayText }) => displayText.toLowerCase().startsWith(noPrefix)) .filter(({ displayText }) => displayText.toLowerCase().match(noPrefix))
.sort((a, b) => { .sort((a, b) => {
let aScore = 0 let aScore = 0
let bScore = 0 let bScore = 0
// Make custom emojis a priority // An exact match always wins
aScore += a.imageUrl ? 10 : 0 aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0
bScore += b.imageUrl ? 10 : 0 bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0
// Sort alphabetically // Prioritize custom emoji a lot
const alphabetically = a.displayText > b.displayText ? 1 : -1 aScore += a.imageUrl ? 100 : 0
bScore += b.imageUrl ? 100 : 0
// Prioritize prefix matches somewhat
aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
// Sort by length
aScore -= a.displayText.length
bScore -= b.displayText.length
// Break ties alphabetically
const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5
return bScore - aScore + alphabetically return bScore - aScore + alphabetically
}) })

View File

@ -29,6 +29,11 @@ const ExtraButtons = {
this.$store.dispatch('unmuteConversation', this.status.id) this.$store.dispatch('unmuteConversation', this.status.id)
.then(() => this.$emit('onSuccess')) .then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error)) .catch(err => this.$emit('onError', err.error.error))
},
copyLink () {
navigator.clipboard.writeText(this.statusLink)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
} }
}, },
computed: { computed: {
@ -46,6 +51,9 @@ const ExtraButtons = {
}, },
canMute () { canMute () {
return !!this.currentUser return !!this.currentUser
},
statusLink () {
return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}`
} }
} }
} }

View File

@ -1,11 +1,14 @@
<template> <template>
<Popover <Popover
v-if="canDelete || canMute || canPin"
trigger="click" trigger="click"
placement="top" placement="top"
class="extra-button-popover" class="extra-button-popover"
:bound-to="{ x: 'container' }"
>
<div
slot="content"
slot-scope="{close}"
> >
<div slot="content">
<div class="dropdown-menu"> <div class="dropdown-menu">
<button <button
v-if="canMute && !status.thread_muted" v-if="canMute && !status.thread_muted"
@ -23,28 +26,35 @@
</button> </button>
<button <button
v-if="!status.pinned && canPin" v-if="!status.pinned && canPin"
v-close-popover
class="dropdown-item dropdown-item-icon" class="dropdown-item dropdown-item-icon"
@click.prevent="pinStatus" @click.prevent="pinStatus"
@click="close"
> >
<i class="icon-pin" /><span>{{ $t("status.pin") }}</span> <i class="icon-pin" /><span>{{ $t("status.pin") }}</span>
</button> </button>
<button <button
v-if="status.pinned && canPin" v-if="status.pinned && canPin"
v-close-popover
class="dropdown-item dropdown-item-icon" class="dropdown-item dropdown-item-icon"
@click.prevent="unpinStatus" @click.prevent="unpinStatus"
@click="close"
> >
<i class="icon-pin" /><span>{{ $t("status.unpin") }}</span> <i class="icon-pin" /><span>{{ $t("status.unpin") }}</span>
</button> </button>
<button <button
v-if="canDelete" v-if="canDelete"
v-close-popover
class="dropdown-item dropdown-item-icon" class="dropdown-item dropdown-item-icon"
@click.prevent="deleteStatus" @click.prevent="deleteStatus"
@click="close"
> >
<i class="icon-cancel" /><span>{{ $t("status.delete") }}</span> <i class="icon-cancel" /><span>{{ $t("status.delete") }}</span>
</button> </button>
<button
class="dropdown-item dropdown-item-icon"
@click.prevent="copyLink"
@click="close"
>
<i class="icon-share" /><span>{{ $t("status.copy_link") }}</span>
</button>
</div> </div>
</div> </div>
<i <i

View File

@ -1,6 +1,6 @@
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
export default { export default {
props: ['user', 'labelFollowing', 'buttonClass'], props: ['relationship', 'labelFollowing', 'buttonClass'],
data () { data () {
return { return {
inProgress: false inProgress: false
@ -8,12 +8,12 @@ export default {
}, },
computed: { computed: {
isPressed () { isPressed () {
return this.inProgress || this.user.following return this.inProgress || this.relationship.following
}, },
title () { title () {
if (this.inProgress || this.user.following) { if (this.inProgress || this.relationship.following) {
return this.$t('user_card.follow_unfollow') return this.$t('user_card.follow_unfollow')
} else if (this.user.requested) { } else if (this.relationship.requested) {
return this.$t('user_card.follow_again') return this.$t('user_card.follow_again')
} else { } else {
return this.$t('user_card.follow') return this.$t('user_card.follow')
@ -22,9 +22,9 @@ export default {
label () { label () {
if (this.inProgress) { if (this.inProgress) {
return this.$t('user_card.follow_progress') return this.$t('user_card.follow_progress')
} else if (this.user.following) { } else if (this.relationship.following) {
return this.labelFollowing || this.$t('user_card.following') return this.labelFollowing || this.$t('user_card.following')
} else if (this.user.requested) { } else if (this.relationship.requested) {
return this.$t('user_card.follow_sent') return this.$t('user_card.follow_sent')
} else { } else {
return this.$t('user_card.follow') return this.$t('user_card.follow')
@ -33,20 +33,20 @@ export default {
}, },
methods: { methods: {
onClick () { onClick () {
this.user.following ? this.unfollow() : this.follow() this.relationship.following ? this.unfollow() : this.follow()
}, },
follow () { follow () {
this.inProgress = true this.inProgress = true
requestFollow(this.user, this.$store).then(() => { requestFollow(this.relationship.id, this.$store).then(() => {
this.inProgress = false this.inProgress = false
}) })
}, },
unfollow () { unfollow () {
const store = this.$store const store = this.$store
this.inProgress = true this.inProgress = true
requestUnfollow(this.user, store).then(() => { requestUnfollow(this.relationship.id, store).then(() => {
this.inProgress = false this.inProgress = false
store.commit('removeStatus', { timeline: 'friends', userId: this.user.id }) store.commit('removeStatus', { timeline: 'friends', userId: this.relationship.id })
}) })
} }
} }

View File

@ -18,6 +18,9 @@ const FollowCard = {
}, },
loggedIn () { loggedIn () {
return this.$store.state.users.currentUser return this.$store.state.users.currentUser
},
relationship () {
return this.$store.getters.relationship(this.user.id)
} }
} }
} }

View File

@ -2,24 +2,24 @@
<basic-user-card :user="user"> <basic-user-card :user="user">
<div class="follow-card-content-container"> <div class="follow-card-content-container">
<span <span
v-if="!noFollowsYou && user.follows_you" v-if="isMe || (!noFollowsYou && relationship.followed_by)"
class="faint" class="faint"
> >
{{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }} {{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }}
</span> </span>
<template v-if="!loggedIn"> <template v-if="!loggedIn">
<div <div
v-if="!user.following" v-if="!relationship.following"
class="follow-card-follow-button" class="follow-card-follow-button"
> >
<RemoteFollow :user="user" /> <RemoteFollow :user="user" />
</div> </div>
</template> </template>
<template v-else> <template v-else-if="!isMe">
<FollowButton <FollowButton
:user="user" :relationship="relationship"
class="follow-card-follow-button"
:label-following="$t('user_card.follow_unfollow')" :label-following="$t('user_card.follow_unfollow')"
class="follow-card-follow-button"
/> />
</template> </template>
</div> </div>

View File

@ -1,4 +1,5 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue' import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import { notificationsFromStore } from '../../services/notification_utils/notification_utils.js'
const FollowRequestCard = { const FollowRequestCard = {
props: ['user'], props: ['user'],
@ -6,13 +7,32 @@ const FollowRequestCard = {
BasicUserCard BasicUserCard
}, },
methods: { methods: {
findFollowRequestNotificationId () {
const notif = notificationsFromStore(this.$store).find(
(notif) => notif.from_profile.id === this.user.id && notif.type === 'follow_request'
)
return notif && notif.id
},
approveUser () { approveUser () {
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id }) this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user) this.$store.dispatch('removeFollowRequest', this.user)
const notifId = this.findFollowRequestNotificationId()
this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId })
this.$store.dispatch('updateNotification', {
id: notifId,
updater: notification => {
notification.type = 'follow'
}
})
}, },
denyUser () { denyUser () {
const notifId = this.findFollowRequestNotificationId()
this.$store.state.api.backendInteractor.denyUser({ id: this.user.id }) this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
.then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: notifId })
this.$store.dispatch('removeFollowRequest', this.user) this.$store.dispatch('removeFollowRequest', this.user)
})
} }
} }
} }

View File

@ -78,6 +78,7 @@
video, video,
canvas { canvas {
object-fit: contain; object-fit: contain;
height: 100%;
} }
} }

View File

@ -32,7 +32,7 @@ import _ from 'lodash'
export default { export default {
computed: { computed: {
languageCodes () { languageCodes () {
return Object.keys(languagesObject) return languagesObject.languages
}, },
languageNames () { languageNames () {
@ -43,7 +43,6 @@ export default {
get: function () { return this.$store.getters.mergedConfig.interfaceLanguage }, get: function () { return this.$store.getters.mergedConfig.interfaceLanguage },
set: function (val) { set: function (val) {
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val }) this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
this.$i18n.locale = val
} }
} }
}, },

View File

@ -84,10 +84,12 @@ const MediaModal = {
} }
}, },
mounted () { mounted () {
window.addEventListener('popstate', this.hide)
document.addEventListener('keyup', this.handleKeyupEvent) document.addEventListener('keyup', this.handleKeyupEvent)
document.addEventListener('keydown', this.handleKeydownEvent) document.addEventListener('keydown', this.handleKeydownEvent)
}, },
destroyed () { destroyed () {
window.removeEventListener('popstate', this.hide)
document.removeEventListener('keyup', this.handleKeyupEvent) document.removeEventListener('keyup', this.handleKeyupEvent)
document.removeEventListener('keydown', this.handleKeydownEvent) document.removeEventListener('keydown', this.handleKeydownEvent)
} }

View File

@ -5,10 +5,15 @@ import fileSizeFormatService from '../../services/file_size_format/file_size_for
const mediaUpload = { const mediaUpload = {
data () { data () {
return { return {
uploading: false, uploadCount: 0,
uploadReady: true uploadReady: true
} }
}, },
computed: {
uploading () {
return this.uploadCount > 0
}
},
methods: { methods: {
uploadFile (file) { uploadFile (file) {
const self = this const self = this
@ -23,29 +28,21 @@ const mediaUpload = {
formData.append('file', file) formData.append('file', file)
self.$emit('uploading') self.$emit('uploading')
self.uploading = true self.uploadCount++
statusPosterService.uploadMedia({ store, formData }) statusPosterService.uploadMedia({ store, formData })
.then((fileData) => { .then((fileData) => {
self.$emit('uploaded', fileData) self.$emit('uploaded', fileData)
self.uploading = false self.decreaseUploadCount()
}, (error) => { // eslint-disable-line handle-callback-err }, (error) => { // eslint-disable-line handle-callback-err
self.$emit('upload-failed', 'default') self.$emit('upload-failed', 'default')
self.uploading = false self.decreaseUploadCount()
}) })
}, },
fileDrop (e) { decreaseUploadCount () {
if (e.dataTransfer.files.length > 0) { this.uploadCount--
e.preventDefault() // allow dropping text like before if (this.uploadCount === 0) {
this.uploadFile(e.dataTransfer.files[0]) this.$emit('all-uploaded')
}
},
fileDrag (e) {
let types = e.dataTransfer.types
if (types.contains('Files')) {
e.dataTransfer.dropEffect = 'copy'
} else {
e.dataTransfer.dropEffect = 'none'
} }
}, },
clearFile () { clearFile () {
@ -54,11 +51,13 @@ const mediaUpload = {
this.uploadReady = true this.uploadReady = true
}) })
}, },
change ({ target }) { multiUpload (files) {
for (var i = 0; i < target.files.length; i++) { for (const file of files) {
let file = target.files[i]
this.uploadFile(file) this.uploadFile(file)
} }
},
change ({ target }) {
this.multiUpload(target.files)
} }
}, },
props: [ props: [
@ -67,7 +66,7 @@ const mediaUpload = {
watch: { watch: {
'dropFiles': function (fileInfos) { 'dropFiles': function (fileInfos) {
if (!this.uploading) { if (!this.uploading) {
this.uploadFile(fileInfos[0]) this.multiUpload(fileInfos)
} }
} }
} }

View File

@ -1,10 +1,5 @@
<template> <template>
<div <div class="media-upload">
class="media-upload"
@drop.prevent
@dragover.prevent="fileDrag"
@drop="fileDrop"
>
<label <label
class="label" class="label"
:title="$t('tool_tip.media_upload')" :title="$t('tool_tip.media_upload')"

View File

@ -1,8 +1,9 @@
<template> <template>
<div <div
v-show="isOpen" v-show="isOpen"
v-body-scroll-lock="isOpen" v-body-scroll-lock="isOpen && !noBackground"
class="modal-view" class="modal-view"
:class="classes"
@click.self="$emit('backdropClicked')" @click.self="$emit('backdropClicked')"
> >
<slot /> <slot />
@ -15,6 +16,18 @@ export default {
isOpen: { isOpen: {
type: Boolean, type: Boolean,
default: true default: true
},
noBackground: {
type: Boolean,
default: false
}
},
computed: {
classes () {
return {
'modal-background': !this.noBackground,
'open': this.isOpen
}
} }
} }
} }
@ -32,12 +45,22 @@ export default {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
overflow: auto; overflow: auto;
pointer-events: none;
animation-duration: 0.2s; animation-duration: 0.2s;
background-color: rgba(0, 0, 0, 0.5);
animation-name: modal-background-fadein; animation-name: modal-background-fadein;
body:not(.scroll-locked) & {
opacity: 0; opacity: 0;
> * {
pointer-events: initial;
}
&.modal-background {
pointer-events: initial;
background-color: rgba(0, 0, 0, 0.5);
}
&.open {
opacity: 1;
} }
} }

View File

@ -11,8 +11,11 @@ const MuteCard = {
user () { user () {
return this.$store.getters.findUser(this.userId) return this.$store.getters.findUser(this.userId)
}, },
relationship () {
return this.$store.getters.relationship(this.userId)
},
muted () { muted () {
return this.user.muted return this.relationship.muting
} }
}, },
components: { components: {
@ -21,13 +24,13 @@ const MuteCard = {
methods: { methods: {
unmuteUser () { unmuteUser () {
this.progress = true this.progress = true
this.$store.dispatch('unmuteUser', this.user.id).then(() => { this.$store.dispatch('unmuteUser', this.userId).then(() => {
this.progress = false this.progress = false
}) })
}, },
muteUser () { muteUser () {
this.progress = true this.progress = true
this.$store.dispatch('muteUser', this.user.id).then(() => { this.$store.dispatch('muteUser', this.userId).then(() => {
this.progress = false this.progress = false
}) })
} }

View File

@ -1,7 +1,9 @@
import StatusContent from '../status_content/status_content.vue'
import Status from '../status/status.vue' import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue' import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue' import Timeago from '../timeago/timeago.vue'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -15,10 +17,11 @@ const Notification = {
}, },
props: [ 'notification' ], props: [ 'notification' ],
components: { components: {
Status, StatusContent,
UserAvatar, UserAvatar,
UserCard, UserCard,
Timeago Timeago,
Status
}, },
methods: { methods: {
toggleUserExpanded () { toggleUserExpanded () {
@ -32,6 +35,24 @@ const Notification = {
}, },
toggleMute () { toggleMute () {
this.unmuted = !this.unmuted this.unmuted = !this.unmuted
},
approveUser () {
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
this.$store.dispatch('markSingleNotificationAsSeen', { id: this.notification.id })
this.$store.dispatch('updateNotification', {
id: this.notification.id,
updater: notification => {
notification.type = 'follow'
}
})
},
denyUser () {
this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
.then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: this.notification.id })
this.$store.dispatch('removeFollowRequest', this.user)
})
} }
}, },
computed: { computed: {
@ -56,7 +77,10 @@ const Notification = {
return this.generateUserProfileLink(this.targetUser) return this.generateUserProfileLink(this.targetUser)
}, },
needMute () { needMute () {
return this.user.muted return this.$store.getters.relationship(this.user.id).muting
},
isStatusNotification () {
return isStatusNotification(this.notification.type)
} }
} }
} }

View File

@ -40,14 +40,14 @@
<div class="notification-right"> <div class="notification-right">
<UserCard <UserCard
v-if="userExpanded" v-if="userExpanded"
:user="getUser(notification)" :user-id="getUser(notification).id"
:rounded="true" :rounded="true"
:bordered="true" :bordered="true"
/> />
<span class="notification-details"> <span class="notification-details">
<div class="name-and-action"> <div class="name-and-action">
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<span <bdi
v-if="!!notification.from_profile.name_html" v-if="!!notification.from_profile.name_html"
class="username" class="username"
:title="'@'+notification.from_profile.screen_name" :title="'@'+notification.from_profile.screen_name"
@ -74,6 +74,10 @@
<i class="fa icon-user-plus lit" /> <i class="fa icon-user-plus lit" />
<small>{{ $t('notifications.followed_you') }}</small> <small>{{ $t('notifications.followed_you') }}</small>
</span> </span>
<span v-if="notification.type === 'follow_request'">
<i class="fa icon-user lit" />
<small>{{ $t('notifications.follow_request') }}</small>
</span>
<span v-if="notification.type === 'move'"> <span v-if="notification.type === 'move'">
<i class="fa icon-arrow-curved lit" /> <i class="fa icon-arrow-curved lit" />
<small>{{ $t('notifications.migrated_to') }}</small> <small>{{ $t('notifications.migrated_to') }}</small>
@ -87,18 +91,7 @@
</span> </span>
</div> </div>
<div <div
v-if="notification.type === 'follow' || notification.type === 'move'" v-if="isStatusNotification"
class="timeago"
>
<span class="faint">
<Timeago
:time="notification.created_at"
:auto-update="240"
/>
</span>
</div>
<div
v-else
class="timeago" class="timeago"
> >
<router-link <router-link
@ -112,6 +105,17 @@
/> />
</router-link> </router-link>
</div> </div>
<div
v-else
class="timeago"
>
<span class="faint">
<Timeago
:time="notification.created_at"
:auto-update="240"
/>
</span>
</div>
<a <a
v-if="needMute" v-if="needMute"
href="#" href="#"
@ -119,12 +123,30 @@
><i class="button-icon icon-eye-off" /></a> ><i class="button-icon icon-eye-off" /></a>
</span> </span>
<div <div
v-if="notification.type === 'follow'" v-if="notification.type === 'follow' || notification.type === 'follow_request'"
class="follow-text" class="follow-text"
> >
<router-link :to="userProfileLink"> <router-link
:to="userProfileLink"
class="follow-name"
>
@{{ notification.from_profile.screen_name }} @{{ notification.from_profile.screen_name }}
</router-link> </router-link>
<div
v-if="notification.type === 'follow_request'"
style="white-space: nowrap;"
>
<i
class="icon-ok button-icon follow-request-accept"
:title="$t('tool_tip.accept_follow_request')"
@click="approveUser()"
/>
<i
class="icon-cancel button-icon follow-request-reject"
:title="$t('tool_tip.reject_follow_request')"
@click="denyUser()"
/>
</div>
</div> </div>
<div <div
v-else-if="notification.type === 'move'" v-else-if="notification.type === 'move'"
@ -135,11 +157,9 @@
</router-link> </router-link>
</div> </div>
<template v-else> <template v-else>
<status <status-content
class="faint" class="faint"
:compact="true" :status="notification.action"
:statusoid="notification.action"
:no-heading="true"
/> />
</template> </template>
</div> </div>

View File

@ -36,6 +36,8 @@
border-bottom: 1px solid; border-bottom: 1px solid;
border-color: $fallback--border; border-color: $fallback--border;
border-color: var(--border, $fallback--border); border-color: var(--border, $fallback--border);
word-wrap: break-word;
word-break: break-word;
&:hover .animated.avatar { &:hover .animated.avatar {
canvas { canvas {
@ -46,23 +48,19 @@
} }
} }
.muted {
padding: .25em .6em;
}
.non-mention { .non-mention {
display: flex; display: flex;
flex: 1; flex: 1;
flex-wrap: nowrap; flex-wrap: nowrap;
padding: 0.6em; padding: 0.6em;
min-width: 0; min-width: 0;
.avatar-container { .avatar-container {
width: 32px; width: 32px;
height: 32px; height: 32px;
} }
.status-el {
.status { .status-body {
padding: 0.25em 0;
color: $fallback--faint; color: $fallback--faint;
color: var(--faint, $fallback--faint); color: var(--faint, $fallback--faint);
a { a {
@ -72,15 +70,40 @@
color: var(--postFaintLink); color: var(--postFaintLink);
} }
} }
padding: 0;
.media-body {
margin: 0;
} }
.follow-request-accept {
cursor: pointer;
&:hover {
color: $fallback--text;
color: var(--text, $fallback--text);
} }
} }
.follow-request-reject {
cursor: pointer;
&:hover {
color: $fallback--cRed;
color: var(--cRed, $fallback--cRed);
}
}
.follow-text, .move-text { .follow-text, .move-text {
padding: 0.5em 0; padding: 0.5em 0;
overflow-wrap: break-word;
display: flex;
justify-content: space-between;
.follow-name {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
} }
.status-el { .status-el {
@ -142,6 +165,11 @@
color: var(--cGreen, $fallback--cGreen); color: var(--cGreen, $fallback--cGreen);
} }
.icon-user.lit {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
.icon-user-plus.lit { .icon-user-plus.lit {
color: $fallback--cBlue; color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue); color: var(--cBlue, $fallback--cBlue);

View File

@ -0,0 +1,29 @@
<template>
<div class="panel-loading">
<span class="loading-text">
<i class="icon-spin4 animate-spin" />
{{ $t('general.loading') }}
</span>
</div>
</template>
<style lang="scss">
@import 'src/_variables.scss';
.panel-loading {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
font-size: 2em;
color: $fallback--text;
color: var(--text, $fallback--text);
.loading-text i {
font-size: 3em;
line-height: 0;
vertical-align: middle;
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
</style>

View File

@ -1,4 +1,3 @@
const Popover = { const Popover = {
name: 'Popover', name: 'Popover',
props: { props: {
@ -10,6 +9,9 @@ const Popover = {
// 'container' for using offsetParent as boundaries for either axis // 'container' for using offsetParent as boundaries for either axis
// or 'viewport' // or 'viewport'
boundTo: Object, boundTo: Object,
// Takes a selector to use as a replacement for the parent container
// for getting boundaries for x an y axis
boundToSelector: String,
// Takes a top/bottom/left/right object, how much space to leave // Takes a top/bottom/left/right object, how much space to leave
// between boundary and popover element // between boundary and popover element
margin: Object, margin: Object,
@ -27,6 +29,10 @@ const Popover = {
} }
}, },
methods: { methods: {
containerBoundingClientRect () {
const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent
return container.getBoundingClientRect()
},
updateStyles () { updateStyles () {
if (this.hidden) { if (this.hidden) {
this.styles = { this.styles = {
@ -45,7 +51,8 @@ const Popover = {
// Minor optimization, don't call a slow reflow call if we don't have to // Minor optimization, don't call a slow reflow call if we don't have to
const parentBounds = this.boundTo && const parentBounds = this.boundTo &&
(this.boundTo.x === 'container' || this.boundTo.y === 'container') && (this.boundTo.x === 'container' || this.boundTo.y === 'container') &&
this.$el.offsetParent.getBoundingClientRect() this.containerBoundingClientRect()
const margin = this.margin || {} const margin = this.margin || {}
// What are the screen bounds for the popover? Viewport vs container // What are the screen bounds for the popover? Viewport vs container

View File

@ -82,7 +82,9 @@ const PostStatusForm = {
contentType contentType
}, },
caret: 0, caret: 0,
pollFormVisible: false pollFormVisible: false,
showDropIcon: 'hide',
dropStopTimeout: null
} }
}, },
computed: { computed: {
@ -102,7 +104,7 @@ const PostStatusForm = {
...this.$store.state.instance.customEmoji ...this.$store.state.instance.customEmoji
], ],
users: this.$store.state.users.users, users: this.$store.state.users.users,
updateUsersList: (input) => this.$store.dispatch('searchUsers', input) updateUsersList: (query) => this.$store.dispatch('searchUsers', { query })
}) })
}, },
emojiSuggestor () { emojiSuggestor () {
@ -218,7 +220,6 @@ const PostStatusForm = {
}, },
addMediaFile (fileInfo) { addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo) this.newStatus.files.push(fileInfo)
this.enableSubmit()
}, },
removeMediaFile (fileInfo) { removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo) let index = this.newStatus.files.indexOf(fileInfo)
@ -227,7 +228,6 @@ const PostStatusForm = {
uploadFailed (errString, templateArgs) { uploadFailed (errString, templateArgs) {
templateArgs = templateArgs || {} templateArgs = templateArgs || {}
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs) this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
this.enableSubmit()
}, },
disableSubmit () { disableSubmit () {
this.submitDisabled = true this.submitDisabled = true
@ -250,13 +250,27 @@ const PostStatusForm = {
} }
}, },
fileDrop (e) { fileDrop (e) {
if (e.dataTransfer.files.length > 0) { if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
e.preventDefault() // allow dropping text like before e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files this.dropFiles = e.dataTransfer.files
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'hide'
} }
}, },
fileDragStop (e) {
// The false-setting is done with delay because just using leave-events
// directly caused unwanted flickering, this is not perfect either but
// much less noticable.
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'fade'
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
},
fileDrag (e) { fileDrag (e) {
e.dataTransfer.dropEffect = 'copy' e.dataTransfer.dropEffect = 'copy'
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show'
}
}, },
onEmojiInputInput (e) { onEmojiInputInput (e) {
this.$nextTick(() => { this.$nextTick(() => {

View File

@ -6,7 +6,15 @@
<form <form
autocomplete="off" autocomplete="off"
@submit.prevent="postStatus(newStatus)" @submit.prevent="postStatus(newStatus)"
@dragover.prevent="fileDrag"
> >
<div
v-show="showDropIcon !== 'hide'"
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
class="drop-indicator icon-upload"
@dragleave="fileDragStop"
@drop.stop="fileDrop"
/>
<div class="form-group"> <div class="form-group">
<i18n <i18n
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'" v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'"
@ -73,6 +81,7 @@
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
type="text" type="text"
:placeholder="$t('post_status.content_warning')" :placeholder="$t('post_status.content_warning')"
:disabled="posting"
class="form-post-subject" class="form-post-subject"
> >
</EmojiInput> </EmojiInput>
@ -96,9 +105,7 @@
:disabled="posting" :disabled="posting"
class="form-post-body" class="form-post-body"
@keydown.meta.enter="postStatus(newStatus)" @keydown.meta.enter="postStatus(newStatus)"
@keyup.ctrl.enter="postStatus(newStatus)" @keydown.ctrl.enter="postStatus(newStatus)"
@drop="fileDrop"
@dragover.prevent="fileDrag"
@input="resize" @input="resize"
@compositionupdate="resize" @compositionupdate="resize"
@paste="paste" @paste="paste"
@ -172,6 +179,7 @@
@uploading="disableSubmit" @uploading="disableSubmit"
@uploaded="addMediaFile" @uploaded="addMediaFile"
@upload-failed="uploadFailed" @upload-failed="uploadFailed"
@all-uploaded="enableSubmit"
/> />
<div <div
class="emoji-icon" class="emoji-icon"
@ -446,7 +454,8 @@
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0.6em; margin: 0.6em;
position: relative;
} }
.form-group { .form-group {
@ -504,5 +513,35 @@
cursor: pointer; cursor: pointer;
z-index: 4; z-index: 4;
} }
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 0.6; }
}
@keyframes fade-out {
from { opacity: 0.6; }
to { opacity: 0; }
}
.drop-indicator {
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
font-size: 5em;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
color: $fallback--text;
color: var(--text, $fallback--text);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
border: 2px dashed $fallback--text;
border: 2px dashed var(--text, $fallback--text);
}
} }
</style> </style>

View File

@ -2,7 +2,7 @@ import Popover from '../popover/popover.vue'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
const ReactButton = { const ReactButton = {
props: ['status', 'loggedIn'], props: ['status'],
data () { data () {
return { return {
filterWord: '' filterWord: ''
@ -24,7 +24,7 @@ const ReactButton = {
}, },
computed: { computed: {
commonEmojis () { commonEmojis () {
return ['❤️', '😠', '👀', '😂', '🔥'] return ['👍', '😠', '👀', '😂', '🔥']
}, },
emojis () { emojis () {
if (this.filterWord !== '') { if (this.filterWord !== '') {

View File

@ -37,7 +37,6 @@
</div> </div>
</div> </div>
<i <i
v-if="loggedIn"
slot="trigger" slot="trigger"
class="icon-smile button-icon add-reaction-button" class="icon-smile button-icon add-reaction-button"
:title="$t('tool_tip.add_reaction')" :title="$t('tool_tip.add_reaction')"

View File

@ -1,5 +1,5 @@
import { validationMixin } from 'vuelidate' import { validationMixin } from 'vuelidate'
import { required, sameAs } from 'vuelidate/lib/validators' import { required, requiredIf, sameAs } from 'vuelidate/lib/validators'
import { mapActions, mapState } from 'vuex' import { mapActions, mapState } from 'vuex'
const registration = { const registration = {
@ -14,9 +14,10 @@ const registration = {
}, },
captcha: {} captcha: {}
}), }),
validations: { validations () {
return {
user: { user: {
email: { required }, email: { required: requiredIf(() => this.accountActivationRequired) },
username: { required }, username: { required },
fullname: { required }, fullname: { required },
password: { required }, password: { required },
@ -25,6 +26,7 @@ const registration = {
sameAsPassword: sameAs('password') sameAsPassword: sameAs('password')
} }
} }
}
}, },
created () { created () {
if ((!this.registrationOpen && !this.token) || this.signedIn) { if ((!this.registrationOpen && !this.token) || this.signedIn) {
@ -43,7 +45,8 @@ const registration = {
signedIn: (state) => !!state.users.currentUser, signedIn: (state) => !!state.users.currentUser,
isPending: (state) => state.users.signUpPending, isPending: (state) => state.users.signUpPending,
serverValidationErrors: (state) => state.users.signUpErrors, serverValidationErrors: (state) => state.users.signUpErrors,
termsOfService: (state) => state.instance.tos termsOfService: (state) => state.instance.tos,
accountActivationRequired: (state) => state.instance.accountActivationRequired
}) })
}, },
methods: { methods: {

View File

@ -187,6 +187,9 @@
class="form-control" class="form-control"
type="text" type="text"
autocomplete="off" autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
> >
</template> </template>
</div> </div>

View File

@ -1,128 +0,0 @@
/* eslint-env browser */
import { filter, trim } from 'lodash'
import TabSwitcher from '../tab_switcher/tab_switcher.js'
import StyleSwitcher from '../style_switcher/style_switcher.vue'
import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue'
import { extractCommit } from '../../services/version/version.service'
import { instanceDefaultProperties, defaultState as configDefaultState } from '../../modules/config.js'
import Checkbox from '../checkbox/checkbox.vue'
const pleromaFeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
const pleromaBeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma/commit/'
const multiChoiceProperties = [
'postContentType',
'subjectLineBehavior'
]
const settings = {
data () {
const instance = this.$store.state.instance
return {
loopSilentAvailable:
// Firefox
Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
// Chrome-likes
Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') ||
// Future spec, still not supported in Nightly 63 as of 08/2018
Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks'),
backendVersion: instance.backendVersion,
frontendVersion: instance.frontendVersion
}
},
components: {
TabSwitcher,
StyleSwitcher,
InterfaceLanguageSwitcher,
Checkbox
},
computed: {
user () {
return this.$store.state.users.currentUser
},
currentSaveStateNotice () {
return this.$store.state.interface.settings.currentSaveStateNotice
},
postFormats () {
return this.$store.state.instance.postFormats || []
},
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
frontendVersionLink () {
return pleromaFeCommitUrl + this.frontendVersion
},
backendVersionLink () {
return pleromaBeCommitUrl + extractCommit(this.backendVersion)
},
// Getting localized values for instance-default properties
...instanceDefaultProperties
.filter(key => multiChoiceProperties.includes(key))
.map(key => [
key + 'DefaultValue',
function () {
return this.$store.getters.instanceDefaultConfig[key]
}
])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
...instanceDefaultProperties
.filter(key => !multiChoiceProperties.includes(key))
.map(key => [
key + 'LocalizedValue',
function () {
return this.$t('settings.values.' + this.$store.getters.instanceDefaultConfig[key])
}
])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
// Generating computed values for vuex properties
...Object.keys(configDefaultState)
.map(key => [key, {
get () { return this.$store.getters.mergedConfig[key] },
set (value) {
this.$store.dispatch('setOption', { name: key, value })
}
}])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
// Special cases (need to transform values or perform actions first)
muteWordsString: {
get () { return this.$store.getters.mergedConfig.muteWords.join('\n') },
set (value) {
this.$store.dispatch('setOption', {
name: 'muteWords',
value: filter(value.split('\n'), (word) => trim(word).length > 0)
})
}
},
useStreamingApi: {
get () { return this.$store.getters.mergedConfig.useStreamingApi },
set (value) {
const promise = value
? this.$store.dispatch('enableMastoSockets')
: this.$store.dispatch('disableMastoSockets')
promise.then(() => {
this.$store.dispatch('setOption', { name: 'useStreamingApi', value })
}).catch((e) => {
console.error('Failed starting MastoAPI Streaming socket', e)
this.$store.dispatch('disableMastoSockets')
this.$store.dispatch('setOption', { name: 'useStreamingApi', value: false })
})
}
}
},
// Updating nested properties
watch: {
notificationVisibility: {
handler (value) {
this.$store.dispatch('setOption', {
name: 'notificationVisibility',
value: this.$store.getters.mergedConfig.notificationVisibility
})
},
deep: true
}
}
}
export default settings

View File

@ -1,424 +0,0 @@
<template>
<div class="settings panel panel-default">
<div class="panel-heading">
<div class="title">
{{ $t('settings.settings') }}
</div>
<transition name="fade">
<template v-if="currentSaveStateNotice">
<div
v-if="currentSaveStateNotice.error"
class="alert error"
@click.prevent
>
{{ $t('settings.saving_err') }}
</div>
<div
v-if="!currentSaveStateNotice.error"
class="alert transparent"
@click.prevent
>
{{ $t('settings.saving_ok') }}
</div>
</template>
</transition>
</div>
<div class="panel-body">
<keep-alive>
<tab-switcher>
<div :label="$t('settings.general')">
<div class="setting-item">
<h2>{{ $t('settings.interface') }}</h2>
<ul class="setting-list">
<li>
<interface-language-switcher />
</li>
<li v-if="instanceSpecificPanelPresent">
<Checkbox v-model="hideISP">
{{ $t('settings.hide_isp') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('nav.timeline') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="hideMutedPosts">
{{ $t('settings.hide_muted_posts') }} {{ $t('settings.instance_default', { value: hideMutedPostsLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="collapseMessageWithSubject">
{{ $t('settings.collapse_subject') }} {{ $t('settings.instance_default', { value: collapseMessageWithSubjectLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="streaming">
{{ $t('settings.streaming') }}
</Checkbox>
<ul
class="setting-list suboptions"
:class="[{disabled: !streaming}]"
>
<li>
<Checkbox
v-model="pauseOnUnfocused"
:disabled="!streaming"
>
{{ $t('settings.pause_on_unfocused') }}
</Checkbox>
</li>
</ul>
</li>
<li>
<Checkbox v-model="useStreamingApi">
{{ $t('settings.useStreamingApi') }}
<br>
<small>
{{ $t('settings.useStreamingApiWarning') }}
</small>
</Checkbox>
</li>
<li>
<Checkbox v-model="autoLoad">
{{ $t('settings.autoload') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="hoverPreview">
{{ $t('settings.reply_link_preview') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="emojiReactionsOnTimeline">
{{ $t('settings.emoji_reactions_on_timeline') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.composing') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="scopeCopy">
{{ $t('settings.scope_copy') }} {{ $t('settings.instance_default', { value: scopeCopyLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="alwaysShowSubjectInput">
{{ $t('settings.subject_input_always_show') }} {{ $t('settings.instance_default', { value: alwaysShowSubjectInputLocalizedValue }) }}
</Checkbox>
</li>
<li>
<div>
{{ $t('settings.subject_line_behavior') }}
<label
for="subjectLineBehavior"
class="select"
>
<select
id="subjectLineBehavior"
v-model="subjectLineBehavior"
>
<option value="email">
{{ $t('settings.subject_line_email') }}
{{ subjectLineBehaviorDefaultValue == 'email' ? $t('settings.instance_default_simple') : '' }}
</option>
<option value="masto">
{{ $t('settings.subject_line_mastodon') }}
{{ subjectLineBehaviorDefaultValue == 'mastodon' ? $t('settings.instance_default_simple') : '' }}
</option>
<option value="noop">
{{ $t('settings.subject_line_noop') }}
{{ subjectLineBehaviorDefaultValue == 'noop' ? $t('settings.instance_default_simple') : '' }}
</option>
</select>
<i class="icon-down-open" />
</label>
</div>
</li>
<li v-if="postFormats.length > 0">
<div>
{{ $t('settings.post_status_content_type') }}
<label
for="postContentType"
class="select"
>
<select
id="postContentType"
v-model="postContentType"
>
<option
v-for="postFormat in postFormats"
:key="postFormat"
:value="postFormat"
>
{{ $t(`post_status.content_type["${postFormat}"]`) }}
{{ postContentTypeDefaultValue === postFormat ? $t('settings.instance_default_simple') : '' }}
</option>
</select>
<i class="icon-down-open" />
</label>
</div>
</li>
<li>
<Checkbox v-model="minimalScopesMode">
{{ $t('settings.minimal_scopes_mode') }} {{ $t('settings.instance_default', { value: minimalScopesModeLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="autohideFloatingPostButton">
{{ $t('settings.autohide_floating_post_button') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="padEmoji">
{{ $t('settings.pad_emoji') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.attachments') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="hideAttachments">
{{ $t('settings.hide_attachments_in_tl') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="hideAttachmentsInConv">
{{ $t('settings.hide_attachments_in_convo') }}
</Checkbox>
</li>
<li>
<label for="maxThumbnails">
{{ $t('settings.max_thumbnails') }}
</label>
<input
id="maxThumbnails"
v-model.number="maxThumbnails"
class="number-input"
type="number"
min="0"
step="1"
>
</li>
<li>
<Checkbox v-model="hideNsfw">
{{ $t('settings.nsfw_clickthrough') }}
</Checkbox>
</li>
<ul class="setting-list suboptions">
<li>
<Checkbox
v-model="preloadImage"
:disabled="!hideNsfw"
>
{{ $t('settings.preload_images') }}
</Checkbox>
</li>
<li>
<Checkbox
v-model="useOneClickNsfw"
:disabled="!hideNsfw"
>
{{ $t('settings.use_one_click_nsfw') }}
</Checkbox>
</li>
</ul>
<li>
<Checkbox v-model="stopGifs">
{{ $t('settings.stop_gifs') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="loopVideo">
{{ $t('settings.loop_video') }}
</Checkbox>
<ul
class="setting-list suboptions"
:class="[{disabled: !streaming}]"
>
<li>
<Checkbox
v-model="loopVideoSilentOnly"
:disabled="!loopVideo || !loopSilentAvailable"
>
{{ $t('settings.loop_video_silent_only') }}
</Checkbox>
<div
v-if="!loopSilentAvailable"
class="unavailable"
>
<i class="icon-globe" />! {{ $t('settings.limited_availability') }}
</div>
</li>
</ul>
</li>
<li>
<Checkbox v-model="playVideosInModal">
{{ $t('settings.play_videos_in_modal') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="useContainFit">
{{ $t('settings.use_contain_fit') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.notifications') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="webPushNotifications">
{{ $t('settings.enable_web_push_notifications') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.fun') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="greentext">
{{ $t('settings.greentext') }} {{ $t('settings.instance_default', { value: greentextLocalizedValue }) }}
</Checkbox>
</li>
</ul>
</div>
</div>
<div :label="$t('settings.theme')">
<div class="setting-item">
<style-switcher />
</div>
</div>
<div :label="$t('settings.filtering')">
<div class="setting-item">
<div class="select-multiple">
<span class="label">{{ $t('settings.notification_visibility') }}</span>
<ul class="option-list">
<li>
<Checkbox v-model="notificationVisibility.likes">
{{ $t('settings.notification_visibility_likes') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.repeats">
{{ $t('settings.notification_visibility_repeats') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.follows">
{{ $t('settings.notification_visibility_follows') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.mentions">
{{ $t('settings.notification_visibility_mentions') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.moves">
{{ $t('settings.notification_visibility_moves') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.emojiReactions">
{{ $t('settings.notification_visibility_emoji_reactions') }}
</Checkbox>
</li>
</ul>
</div>
<div>
{{ $t('settings.replies_in_timeline') }}
<label
for="replyVisibility"
class="select"
>
<select
id="replyVisibility"
v-model="replyVisibility"
>
<option
value="all"
selected
>{{ $t('settings.reply_visibility_all') }}</option>
<option value="following">{{ $t('settings.reply_visibility_following') }}</option>
<option value="self">{{ $t('settings.reply_visibility_self') }}</option>
</select>
<i class="icon-down-open" />
</label>
</div>
<div>
<Checkbox v-model="hidePostStats">
{{ $t('settings.hide_post_stats') }} {{ $t('settings.instance_default', { value: hidePostStatsLocalizedValue }) }}
</Checkbox>
</div>
<div>
<Checkbox v-model="hideUserStats">
{{ $t('settings.hide_user_stats') }} {{ $t('settings.instance_default', { value: hideUserStatsLocalizedValue }) }}
</Checkbox>
</div>
</div>
<div class="setting-item">
<div>
<p>{{ $t('settings.filtering_explanation') }}</p>
<textarea
id="muteWords"
v-model="muteWordsString"
/>
</div>
<div>
<Checkbox v-model="hideFilteredStatuses">
{{ $t('settings.hide_filtered_statuses') }} {{ $t('settings.instance_default', { value: hideFilteredStatusesLocalizedValue }) }}
</Checkbox>
</div>
</div>
</div>
<div :label="$t('settings.version.title')">
<div class="setting-item">
<ul class="setting-list">
<li>
<p>{{ $t('settings.version.backend_version') }}</p>
<ul class="option-list">
<li>
<a
:href="backendVersionLink"
target="_blank"
>{{ backendVersion }}</a>
</li>
</ul>
</li>
<li>
<p>{{ $t('settings.version.frontend_version') }}</p>
<ul class="option-list">
<li>
<a
:href="frontendVersionLink"
target="_blank"
>{{ frontendVersion }}</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</tab-switcher>
</keep-alive>
</div>
</div>
</template>
<script src="./settings.js">
</script>

View File

@ -0,0 +1,58 @@
import {
instanceDefaultProperties,
multiChoiceProperties,
defaultState as configDefaultState
} from 'src/modules/config.js'
const SharedComputedObject = () => ({
user () {
return this.$store.state.users.currentUser
},
// Getting localized values for instance-default properties
...instanceDefaultProperties
.filter(key => multiChoiceProperties.includes(key))
.map(key => [
key + 'DefaultValue',
function () {
return this.$store.getters.instanceDefaultConfig[key]
}
])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
...instanceDefaultProperties
.filter(key => !multiChoiceProperties.includes(key))
.map(key => [
key + 'LocalizedValue',
function () {
return this.$t('settings.values.' + this.$store.getters.instanceDefaultConfig[key])
}
])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
// Generating computed values for vuex properties
...Object.keys(configDefaultState)
.map(key => [key, {
get () { return this.$store.getters.mergedConfig[key] },
set (value) {
this.$store.dispatch('setOption', { name: key, value })
}
}])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
// Special cases (need to transform values or perform actions first)
useStreamingApi: {
get () { return this.$store.getters.mergedConfig.useStreamingApi },
set (value) {
const promise = value
? this.$store.dispatch('enableMastoSockets')
: this.$store.dispatch('disableMastoSockets')
promise.then(() => {
this.$store.dispatch('setOption', { name: 'useStreamingApi', value })
}).catch((e) => {
console.error('Failed starting MastoAPI Streaming socket', e)
this.$store.dispatch('disableMastoSockets')
this.$store.dispatch('setOption', { name: 'useStreamingApi', value: false })
})
}
}
})
export default SharedComputedObject

View File

@ -0,0 +1,42 @@
import Modal from 'src/components/modal/modal.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue'
import getResettableAsyncComponent from 'src/services/resettable_async_component.js'
const SettingsModal = {
components: {
Modal,
SettingsModalContent: getResettableAsyncComponent(
() => import('./settings_modal_content.vue'),
{
loading: PanelLoading,
error: AsyncComponentError,
delay: 0
}
)
},
methods: {
closeModal () {
this.$store.dispatch('closeSettingsModal')
},
peekModal () {
this.$store.dispatch('togglePeekSettingsModal')
}
},
computed: {
currentSaveStateNotice () {
return this.$store.state.interface.settings.currentSaveStateNotice
},
modalActivated () {
return this.$store.state.interface.settingsModalState !== 'hidden'
},
modalOpenedOnce () {
return this.$store.state.interface.settingsModalLoaded
},
modalPeeked () {
return this.$store.state.interface.settingsModalState === 'minimized'
}
}
}
export default SettingsModal

View File

@ -0,0 +1,44 @@
@import 'src/_variables.scss';
.settings-modal {
overflow: hidden;
&.peek {
.settings-modal-panel {
/* Explanation:
* Modal is positioned vertically centered.
* 100vh - 100% = Distance between modal's top+bottom boundaries and screen
* (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen
* + 100% - we move modal completely off-screen, it's top boundary touches
* bottom of the screen
* - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible
*/
transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px));
}
}
.settings-modal-panel {
overflow: hidden;
transition: transform;
transition-timing-function: ease-in-out;
transition-duration: 300ms;
width: 1000px;
max-width: 90vw;
height: 90vh;
@media all and (max-width: 800px) {
max-width: 100vw;
height: 100vh;
}
.panel-body {
height: 100%;
overflow-y: hidden;
.btn {
min-height: 28px;
min-width: 10em;
padding: 0 2em;
}
}
}
}

View File

@ -0,0 +1,54 @@
<template>
<Modal
:is-open="modalActivated"
class="settings-modal"
:class="{ peek: modalPeeked }"
:no-background="modalPeeked"
>
<div class="settings-modal-panel panel">
<div class="panel-heading">
<span class="title">
{{ $t('settings.settings') }}
</span>
<transition name="fade">
<template v-if="currentSaveStateNotice">
<div
v-if="currentSaveStateNotice.error"
class="alert error"
@click.prevent
>
{{ $t('settings.saving_err') }}
</div>
<div
v-if="!currentSaveStateNotice.error"
class="alert transparent"
@click.prevent
>
{{ $t('settings.saving_ok') }}
</div>
</template>
</transition>
<button
class="btn"
@click="peekModal"
>
{{ $t('general.peek') }}
</button>
<button
class="btn"
@click="closeModal"
>
{{ $t('general.close') }}
</button>
</div>
<div class="panel-body">
<SettingsModalContent v-if="modalOpenedOnce" />
</div>
</div>
</Modal>
</template>
<script src="./settings_modal.js"></script>
<style src="./settings_modal.scss" lang="scss"></style>

View File

@ -0,0 +1,34 @@
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
import DataImportExportTab from './tabs/data_import_export_tab.vue'
import MutesAndBlocksTab from './tabs/mutes_and_blocks_tab.vue'
import NotificationsTab from './tabs/notifications_tab.vue'
import FilteringTab from './tabs/filtering_tab.vue'
import SecurityTab from './tabs/security_tab/security_tab.vue'
import ProfileTab from './tabs/profile_tab.vue'
import GeneralTab from './tabs/general_tab.vue'
import VersionTab from './tabs/version_tab.vue'
import ThemeTab from './tabs/theme_tab/theme_tab.vue'
const SettingsModalContent = {
components: {
TabSwitcher,
DataImportExportTab,
MutesAndBlocksTab,
NotificationsTab,
FilteringTab,
SecurityTab,
ProfileTab,
GeneralTab,
VersionTab,
ThemeTab
},
computed: {
isLoggedIn () {
return !!this.$store.state.users.currentUser
}
}
}
export default SettingsModalContent

View File

@ -0,0 +1,43 @@
@import 'src/_variables.scss';
.settings_tab-switcher {
height: 100%;
.setting-item {
border-bottom: 2px solid var(--fg, $fallback--fg);
margin: 1em 1em 1.4em;
padding-bottom: 1.4em;
> div {
margin-bottom: .5em;
&:last-child {
margin-bottom: 0;
}
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 1em;
}
select {
min-width: 10em;
}
textarea {
width: 100%;
max-width: 100%;
height: 100px;
}
.unavailable,
.unavailable i {
color: var(--cRed, $fallback--cRed);
color: $fallback--cRed;
}
.number-input {
max-width: 6em;
}
}
}

View File

@ -0,0 +1,73 @@
<template>
<tab-switcher
ref="tabSwitcher"
class="settings_tab-switcher"
:side-tab-bar="true"
:scrollable-tabs="true"
>
<div
:label="$t('settings.general')"
icon="wrench"
>
<GeneralTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.profile_tab')"
icon="user"
>
<ProfileTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.security_tab')"
icon="lock"
>
<SecurityTab />
</div>
<div
:label="$t('settings.filtering')"
icon="filter"
>
<FilteringTab />
</div>
<div
:label="$t('settings.theme')"
icon="brush"
>
<ThemeTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.notifications')"
icon="bell-ringing-o"
>
<NotificationsTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.data_import_export_tab')"
icon="download"
>
<DataImportExportTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.mutes_and_blocks')"
:fullHeight="true"
icon="eye-off"
>
<MutesAndBlocksTab />
</div>
<div
:label="$t('settings.version.title')"
icon="info-circled"
>
<VersionTab />
</div>
</tab-switcher>
</template>
<script src="./settings_modal_content.js"></script>
<style src="./settings_modal_content.scss" lang="scss"></style>

View File

@ -0,0 +1,65 @@
import Importer from 'src/components/importer/importer.vue'
import Exporter from 'src/components/exporter/exporter.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
const DataImportExportTab = {
data () {
return {
activeTab: 'profile',
newDomainToMute: ''
}
},
created () {
this.$store.dispatch('fetchTokens')
},
components: {
Importer,
Exporter,
Checkbox
},
computed: {
user () {
return this.$store.state.users.currentUser
}
},
methods: {
getFollowsContent () {
return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id })
.then(this.generateExportableUsersContent)
},
getBlocksContent () {
return this.$store.state.api.backendInteractor.fetchBlocks()
.then(this.generateExportableUsersContent)
},
importFollows (file) {
return this.$store.state.api.backendInteractor.importFollows({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
importBlocks (file) {
return this.$store.state.api.backendInteractor.importBlocks({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
generateExportableUsersContent (users) {
// Get addresses
return users.map((user) => {
// check is it's a local user
if (user && user.is_local) {
// append the instance address
// eslint-disable-next-line no-undef
return user.screen_name + '@' + location.hostname
}
return user.screen_name
}).join('\n')
}
}
}
export default DataImportExportTab

View File

@ -0,0 +1,43 @@
<template>
<div
:label="$t('settings.data_import_export_tab')"
>
<div class="setting-item">
<h2>{{ $t('settings.follow_import') }}</h2>
<p>{{ $t('settings.import_followers_from_a_csv_file') }}</p>
<Importer
:submit-handler="importFollows"
:success-message="$t('settings.follows_imported')"
:error-message="$t('settings.follow_import_error')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.follow_export') }}</h2>
<Exporter
:get-content="getFollowsContent"
filename="friends.csv"
:export-button-label="$t('settings.follow_export_button')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.block_import') }}</h2>
<p>{{ $t('settings.import_blocks_from_a_csv_file') }}</p>
<Importer
:submit-handler="importBlocks"
:success-message="$t('settings.blocks_imported')"
:error-message="$t('settings.block_import_error')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.block_export') }}</h2>
<Exporter
:get-content="getBlocksContent"
filename="blocks.csv"
:export-button-label="$t('settings.block_export_button')"
/>
</div>
</div>
</template>
<script src="./data_import_export_tab.js"></script>
<!-- <style lang="scss" src="./profile.scss"></style> -->

View File

@ -0,0 +1,44 @@
import { filter, trim } from 'lodash'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
const FilteringTab = {
data () {
return {
muteWordsStringLocal: this.$store.getters.mergedConfig.muteWords.join('\n')
}
},
components: {
Checkbox
},
computed: {
...SharedComputedObject(),
muteWordsString: {
get () {
return this.muteWordsStringLocal
},
set (value) {
this.muteWordsStringLocal = value
this.$store.dispatch('setOption', {
name: 'muteWords',
value: filter(value.split('\n'), (word) => trim(word).length > 0)
})
}
}
},
// Updating nested properties
watch: {
notificationVisibility: {
handler (value) {
this.$store.dispatch('setOption', {
name: 'notificationVisibility',
value: this.$store.getters.mergedConfig.notificationVisibility
})
},
deep: true
}
}
}
export default FilteringTab

View File

@ -0,0 +1,86 @@
<template>
<div :label="$t('settings.filtering')">
<div class="setting-item">
<div class="select-multiple">
<span class="label">{{ $t('settings.notification_visibility') }}</span>
<ul class="option-list">
<li>
<Checkbox v-model="notificationVisibility.likes">
{{ $t('settings.notification_visibility_likes') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.repeats">
{{ $t('settings.notification_visibility_repeats') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.follows">
{{ $t('settings.notification_visibility_follows') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.mentions">
{{ $t('settings.notification_visibility_mentions') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.moves">
{{ $t('settings.notification_visibility_moves') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.emojiReactions">
{{ $t('settings.notification_visibility_emoji_reactions') }}
</Checkbox>
</li>
</ul>
</div>
<div>
{{ $t('settings.replies_in_timeline') }}
<label
for="replyVisibility"
class="select"
>
<select
id="replyVisibility"
v-model="replyVisibility"
>
<option
value="all"
selected
>{{ $t('settings.reply_visibility_all') }}</option>
<option value="following">{{ $t('settings.reply_visibility_following') }}</option>
<option value="self">{{ $t('settings.reply_visibility_self') }}</option>
</select>
<i class="icon-down-open" />
</label>
</div>
<div>
<Checkbox v-model="hidePostStats">
{{ $t('settings.hide_post_stats') }} {{ $t('settings.instance_default', { value: hidePostStatsLocalizedValue }) }}
</Checkbox>
</div>
<div>
<Checkbox v-model="hideUserStats">
{{ $t('settings.hide_user_stats') }} {{ $t('settings.instance_default', { value: hideUserStatsLocalizedValue }) }}
</Checkbox>
</div>
</div>
<div class="setting-item">
<div>
<p>{{ $t('settings.filtering_explanation') }}</p>
<textarea
id="muteWords"
v-model="muteWordsString"
/>
</div>
<div>
<Checkbox v-model="hideFilteredStatuses">
{{ $t('settings.hide_filtered_statuses') }} {{ $t('settings.instance_default', { value: hideFilteredStatusesLocalizedValue }) }}
</Checkbox>
</div>
</div>
</div>
</template>
<script src="./filtering_tab.js"></script>

View File

@ -0,0 +1,31 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
const GeneralTab = {
data () {
return {
loopSilentAvailable:
// Firefox
Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
// Chrome-likes
Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') ||
// Future spec, still not supported in Nightly 63 as of 08/2018
Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks')
}
},
components: {
Checkbox,
InterfaceLanguageSwitcher
},
computed: {
postFormats () {
return this.$store.state.instance.postFormats || []
},
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
...SharedComputedObject()
}
}
export default GeneralTab

View File

@ -0,0 +1,272 @@
<template>
<div :label="$t('settings.general')">
<div class="setting-item">
<h2>{{ $t('settings.interface') }}</h2>
<ul class="setting-list">
<li>
<interface-language-switcher />
</li>
<li v-if="instanceSpecificPanelPresent">
<Checkbox v-model="hideISP">
{{ $t('settings.hide_isp') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('nav.timeline') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="hideMutedPosts">
{{ $t('settings.hide_muted_posts') }} {{ $t('settings.instance_default', { value: hideMutedPostsLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="collapseMessageWithSubject">
{{ $t('settings.collapse_subject') }} {{ $t('settings.instance_default', { value: collapseMessageWithSubjectLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="streaming">
{{ $t('settings.streaming') }}
</Checkbox>
<ul
class="setting-list suboptions"
:class="[{disabled: !streaming}]"
>
<li>
<Checkbox
v-model="pauseOnUnfocused"
:disabled="!streaming"
>
{{ $t('settings.pause_on_unfocused') }}
</Checkbox>
</li>
</ul>
</li>
<li>
<Checkbox v-model="useStreamingApi">
{{ $t('settings.useStreamingApi') }}
<br>
<small>
{{ $t('settings.useStreamingApiWarning') }}
</small>
</Checkbox>
</li>
<li>
<Checkbox v-model="autoLoad">
{{ $t('settings.autoload') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="hoverPreview">
{{ $t('settings.reply_link_preview') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="emojiReactionsOnTimeline">
{{ $t('settings.emoji_reactions_on_timeline') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.composing') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="scopeCopy">
{{ $t('settings.scope_copy') }} {{ $t('settings.instance_default', { value: scopeCopyLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="alwaysShowSubjectInput">
{{ $t('settings.subject_input_always_show') }} {{ $t('settings.instance_default', { value: alwaysShowSubjectInputLocalizedValue }) }}
</Checkbox>
</li>
<li>
<div>
{{ $t('settings.subject_line_behavior') }}
<label
for="subjectLineBehavior"
class="select"
>
<select
id="subjectLineBehavior"
v-model="subjectLineBehavior"
>
<option value="email">
{{ $t('settings.subject_line_email') }}
{{ subjectLineBehaviorDefaultValue == 'email' ? $t('settings.instance_default_simple') : '' }}
</option>
<option value="masto">
{{ $t('settings.subject_line_mastodon') }}
{{ subjectLineBehaviorDefaultValue == 'mastodon' ? $t('settings.instance_default_simple') : '' }}
</option>
<option value="noop">
{{ $t('settings.subject_line_noop') }}
{{ subjectLineBehaviorDefaultValue == 'noop' ? $t('settings.instance_default_simple') : '' }}
</option>
</select>
<i class="icon-down-open" />
</label>
</div>
</li>
<li v-if="postFormats.length > 0">
<div>
{{ $t('settings.post_status_content_type') }}
<label
for="postContentType"
class="select"
>
<select
id="postContentType"
v-model="postContentType"
>
<option
v-for="postFormat in postFormats"
:key="postFormat"
:value="postFormat"
>
{{ $t(`post_status.content_type["${postFormat}"]`) }}
{{ postContentTypeDefaultValue === postFormat ? $t('settings.instance_default_simple') : '' }}
</option>
</select>
<i class="icon-down-open" />
</label>
</div>
</li>
<li>
<Checkbox v-model="minimalScopesMode">
{{ $t('settings.minimal_scopes_mode') }} {{ $t('settings.instance_default', { value: minimalScopesModeLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="autohideFloatingPostButton">
{{ $t('settings.autohide_floating_post_button') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="padEmoji">
{{ $t('settings.pad_emoji') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.attachments') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="hideAttachments">
{{ $t('settings.hide_attachments_in_tl') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="hideAttachmentsInConv">
{{ $t('settings.hide_attachments_in_convo') }}
</Checkbox>
</li>
<li>
<label for="maxThumbnails">
{{ $t('settings.max_thumbnails') }}
</label>
<input
id="maxThumbnails"
v-model.number="maxThumbnails"
class="number-input"
type="number"
min="0"
step="1"
>
</li>
<li>
<Checkbox v-model="hideNsfw">
{{ $t('settings.nsfw_clickthrough') }}
</Checkbox>
</li>
<ul class="setting-list suboptions">
<li>
<Checkbox
v-model="preloadImage"
:disabled="!hideNsfw"
>
{{ $t('settings.preload_images') }}
</Checkbox>
</li>
<li>
<Checkbox
v-model="useOneClickNsfw"
:disabled="!hideNsfw"
>
{{ $t('settings.use_one_click_nsfw') }}
</Checkbox>
</li>
</ul>
<li>
<Checkbox v-model="stopGifs">
{{ $t('settings.stop_gifs') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="loopVideo">
{{ $t('settings.loop_video') }}
</Checkbox>
<ul
class="setting-list suboptions"
:class="[{disabled: !streaming}]"
>
<li>
<Checkbox
v-model="loopVideoSilentOnly"
:disabled="!loopVideo || !loopSilentAvailable"
>
{{ $t('settings.loop_video_silent_only') }}
</Checkbox>
<div
v-if="!loopSilentAvailable"
class="unavailable"
>
<i class="icon-globe" />! {{ $t('settings.limited_availability') }}
</div>
</li>
</ul>
</li>
<li>
<Checkbox v-model="playVideosInModal">
{{ $t('settings.play_videos_in_modal') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="useContainFit">
{{ $t('settings.use_contain_fit') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.notifications') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="webPushNotifications">
{{ $t('settings.enable_web_push_notifications') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.fun') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="greentext">
{{ $t('settings.greentext') }} {{ $t('settings.instance_default', { value: greentextLocalizedValue }) }}
</Checkbox>
</li>
</ul>
</div>
</div>
</template>
<script src="./general_tab.js"></script>

View File

@ -0,0 +1,136 @@
import get from 'lodash/get'
import map from 'lodash/map'
import reject from 'lodash/reject'
import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
import BlockCard from 'src/components/block_card/block_card.vue'
import MuteCard from 'src/components/mute_card/mute_card.vue'
import DomainMuteCard from 'src/components/domain_mute_card/domain_mute_card.vue'
import SelectableList from 'src/components/selectable_list/selectable_list.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import withSubscription from 'src/components/../hocs/with_subscription/with_subscription'
import Checkbox from 'src/components/checkbox/checkbox.vue'
const BlockList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchBlocks'),
select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []),
childPropName: 'items'
})(SelectableList)
const MuteList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []),
childPropName: 'items'
})(SelectableList)
const DomainMuteList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchDomainMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'domainMutes', []),
childPropName: 'items'
})(SelectableList)
const MutesAndBlocks = {
data () {
return {
activeTab: 'profile'
}
},
created () {
this.$store.dispatch('fetchTokens')
this.$store.dispatch('getKnownDomains')
},
components: {
TabSwitcher,
BlockList,
MuteList,
DomainMuteList,
BlockCard,
MuteCard,
DomainMuteCard,
ProgressButton,
Autosuggest,
Checkbox
},
computed: {
knownDomains () {
return this.$store.state.instance.knownDomains
},
user () {
return this.$store.state.users.currentUser
}
},
methods: {
importFollows (file) {
return this.$store.state.api.backendInteractor.importFollows({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
importBlocks (file) {
return this.$store.state.api.backendInteractor.importBlocks({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
generateExportableUsersContent (users) {
// Get addresses
return users.map((user) => {
// check is it's a local user
if (user && user.is_local) {
// append the instance address
// eslint-disable-next-line no-undef
return user.screen_name + '@' + location.hostname
}
return user.screen_name
}).join('\n')
},
activateTab (tabName) {
this.activeTab = tabName
},
filterUnblockedUsers (userIds) {
return reject(userIds, (userId) => {
const relationship = this.$store.getters.relationship(this.userId)
return relationship.blocking || userId === this.user.id
})
},
filterUnMutedUsers (userIds) {
return reject(userIds, (userId) => {
const relationship = this.$store.getters.relationship(this.userId)
return relationship.muting || userId === this.user.id
})
},
queryUserIds (query) {
return this.$store.dispatch('searchUsers', { query })
.then((users) => map(users, 'id'))
},
blockUsers (ids) {
return this.$store.dispatch('blockUsers', ids)
},
unblockUsers (ids) {
return this.$store.dispatch('unblockUsers', ids)
},
muteUsers (ids) {
return this.$store.dispatch('muteUsers', ids)
},
unmuteUsers (ids) {
return this.$store.dispatch('unmuteUsers', ids)
},
filterUnMutedDomains (urls) {
return urls.filter(url => !this.user.domainMutes.includes(url))
},
queryKnownDomains (query) {
return new Promise((resolve, reject) => {
resolve(this.knownDomains.filter(url => url.toLowerCase().includes(query)))
})
},
unmuteDomains (domains) {
return this.$store.dispatch('unmuteDomains', domains)
}
}
}
export default MutesAndBlocks

View File

@ -0,0 +1,29 @@
.mutes-and-blocks-tab {
height: 100%;
.usersearch-wrapper {
padding: 1em;
}
.bulk-actions {
text-align: right;
padding: 0 1em;
min-height: 28px;
}
.bulk-action-button {
width: 10em
}
.domain-mute-form {
padding: 1em;
display: flex;
flex-direction: column
}
.domain-mute-button {
align-self: flex-end;
margin-top: 1em;
width: 10em
}
}

View File

@ -0,0 +1,171 @@
<template>
<tab-switcher
:scrollable-tabs="true"
class="mutes-and-blocks-tab"
>
<div :label="$t('settings.blocks_tab')">
<div class="usersearch-wrapper">
<Autosuggest
:filter="filterUnblockedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_block')"
>
<BlockCard
slot-scope="row"
:user-id="row.item"
/>
</Autosuggest>
</div>
<BlockList
:refresh="true"
:get-key="i => i"
>
<template
slot="header"
slot-scope="{selected}"
>
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default bulk-action-button"
:click="() => blockUsers(selected)"
>
{{ $t('user_card.block') }}
<template slot="progress">
{{ $t('user_card.block_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => unblockUsers(selected)"
>
{{ $t('user_card.unblock') }}
<template slot="progress">
{{ $t('user_card.unblock_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template
slot="item"
slot-scope="{item}"
>
<BlockCard :user-id="item" />
</template>
<template slot="empty">
{{ $t('settings.no_blocks') }}
</template>
</BlockList>
</div>
<div :label="$t('settings.mutes_tab')">
<tab-switcher>
<div label="Users">
<div class="usersearch-wrapper">
<Autosuggest
:filter="filterUnMutedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')"
>
<MuteCard
slot-scope="row"
:user-id="row.item"
/>
</Autosuggest>
</div>
<MuteList
:refresh="true"
:get-key="i => i"
>
<template
slot="header"
slot-scope="{selected}"
>
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => muteUsers(selected)"
>
{{ $t('user_card.mute') }}
<template slot="progress">
{{ $t('user_card.mute_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => unmuteUsers(selected)"
>
{{ $t('user_card.unmute') }}
<template slot="progress">
{{ $t('user_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template
slot="item"
slot-scope="{item}"
>
<MuteCard :user-id="item" />
</template>
<template slot="empty">
{{ $t('settings.no_mutes') }}
</template>
</MuteList>
</div>
<div :label="$t('settings.domain_mutes')">
<div class="domain-mute-form">
<Autosuggest
:filter="filterUnMutedDomains"
:query="queryKnownDomains"
:placeholder="$t('settings.type_domains_to_mute')"
>
<DomainMuteCard
slot-scope="row"
:domain="row.item"
/>
</Autosuggest>
</div>
<DomainMuteList
:refresh="true"
:get-key="i => i"
>
<template
slot="header"
slot-scope="{selected}"
>
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => unmuteDomains(selected)"
>
{{ $t('domain_mute_card.unmute') }}
<template slot="progress">
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template
slot="item"
slot-scope="{item}"
>
<DomainMuteCard :domain="item" />
</template>
<template slot="empty">
{{ $t('settings.no_mutes') }}
</template>
</DomainMuteList>
</div>
</tab-switcher>
</div>
</tab-switcher>
</template>
<script src="./mutes_and_blocks_tab.js"></script>
<style lang="scss" src="./mutes_and_blocks_tab.scss"></style>

View File

@ -0,0 +1,27 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
const NotificationsTab = {
data () {
return {
activeTab: 'profile',
notificationSettings: this.$store.state.users.currentUser.notification_settings,
newDomainToMute: ''
}
},
components: {
Checkbox
},
computed: {
user () {
return this.$store.state.users.currentUser
}
},
methods: {
updateNotificationSettings () {
this.$store.state.api.backendInteractor
.updateNotificationSettings({ settings: this.notificationSettings })
}
}
}
export default NotificationsTab

View File

@ -0,0 +1,54 @@
<template>
<div :label="$t('settings.notifications')">
<div class="setting-item">
<h2>{{ $t('settings.notification_setting_filters') }}</h2>
<div class="select-multiple">
<span class="label">{{ $t('settings.notification_setting') }}</span>
<ul class="option-list">
<li>
<Checkbox v-model="notificationSettings.follows">
{{ $t('settings.notification_setting_follows') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationSettings.followers">
{{ $t('settings.notification_setting_followers') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationSettings.non_follows">
{{ $t('settings.notification_setting_non_follows') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationSettings.non_followers">
{{ $t('settings.notification_setting_non_followers') }}
</Checkbox>
</li>
</ul>
</div>
</div>
<div class="setting-item">
<h2>{{ $t('settings.notification_setting_privacy') }}</h2>
<p>
<Checkbox v-model="notificationSettings.privacy_option">
{{ $t('settings.notification_setting_privacy_option') }}
</Checkbox>
</p>
</div>
<div class="setting-item">
<p>{{ $t('settings.notification_mutes') }}</p>
<p>{{ $t('settings.notification_blocks') }}</p>
<button
class="btn btn-default"
@click="updateNotificationSettings"
>
{{ $t('general.submit') }}
</button>
</div>
</div>
</template>
<script src="./notifications_tab.js"></script>
<!-- <style lang="scss" src="./profile.scss"></style> -->

View File

@ -0,0 +1,179 @@
import unescape from 'lodash/unescape'
import ImageCropper from 'src/components/image_cropper/image_cropper.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import suggestor from 'src/components/emoji_input/suggestor.js'
import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
const ProfileTab = {
data () {
return {
newName: this.$store.state.users.currentUser.name,
newBio: unescape(this.$store.state.users.currentUser.description),
newLocked: this.$store.state.users.currentUser.locked,
newNoRichText: this.$store.state.users.currentUser.no_rich_text,
newDefaultScope: this.$store.state.users.currentUser.default_scope,
hideFollows: this.$store.state.users.currentUser.hide_follows,
hideFollowers: this.$store.state.users.currentUser.hide_followers,
hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
hideFollowersCount: this.$store.state.users.currentUser.hide_followers_count,
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
discoverable: this.$store.state.users.currentUser.discoverable,
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
pickAvatarBtnVisible: true,
bannerUploading: false,
backgroundUploading: false,
banner: null,
bannerPreview: null,
background: null,
backgroundPreview: null,
bannerUploadError: null,
backgroundUploadError: null
}
},
components: {
ScopeSelector,
ImageCropper,
EmojiInput,
Autosuggest,
ProgressButton,
Checkbox
},
computed: {
user () {
return this.$store.state.users.currentUser
},
emojiUserSuggestor () {
return suggestor({
emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
],
users: this.$store.state.users.users,
updateUsersList: (query) => this.$store.dispatch('searchUsers', { query })
})
},
emojiSuggestor () {
return suggestor({ emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
] })
}
},
methods: {
updateProfile () {
this.$store.state.api.backendInteractor
.updateProfile({
params: {
note: this.newBio,
locked: this.newLocked,
// Backend notation.
/* eslint-disable camelcase */
display_name: this.newName,
default_scope: this.newDefaultScope,
no_rich_text: this.newNoRichText,
hide_follows: this.hideFollows,
hide_followers: this.hideFollowers,
discoverable: this.discoverable,
allow_following_move: this.allowFollowingMove,
hide_follows_count: this.hideFollowsCount,
hide_followers_count: this.hideFollowersCount,
show_role: this.showRole
/* eslint-enable camelcase */
} }).then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
changeVis (visibility) {
this.newDefaultScope = visibility
},
uploadFile (slot, e) {
const file = e.target.files[0]
if (!file) { return }
if (file.size > this.$store.state.instance[slot + 'limit']) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit'])
this[slot + 'UploadError'] = [
this.$t('upload.error.base'),
this.$t(
'upload.error.file_too_big',
{
filesize: filesize.num,
filesizeunit: filesize.unit,
allowedsize: allowedsize.num,
allowedsizeunit: allowedsize.unit
}
)
].join(' ')
return
}
// eslint-disable-next-line no-undef
const reader = new FileReader()
reader.onload = ({ target }) => {
const img = target.result
this[slot + 'Preview'] = img
this[slot] = file
}
reader.readAsDataURL(file)
},
submitAvatar (cropper, file) {
const that = this
return new Promise((resolve, reject) => {
function updateAvatar (avatar) {
that.$store.state.api.backendInteractor.updateAvatar({ avatar })
.then((user) => {
that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user)
resolve()
})
.catch((err) => {
reject(new Error(that.$t('upload.error.base') + ' ' + err.message))
})
}
if (cropper) {
cropper.getCroppedCanvas().toBlob(updateAvatar, file.type)
} else {
updateAvatar(file)
}
})
},
submitBanner () {
if (!this.bannerPreview) { return }
this.bannerUploading = true
this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.bannerPreview = null
})
.catch((err) => {
this.bannerUploadError = this.$t('upload.error.base') + ' ' + err.message
})
.then(() => { this.bannerUploading = false })
},
submitBg () {
if (!this.backgroundPreview) { return }
let background = this.background
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => {
if (!data.error) {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
} else {
this.backgroundUploadError = this.$t('upload.error.base') + data.error
}
this.backgroundUploading = false
})
}
}
}
export default ProfileTab

View File

@ -0,0 +1,82 @@
@import '../../../_variables.scss';
.profile-tab {
.bio {
margin: 0;
}
.visibility-tray {
padding-top: 5px;
}
input[type=file] {
padding: 5px;
height: auto;
}
.banner {
max-width: 100%;
}
.uploading {
font-size: 1.5em;
margin: 0.25em;
}
.name-changer {
width: 100%;
}
.bg {
max-width: 100%;
}
.current-avatar {
display: block;
width: 150px;
height: 150px;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
}
.oauth-tokens {
width: 100%;
th {
text-align: left;
}
.actions {
text-align: right;
}
}
&-usersearch-wrapper {
padding: 1em;
}
&-bulk-actions {
text-align: right;
padding: 0 1em;
min-height: 28px;
button {
width: 10em;
}
}
&-domain-mute-form {
padding: 1em;
display: flex;
flex-direction: column;
button {
align-self: flex-end;
margin-top: 1em;
width: 10em;
}
}
.setting-subitem {
margin-left: 1.75em;
}
}

View File

@ -0,0 +1,213 @@
<template>
<div class="profile-tab">
<div class="setting-item">
<h2>{{ $t('settings.name_bio') }}</h2>
<p>{{ $t('settings.name') }}</p>
<EmojiInput
v-model="newName"
enable-emoji-picker
:suggest="emojiSuggestor"
>
<input
id="username"
v-model="newName"
classname="name-changer"
>
</EmojiInput>
<p>{{ $t('settings.bio') }}</p>
<EmojiInput
v-model="newBio"
enable-emoji-picker
:suggest="emojiUserSuggestor"
>
<textarea
v-model="newBio"
classname="bio"
/>
</EmojiInput>
<p>
<Checkbox v-model="newLocked">
{{ $t('settings.lock_account_description') }}
</Checkbox>
</p>
<div>
<label for="default-vis">{{ $t('settings.default_vis') }}</label>
<div
id="default-vis"
class="visibility-tray"
>
<scope-selector
:show-all="true"
:user-default="newDefaultScope"
:initial-scope="newDefaultScope"
:on-scope-change="changeVis"
/>
</div>
</div>
<p>
<Checkbox v-model="newNoRichText">
{{ $t('settings.no_rich_text_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="hideFollows">
{{ $t('settings.hide_follows_description') }}
</Checkbox>
</p>
<p class="setting-subitem">
<Checkbox
v-model="hideFollowsCount"
:disabled="!hideFollows"
>
{{ $t('settings.hide_follows_count_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="hideFollowers">
{{ $t('settings.hide_followers_description') }}
</Checkbox>
</p>
<p class="setting-subitem">
<Checkbox
v-model="hideFollowersCount"
:disabled="!hideFollowers"
>
{{ $t('settings.hide_followers_count_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="allowFollowingMove">
{{ $t('settings.allow_following_move') }}
</Checkbox>
</p>
<p v-if="role === 'admin' || role === 'moderator'">
<Checkbox v-model="showRole">
<template v-if="role === 'admin'">
{{ $t('settings.show_admin_badge') }}
</template>
<template v-if="role === 'moderator'">
{{ $t('settings.show_moderator_badge') }}
</template>
</Checkbox>
</p>
<p>
<Checkbox v-model="discoverable">
{{ $t('settings.discoverable') }}
</Checkbox>
</p>
<button
:disabled="newName && newName.length === 0"
class="btn btn-default"
@click="updateProfile"
>
{{ $t('general.submit') }}
</button>
</div>
<div class="setting-item">
<h2>{{ $t('settings.avatar') }}</h2>
<p class="visibility-notice">
{{ $t('settings.avatar_size_instruction') }}
</p>
<p>{{ $t('settings.current_avatar') }}</p>
<img
:src="user.profile_image_url_original"
class="current-avatar"
>
<p>{{ $t('settings.set_new_avatar') }}</p>
<button
v-show="pickAvatarBtnVisible"
id="pick-avatar"
class="btn"
type="button"
>
{{ $t('settings.upload_a_photo') }}
</button>
<image-cropper
trigger="#pick-avatar"
:submit-handler="submitAvatar"
@open="pickAvatarBtnVisible=false"
@close="pickAvatarBtnVisible=true"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.profile_banner') }}</h2>
<p>{{ $t('settings.current_profile_banner') }}</p>
<img
:src="user.cover_photo"
class="banner"
>
<p>{{ $t('settings.set_new_profile_banner') }}</p>
<img
v-if="bannerPreview"
class="banner"
:src="bannerPreview"
>
<div>
<input
type="file"
@change="uploadFile('banner', $event)"
>
</div>
<i
v-if="bannerUploading"
class=" icon-spin4 animate-spin uploading"
/>
<button
v-else-if="bannerPreview"
class="btn btn-default"
@click="submitBanner"
>
{{ $t('general.submit') }}
</button>
<div
v-if="bannerUploadError"
class="alert error"
>
Error: {{ bannerUploadError }}
<i
class="button-icon icon-cancel"
@click="clearUploadError('banner')"
/>
</div>
</div>
<div class="setting-item">
<h2>{{ $t('settings.profile_background') }}</h2>
<p>{{ $t('settings.set_new_profile_background') }}</p>
<img
v-if="backgroundPreview"
class="bg"
:src="backgroundPreview"
>
<div>
<input
type="file"
@change="uploadFile('background', $event)"
>
</div>
<i
v-if="backgroundUploading"
class=" icon-spin4 animate-spin uploading"
/>
<button
v-else-if="backgroundPreview"
class="btn btn-default"
@click="submitBg"
>
{{ $t('general.submit') }}
</button>
<div
v-if="backgroundUploadError"
class="alert error"
>
Error: {{ backgroundUploadError }}
<i
class="button-icon icon-cancel"
@click="clearUploadError('background')"
/>
</div>
</div>
</div>
</template>
<script src="./profile_tab.js"></script>
<style lang="scss" src="./profile_tab.scss"></style>

View File

@ -137,20 +137,20 @@
<script src="./mfa.js"></script> <script src="./mfa.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../../../_variables.scss';
.warning {
color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange);
}
.mfa-settings { .mfa-settings {
.mfa-heading, .method-item { .mfa-heading, .method-item {
overflow: hidden;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
} }
.warning {
color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange);
}
.setup-otp { .setup-otp {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@ -1,5 +1,5 @@
<template> <template>
<div> <div class="mfa-backup-codes">
<h4 v-if="displayTitle"> <h4 v-if="displayTitle">
{{ $t('settings.mfa.recovery_codes') }} {{ $t('settings.mfa.recovery_codes') }}
</h4> </h4>
@ -21,13 +21,15 @@
</template> </template>
<script src="./mfa_backup_codes.js"></script> <script src="./mfa_backup_codes.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../../../_variables.scss';
.warning { .mfa-backup-codes {
.warning {
color: $fallback--cOrange; color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange); color: var(--cOrange, $fallback--cOrange);
} }
.backup-codes { .backup-codes {
font-family: var(--postCodeFont, monospace); font-family: var(--postCodeFont, monospace);
}
} }
</style> </style>

View File

@ -0,0 +1,106 @@
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Mfa from './mfa.vue'
const SecurityTab = {
data () {
return {
newEmail: '',
changeEmailError: false,
changeEmailPassword: '',
changedEmail: false,
deletingAccount: false,
deleteAccountConfirmPasswordInput: '',
deleteAccountError: false,
changePasswordInputs: [ '', '', '' ],
changedPassword: false,
changePasswordError: false
}
},
created () {
this.$store.dispatch('fetchTokens')
},
components: {
ProgressButton,
Mfa,
Checkbox
},
computed: {
user () {
return this.$store.state.users.currentUser
},
pleromaBackend () {
return this.$store.state.instance.pleromaBackend
},
oauthTokens () {
return this.$store.state.oauthTokens.tokens.map(oauthToken => {
return {
id: oauthToken.id,
appName: oauthToken.app_name,
validUntil: new Date(oauthToken.valid_until).toLocaleDateString()
}
})
}
},
methods: {
confirmDelete () {
this.deletingAccount = true
},
deleteAccount () {
this.$store.state.api.backendInteractor.deleteAccount({ password: this.deleteAccountConfirmPasswordInput })
.then((res) => {
if (res.status === 'success') {
this.$store.dispatch('logout')
this.$router.push({ name: 'root' })
} else {
this.deleteAccountError = res.error
}
})
},
changePassword () {
const params = {
password: this.changePasswordInputs[0],
newPassword: this.changePasswordInputs[1],
newPasswordConfirmation: this.changePasswordInputs[2]
}
this.$store.state.api.backendInteractor.changePassword(params)
.then((res) => {
if (res.status === 'success') {
this.changedPassword = true
this.changePasswordError = false
this.logout()
} else {
this.changedPassword = false
this.changePasswordError = res.error
}
})
},
changeEmail () {
const params = {
email: this.newEmail,
password: this.changeEmailPassword
}
this.$store.state.api.backendInteractor.changeEmail(params)
.then((res) => {
if (res.status === 'success') {
this.changedEmail = true
this.changeEmailError = false
} else {
this.changedEmail = false
this.changeEmailError = res.error
}
})
},
logout () {
this.$store.dispatch('logout')
this.$router.replace('/')
},
revokeToken (id) {
if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) {
this.$store.dispatch('revokeToken', id)
}
}
}
}
export default SecurityTab

View File

@ -0,0 +1,143 @@
<template>
<div :label="$t('settings.security_tab')">
<div class="setting-item">
<h2>{{ $t('settings.change_email') }}</h2>
<div>
<p>{{ $t('settings.new_email') }}</p>
<input
v-model="newEmail"
type="email"
autocomplete="email"
>
</div>
<div>
<p>{{ $t('settings.current_password') }}</p>
<input
v-model="changeEmailPassword"
type="password"
autocomplete="current-password"
>
</div>
<button
class="btn btn-default"
@click="changeEmail"
>
{{ $t('general.submit') }}
</button>
<p v-if="changedEmail">
{{ $t('settings.changed_email') }}
</p>
<template v-if="changeEmailError !== false">
<p>{{ $t('settings.change_email_error') }}</p>
<p>{{ changeEmailError }}</p>
</template>
</div>
<div class="setting-item">
<h2>{{ $t('settings.change_password') }}</h2>
<div>
<p>{{ $t('settings.current_password') }}</p>
<input
v-model="changePasswordInputs[0]"
type="password"
>
</div>
<div>
<p>{{ $t('settings.new_password') }}</p>
<input
v-model="changePasswordInputs[1]"
type="password"
>
</div>
<div>
<p>{{ $t('settings.confirm_new_password') }}</p>
<input
v-model="changePasswordInputs[2]"
type="password"
>
</div>
<button
class="btn btn-default"
@click="changePassword"
>
{{ $t('general.submit') }}
</button>
<p v-if="changedPassword">
{{ $t('settings.changed_password') }}
</p>
<p v-else-if="changePasswordError !== false">
{{ $t('settings.change_password_error') }}
</p>
<p v-if="changePasswordError">
{{ changePasswordError }}
</p>
</div>
<div class="setting-item">
<h2>{{ $t('settings.oauth_tokens') }}</h2>
<table class="oauth-tokens">
<thead>
<tr>
<th>{{ $t('settings.app_name') }}</th>
<th>{{ $t('settings.valid_until') }}</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="oauthToken in oauthTokens"
:key="oauthToken.id"
>
<td>{{ oauthToken.appName }}</td>
<td>{{ oauthToken.validUntil }}</td>
<td class="actions">
<button
class="btn btn-default"
@click="revokeToken(oauthToken.id)"
>
{{ $t('settings.revoke_token') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<mfa />
<div class="setting-item">
<h2>{{ $t('settings.delete_account') }}</h2>
<p v-if="!deletingAccount">
{{ $t('settings.delete_account_description') }}
</p>
<div v-if="deletingAccount">
<p>{{ $t('settings.delete_account_instructions') }}</p>
<p>{{ $t('login.password') }}</p>
<input
v-model="deleteAccountConfirmPasswordInput"
type="password"
>
<button
class="btn btn-default"
@click="deleteAccount"
>
{{ $t('settings.delete_account') }}
</button>
</div>
<p v-if="deleteAccountError !== false">
{{ $t('settings.delete_account_error') }}
</p>
<p v-if="deleteAccountError">
{{ deleteAccountError }}
</p>
<button
v-if="!deletingAccount"
class="btn btn-default"
@click="confirmDelete"
>
{{ $t('general.submit') }}
</button>
</div>
</div>
</template>
<script src="./security_tab.js"></script>
<!-- <style lang="scss" src="./profile.scss"></style> -->

View File

@ -3,7 +3,7 @@ import {
rgb2hex, rgb2hex,
hex2rgb, hex2rgb,
getContrastRatioLayers getContrastRatioLayers
} from '../../services/color_convert/color_convert.js' } from 'src/services/color_convert/color_convert.js'
import { import {
DEFAULT_SHADOWS, DEFAULT_SHADOWS,
generateColors, generateColors,
@ -14,26 +14,27 @@ import {
getThemes, getThemes,
shadows2to3, shadows2to3,
colors2to3 colors2to3
} from '../../services/style_setter/style_setter.js' } from 'src/services/style_setter/style_setter.js'
import { import {
SLOT_INHERITANCE SLOT_INHERITANCE
} from '../../services/theme_data/pleromafe.js' } from 'src/services/theme_data/pleromafe.js'
import { import {
CURRENT_VERSION, CURRENT_VERSION,
OPACITIES, OPACITIES,
getLayers, getLayers,
getOpacitySlot getOpacitySlot
} from '../../services/theme_data/theme_data.service.js' } from 'src/services/theme_data/theme_data.service.js'
import ColorInput from '../color_input/color_input.vue' import ColorInput from 'src/components/color_input/color_input.vue'
import RangeInput from '../range_input/range_input.vue' import RangeInput from 'src/components/range_input/range_input.vue'
import OpacityInput from '../opacity_input/opacity_input.vue' import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
import ShadowControl from '../shadow_control/shadow_control.vue' import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
import FontControl from '../font_control/font_control.vue' import FontControl from 'src/components/font_control/font_control.vue'
import ContrastRatio from '../contrast_ratio/contrast_ratio.vue' import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
import TabSwitcher from '../tab_switcher/tab_switcher.js' import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
import ExportImport from 'src/components/export_import/export_import.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Preview from './preview.vue' import Preview from './preview.vue'
import ExportImport from '../export_import/export_import.vue'
import Checkbox from '../checkbox/checkbox.vue'
// List of color values used in v1 // List of color values used in v1
const v1OnlyNames = [ const v1OnlyNames = [

View File

@ -1,5 +1,6 @@
@import '../../_variables.scss'; @import 'src/_variables.scss';
.style-switcher { .theme-tab {
padding-bottom: 2em;
.theme-warning { .theme-warning {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@ -54,10 +55,6 @@
} }
} }
.tab-switcher {
margin: 0 -1em;
}
.reset-container { .reset-container {
flex-wrap: wrap; flex-wrap: wrap;
} }
@ -98,20 +95,25 @@
align-items: baseline; align-items: baseline;
width: 100%; width: 100%;
min-height: 30px; min-height: 30px;
margin-bottom: 1em;
.btn {
min-width: 1px;
flex: 0 auto;
padding: 0 1em;
}
p { p {
flex: 1; flex: 1;
margin: 0; margin: 0;
margin-right: .5em; margin-right: .5em;
} }
}
margin-bottom: 1em; .tab-header-buttons {
display: flex;
flex-direction: column;
.btn {
min-width: 1px;
flex: 0 auto;
padding: 0 1em;
margin-bottom: .5em;
}
} }
.shadow-selector { .shadow-selector {
@ -161,7 +163,7 @@
border-bottom: 1px dashed; border-bottom: 1px dashed;
border-color: $fallback--border; border-color: $fallback--border;
border-color: var(--border, $fallback--border); border-color: var(--border, $fallback--border);
margin: 1em -1em 0; margin: 1em 0;
padding: 1em; padding: 1em;
background: var(--body-background-image); background: var(--body-background-image);
background-size: cover; background-size: cover;
@ -328,6 +330,14 @@
padding: 20px; padding: 20px;
} }
.apply-container {
.btn {
min-height: 28px;
min-width: 10em;
padding: 0 2em;
}
}
.btn { .btn {
margin-left: .25em; margin-left: .25em;
margin-right: .25em; margin-right: .25em;

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="style-switcher"> <div class="theme-tab">
<div class="presets-container"> <div class="presets-container">
<div class="save-load"> <div class="save-load">
<div <div
@ -126,6 +126,7 @@
> >
<div class="tab-header"> <div class="tab-header">
<p>{{ $t('settings.theme_help') }}</p> <p>{{ $t('settings.theme_help') }}</p>
<div class="tab-header-buttons">
<button <button
class="btn" class="btn"
@click="clearOpacity" @click="clearOpacity"
@ -139,6 +140,7 @@
{{ $t('settings.style.switcher.clear_all') }} {{ $t('settings.style.switcher.clear_all') }}
</button> </button>
</div> </div>
</div>
<p>{{ $t('settings.theme_help_v2_1') }}</p> <p>{{ $t('settings.theme_help_v2_1') }}</p>
<h4>{{ $t('settings.style.common_colors.main') }}</h4> <h4>{{ $t('settings.style.common_colors.main') }}</h4>
<div class="color-item"> <div class="color-item">
@ -254,6 +256,13 @@
:label="$t('settings.links')" :label="$t('settings.links')"
/> />
<ContrastRatio :contrast="previewContrast.postLink" /> <ContrastRatio :contrast="previewContrast.postLink" />
<ColorInput
v-model="postGreentextColorLocal"
name="postGreentextColor"
:fallback="previewTheme.colors.cGreen"
:label="$t('settings.greentext')"
/>
<ContrastRatio :contrast="previewContrast.postGreentext" />
<h4>{{ $t('settings.style.advanced_colors.alert') }}</h4> <h4>{{ $t('settings.style.advanced_colors.alert') }}</h4>
<ColorInput <ColorInput
v-model="alertErrorColorLocal" v-model="alertErrorColorLocal"
@ -951,6 +960,6 @@
</div> </div>
</template> </template>
<script src="./style_switcher.js"></script> <script src="./theme_tab.js"></script>
<style src="./style_switcher.scss" lang="scss"></style> <style src="./theme_tab.scss" lang="scss"></style>

View File

@ -0,0 +1,24 @@
import { extractCommit } from 'src/services/version/version.service'
const pleromaFeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
const pleromaBeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma/commit/'
const VersionTab = {
data () {
const instance = this.$store.state.instance
return {
backendVersion: instance.backendVersion,
frontendVersion: instance.frontendVersion
}
},
computed: {
frontendVersionLink () {
return pleromaFeCommitUrl + this.frontendVersion
},
backendVersionLink () {
return pleromaBeCommitUrl + extractCommit(this.backendVersion)
}
}
}
export default VersionTab

View File

@ -0,0 +1,31 @@
<template>
<div :label="$t('settings.version.title')">
<div class="setting-item">
<ul class="setting-list">
<li>
<p>{{ $t('settings.version.backend_version') }}</p>
<ul class="option-list">
<li>
<a
:href="backendVersionLink"
target="_blank"
>{{ backendVersion }}</a>
</li>
</ul>
</li>
<li>
<p>{{ $t('settings.version.frontend_version') }}</p>
<ul class="option-list">
<li>
<a
:href="frontendVersionLink"
target="_blank"
>{{ frontendVersion }}</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</template>
<script src="./version_tab.js">

View File

@ -62,6 +62,9 @@ const SideDrawer = {
}, },
touchMove (e) { touchMove (e) {
GestureService.updateSwipe(e, this.closeGesture) GestureService.updateSwipe(e, this.closeGesture)
},
openSettingsModal () {
this.$store.dispatch('openSettingsModal')
} }
} }
} }

View File

@ -19,7 +19,7 @@
> >
<UserCard <UserCard
v-if="currentUser" v-if="currentUser"
:user="currentUser" :user-id="currentUser.id"
:hide-bio="true" :hide-bio="true"
/> />
<div <div
@ -122,9 +122,12 @@
</router-link> </router-link>
</li> </li>
<li @click="toggleDrawer"> <li @click="toggleDrawer">
<router-link :to="{ name: 'settings' }"> <a
href="#"
@click="openSettingsModal"
>
<i class="button-icon icon-cog" /> {{ $t("settings.settings") }} <i class="button-icon icon-cog" /> {{ $t("settings.settings") }}
</router-link> </a>
</li> </li>
<li @click="toggleDrawer"> <li @click="toggleDrawer">
<router-link :to="{ name: 'about'}"> <router-link :to="{ name: 'about'}">

View File

@ -1,24 +1,19 @@
import Attachment from '../attachment/attachment.vue'
import FavoriteButton from '../favorite_button/favorite_button.vue' import FavoriteButton from '../favorite_button/favorite_button.vue'
import ReactButton from '../react_button/react_button.vue' import ReactButton from '../react_button/react_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue' import RetweetButton from '../retweet_button/retweet_button.vue'
import Poll from '../poll/poll.vue'
import ExtraButtons from '../extra_buttons/extra_buttons.vue' import ExtraButtons from '../extra_buttons/extra_buttons.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue' import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCard from '../user_card/user_card.vue' import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import AvatarList from '../avatar_list/avatar_list.vue' import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue' import Timeago from '../timeago/timeago.vue'
import StatusContent from '../status_content/status_content.vue'
import StatusPopover from '../status_popover/status_popover.vue' import StatusPopover from '../status_popover/status_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue' import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import fileType from 'src/services/file_type/file_type.service'
import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js' import { muteWordHits } from '../../services/status_parser/status_parser.js'
import { filter, unescape, uniqBy } from 'lodash' import { unescape, uniqBy } from 'lodash'
import { mapGetters, mapState } from 'vuex' import { mapGetters, mapState } from 'vuex'
const Status = { const Status = {
@ -43,20 +38,19 @@ const Status = {
replying: false, replying: false,
unmuted: false, unmuted: false,
userExpanded: false, userExpanded: false,
showingTall: this.inConversation && this.focused, error: null
showingLongSubject: false,
error: null,
// not as computed because it sets the initial state which will be changed later
expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
} }
}, },
computed: { computed: {
localCollapseSubjectDefault () {
return this.mergedConfig.collapseMessageWithSubject
},
muteWords () { muteWords () {
return this.mergedConfig.muteWords return this.mergedConfig.muteWords
}, },
showReasonMutedThread () {
return (
this.status.thread_muted ||
(this.status.reblog && this.status.reblog.thread_muted)
) && !this.inConversation
},
repeaterClass () { repeaterClass () {
const user = this.statusoid.user const user = this.statusoid.user
return highlightClass(user) return highlightClass(user)
@ -79,10 +73,6 @@ const Status = {
const highlight = this.mergedConfig.highlight const highlight = this.mergedConfig.highlight
return highlightStyle(highlight[user.screen_name]) return highlightStyle(highlight[user.screen_name])
}, },
hideAttachments () {
return (this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation)
},
userProfileLink () { userProfileLink () {
return this.generateUserProfileLink(this.status.user.id, this.status.user.screen_name) return this.generateUserProfileLink(this.status.user.id, this.status.user.screen_name)
}, },
@ -110,15 +100,43 @@ const Status = {
return !!this.currentUser return !!this.currentUser
}, },
muteWordHits () { muteWordHits () {
const statusText = this.status.text.toLowerCase() return muteWordHits(this.status, this.muteWords)
const statusSummary = this.status.summary.toLowerCase() },
const hits = filter(this.muteWords, (muteWord) => { muted () {
return statusText.includes(muteWord.toLowerCase()) || statusSummary.includes(muteWord.toLowerCase()) const { status } = this
}) const { reblog } = status
const relationship = this.$store.getters.relationship(status.user.id)
return hits const relationshipReblog = reblog && this.$store.getters.relationship(reblog.user.id)
const reasonsToMute = (
// Post is muted according to BE
status.muted ||
// Reprööt of a muted post according to BE
(reblog && reblog.muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
(relationshipReblog && relationshipReblog.muting) ||
// Thread is muted
status.thread_muted ||
// Wordfiltered
this.muteWordHits.length > 0
)
const excusesNotToMute = (
(
this.inProfile && (
// Don't mute user's posts on user timeline (except reblogs)
(!reblog && status.user.id === this.profileUserId) ||
// Same as above but also allow self-reblogs
(reblog && reblog.user.id === this.profileUserId)
)
) ||
// Don't mute statuses in muted conversation when said conversation is opened
(this.inConversation && status.thread_muted)
// No excuses if post has muted words
) && !this.muteWordHits.length > 0
return !this.unmuted && !excusesNotToMute && reasonsToMute
}, },
muted () { return !this.unmuted && ((!(this.inProfile && this.status.user.id === this.profileUserId) && this.status.user.muted) || (!this.inConversation && this.status.thread_muted) || this.muteWordHits.length > 0) },
hideFilteredStatuses () { hideFilteredStatuses () {
return this.mergedConfig.hideFilteredStatuses return this.mergedConfig.hideFilteredStatuses
}, },
@ -135,20 +153,6 @@ const Status = {
// use conversation highlight only when in conversation // use conversation highlight only when in conversation
return this.status.id === this.highlight return this.status.id === this.highlight
}, },
// This is a bit hacky, but we want to approximate post height before rendering
// so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
// as well as approximate line count by counting characters and approximating ~80
// per line.
//
// Using max-height + overflow: auto for status components resulted in false positives
// very often with japanese characters, and it was very annoying.
tallStatus () {
const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80
return lengthScore > 20
},
longSubject () {
return this.status.summary.length > 900
},
isReply () { isReply () {
return !!(this.status.in_reply_to_status_id && this.status.in_reply_to_user_id) return !!(this.status.in_reply_to_status_id && this.status.in_reply_to_user_id)
}, },
@ -178,8 +182,11 @@ const Status = {
if (this.status.user.id === this.status.attentions[i].id) { if (this.status.user.id === this.status.attentions[i].id) {
continue continue
} }
const taggedUser = this.$store.getters.findUser(this.status.attentions[i].id) // There's zero guarantee of this working. If we happen to have that user and their
if (checkFollowing && taggedUser && taggedUser.following) { // relationship in store then it will work, but there's kinda little chance of having
// them for people you're not following.
const relationship = this.$store.state.users.relationships[this.status.attentions[i].id]
if (checkFollowing && relationship && relationship.following) {
return false return false
} }
if (this.status.attentions[i].id === this.currentUser.id) { if (this.status.attentions[i].id === this.currentUser.id) {
@ -188,33 +195,6 @@ const Status = {
} }
return this.status.attentions.length > 0 return this.status.attentions.length > 0
}, },
hideSubjectStatus () {
if (this.tallStatus && !this.localCollapseSubjectDefault) {
return false
}
return !this.expandingSubject && this.status.summary
},
hideTallStatus () {
if (this.status.summary && this.localCollapseSubjectDefault) {
return false
}
if (this.showingTall) {
return false
}
return this.tallStatus
},
showingMore () {
return (this.tallStatus && this.showingTall) || (this.status.summary && this.expandingSubject)
},
nsfwClickthrough () {
if (!this.status.nsfw) {
return false
}
if (this.status.summary && this.localCollapseSubjectDefault) {
return false
}
return true
},
replySubject () { replySubject () {
if (!this.status.summary) return '' if (!this.status.summary) return ''
const decodedSummary = unescape(this.status.summary) const decodedSummary = unescape(this.status.summary)
@ -228,83 +208,6 @@ const Status = {
return '' return ''
} }
}, },
attachmentSize () {
if ((this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation) ||
(this.status.attachments.length > this.maxThumbnails)) {
return 'hide'
} else if (this.compact) {
return 'small'
}
return 'normal'
},
galleryTypes () {
if (this.attachmentSize === 'hide') {
return []
}
return this.mergedConfig.playVideosInModal
? ['image', 'video']
: ['image']
},
galleryAttachments () {
return this.status.attachments.filter(
file => fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
nonGalleryAttachments () {
return this.status.attachments.filter(
file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
hasImageAttachments () {
return this.status.attachments.some(
file => fileType.fileType(file.mimetype) === 'image'
)
},
hasVideoAttachments () {
return this.status.attachments.some(
file => fileType.fileType(file.mimetype) === 'video'
)
},
maxThumbnails () {
return this.mergedConfig.maxThumbnails
},
postBodyHtml () {
const html = this.status.statusnet_html
if (this.mergedConfig.greentext) {
try {
if (html.includes('&gt;')) {
// This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works
return processHtml(html, (string) => {
if (string.includes('&gt;') &&
string
.replace(/<[^>]+?>/gi, '') // remove all tags
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
.startsWith('&gt;')) {
return `<span class='greentext'>${string}</span>`
} else {
return string
}
})
} else {
return html
}
} catch (e) {
console.err('Failed to process status html', e)
return html
}
} else {
return html
}
},
contentHtml () {
if (!this.status.summary_html) {
return this.postBodyHtml
}
return this.status.summary_html + '<br />' + this.postBodyHtml
},
combinedFavsAndRepeatsUsers () { combinedFavsAndRepeatsUsers () {
// Use the status from the global status repository since favs and repeats are saved in it // Use the status from the global status repository since favs and repeats are saved in it
const combinedUsers = [].concat( const combinedUsers = [].concat(
@ -313,9 +216,6 @@ const Status = {
) )
return uniqBy(combinedUsers, 'id') return uniqBy(combinedUsers, 'id')
}, },
ownStatus () {
return this.status.user.id === this.currentUser.id
},
tags () { tags () {
return this.status.tags.filter(tagObj => tagObj.hasOwnProperty('name')).map(tagObj => tagObj.name).join(' ') return this.status.tags.filter(tagObj => tagObj.hasOwnProperty('name')).map(tagObj => tagObj.name).join(' ')
}, },
@ -329,21 +229,18 @@ const Status = {
}) })
}, },
components: { components: {
Attachment,
FavoriteButton, FavoriteButton,
ReactButton, ReactButton,
RetweetButton, RetweetButton,
ExtraButtons, ExtraButtons,
PostStatusForm, PostStatusForm,
Poll,
UserCard, UserCard,
UserAvatar, UserAvatar,
Gallery,
LinkPreview,
AvatarList, AvatarList,
Timeago, Timeago,
StatusPopover, StatusPopover,
EmojiReactions EmojiReactions,
StatusContent
}, },
methods: { methods: {
visibilityIcon (visibility) { visibilityIcon (visibility) {
@ -364,32 +261,6 @@ const Status = {
clearError () { clearError () {
this.error = undefined this.error = undefined
}, },
linkClicked (event) {
const target = event.target.closest('.status-content a')
if (target) {
if (target.className.match(/mention/)) {
const href = target.href
const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href))
if (attn) {
event.stopPropagation()
event.preventDefault()
const link = this.generateUserProfileLink(attn.id, attn.screen_name)
this.$router.push(link)
return
}
}
if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) {
// Extract tag name from link url
const tag = extractTagFromUrl(target.href)
if (tag) {
const link = this.generateTagLink(tag)
this.$router.push(link)
return
}
}
window.open(target.href, '_blank')
}
},
toggleReplying () { toggleReplying () {
this.replying = !this.replying this.replying = !this.replying
}, },
@ -407,26 +278,8 @@ const Status = {
toggleUserExpanded () { toggleUserExpanded () {
this.userExpanded = !this.userExpanded this.userExpanded = !this.userExpanded
}, },
toggleShowMore () {
if (this.showingTall) {
this.showingTall = false
} else if (this.expandingSubject && this.status.summary) {
this.expandingSubject = false
} else if (this.hideTallStatus) {
this.showingTall = true
} else if (this.hideSubjectStatus && this.status.summary) {
this.expandingSubject = true
}
},
generateUserProfileLink (id, name) { generateUserProfileLink (id, name) {
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames) return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
},
generateTagLink (tag) {
return `/tag/${tag}`
},
setMedia () {
const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
return () => this.$store.dispatch('setMedia', attachments)
} }
}, },
watch: { watch: {

View File

@ -17,12 +17,33 @@
</div> </div>
<template v-if="muted && !isPreview"> <template v-if="muted && !isPreview">
<div class="media status container muted"> <div class="media status container muted">
<small> <small class="username">
<i
v-if="muted && retweet"
class="button-icon icon-retweet"
/>
<router-link :to="userProfileLink"> <router-link :to="userProfileLink">
{{ status.user.screen_name }} {{ status.user.screen_name }}
</router-link> </router-link>
</small> </small>
<small class="muteWords">{{ muteWordHits.join(', ') }}</small> <small
v-if="showReasonMutedThread"
class="mute-thread"
>
{{ $t('status.thread_muted') }}
</small>
<small
v-if="showReasonMutedThread && muteWordHits.length > 0"
class="mute-thread"
>
{{ $t('status.thread_muted_and_words') }}
</small>
<small
class="mute-words"
:title="muteWordHits.join(', ')"
>
{{ muteWordHits.join(', ') }}
</small>
<a <a
href="#" href="#"
class="unmute" class="unmute"
@ -94,7 +115,7 @@
<div class="status-body"> <div class="status-body">
<UserCard <UserCard
v-if="userExpanded" v-if="userExpanded"
:user="status.user" :user-id="status.user.id"
:rounded="true" :rounded="true"
:bordered="true" :bordered="true"
class="status-usercard" class="status-usercard"
@ -241,118 +262,12 @@
</div> </div>
</div> </div>
<div <StatusContent
v-if="longSubject" :status="status"
class="status-content-wrapper" :no-heading="noHeading"
:class="{ 'tall-status': !showingLongSubject }" :highlight="highlight"
> :focused="isFocused"
<a
v-if="!showingLongSubject"
class="tall-status-hider"
:class="{ 'tall-status-hider_focused': isFocused }"
href="#"
@click.prevent="showingLongSubject=true"
>{{ $t("general.show_more") }}</a>
<div
class="status-content media-body"
@click.prevent="linkClicked"
v-html="contentHtml"
/> />
<a
v-if="showingLongSubject"
href="#"
class="status-unhider"
@click.prevent="showingLongSubject=false"
>{{ $t("general.show_less") }}</a>
</div>
<div
v-else
:class="{'tall-status': hideTallStatus}"
class="status-content-wrapper"
>
<a
v-if="hideTallStatus"
class="tall-status-hider"
:class="{ 'tall-status-hider_focused': isFocused }"
href="#"
@click.prevent="toggleShowMore"
>{{ $t("general.show_more") }}</a>
<div
v-if="!hideSubjectStatus"
class="status-content media-body"
@click.prevent="linkClicked"
v-html="contentHtml"
/>
<div
v-else
class="status-content media-body"
@click.prevent="linkClicked"
v-html="status.summary_html"
/>
<a
v-if="hideSubjectStatus"
href="#"
class="cw-status-hider"
@click.prevent="toggleShowMore"
>
{{ $t("general.show_more") }}
<span
v-if="hasImageAttachments"
class="icon-picture"
/>
<span
v-if="hasVideoAttachments"
class="icon-video"
/>
<span
v-if="status.card"
class="icon-link"
/>
</a>
<a
v-if="showingMore"
href="#"
class="status-unhider"
@click.prevent="toggleShowMore"
>{{ $t("general.show_less") }}</a>
</div>
<div v-if="status.poll && status.poll.options">
<poll :base-poll="status.poll" />
</div>
<div
v-if="status.attachments && (!hideSubjectStatus || showingLongSubject)"
class="attachments media-body"
>
<attachment
v-for="attachment in nonGalleryAttachments"
:key="attachment.id"
class="non-gallery"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
:attachment="attachment"
:allow-play="true"
:set-media="setMedia()"
/>
<gallery
v-if="galleryAttachments.length > 0"
:nsfw="nsfwClickthrough"
:attachments="galleryAttachments"
:set-media="setMedia()"
/>
</div>
<div
v-if="status.card && !hideSubjectStatus && !noHeading"
class="link-preview media-body"
>
<link-preview
:card="status.card"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
/>
</div>
<transition name="fade"> <transition name="fade">
<div <div
@ -419,7 +334,7 @@
:status="status" :status="status"
/> />
<ReactButton <ReactButton
:logged-in="loggedIn" v-if="loggedIn"
:status="status" :status="status"
/> />
<extra-buttons <extra-buttons
@ -645,105 +560,6 @@ $status-margin: 0.75em;
} }
} }
.tall-status {
position: relative;
height: 220px;
overflow-x: hidden;
overflow-y: hidden;
z-index: 1;
.status-content {
height: 100%;
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;
}
}
.tall-status-hider {
display: inline-block;
word-break: break-all;
position: absolute;
height: 70px;
margin-top: 150px;
width: 100%;
text-align: center;
line-height: 110px;
z-index: 2;
}
.status-unhider, .cw-status-hider {
width: 100%;
text-align: center;
display: inline-block;
word-break: break-all;
}
.status-content {
font-family: var(--postFont, sans-serif);
line-height: 1.4em;
white-space: pre-wrap;
a {
color: $fallback--link;
color: var(--postLink, $fallback--link);
}
img, video {
max-width: 100%;
max-height: 400px;
vertical-align: middle;
object-fit: contain;
&.emoji {
width: 32px;
height: 32px;
}
}
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: 1.0em 0;
}
h3 {
font-size: 1em;
margin: 1.2em 0;
}
h4 {
margin: 1.1em 0;
}
}
.retweet-info { .retweet-info {
padding: 0.4em $status-margin; padding: 0.4em $status-margin;
margin: 0; margin: 0;
@ -805,11 +621,6 @@ $status-margin: 0.75em;
} }
} }
.greentext {
color: $fallback--cGreen;
color: var(--cGreen, $fallback--cGreen);
}
.status-conversation { .status-conversation {
border-left-style: solid; border-left-style: solid;
} }
@ -862,33 +673,54 @@ $status-margin: 0.75em;
} }
.muted { .muted {
padding: 0.25em 0.5em; padding: .25em .6em;
button { height: 1.2em;
line-height: 1.2em;
text-overflow: ellipsis;
overflow: hidden;
display: flex;
flex-wrap: nowrap;
.username, .mute-thread, .mute-words {
word-wrap: normal;
word-break: normal;
white-space: nowrap;
}
.username, .mute-words {
text-overflow: ellipsis;
overflow: hidden;
}
.username {
flex: 0 1 auto;
margin-right: .2em;
}
.mute-thread {
flex: 0 0 auto;
}
.mute-words {
flex: 1 0 5em;
margin-left: .2em;
&::before {
content: ' '
}
}
.unmute {
flex: 0 0 auto;
margin-left: auto; margin-left: auto;
}
.muteWords {
margin-left: 10px;
}
}
a.unmute {
display: block; display: block;
margin-left: auto; margin-left: auto;
}
} }
.reply-body { .reply-body {
flex: 1; flex: 1;
} }
.timeline :not(.panel-disabled) > {
.status-el:last-child {
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
border-bottom: none;
}
}
.favs-repeated-users { .favs-repeated-users {
margin-top: $status-margin; margin-top: $status-margin;

View File

@ -0,0 +1,210 @@
import Attachment from '../attachment/attachment.vue'
import Poll from '../poll/poll.vue'
import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import fileType from 'src/services/file_type/file_type.service'
import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
import { mapGetters, mapState } from 'vuex'
const StatusContent = {
name: 'StatusContent',
props: [
'status',
'focused',
'noHeading',
'fullContent'
],
data () {
return {
showingTall: this.inConversation && this.focused,
showingLongSubject: false,
// not as computed because it sets the initial state which will be changed later
expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
}
},
computed: {
localCollapseSubjectDefault () {
return this.mergedConfig.collapseMessageWithSubject
},
hideAttachments () {
return (this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation)
},
// This is a bit hacky, but we want to approximate post height before rendering
// so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
// as well as approximate line count by counting characters and approximating ~80
// per line.
//
// Using max-height + overflow: auto for status components resulted in false positives
// very often with japanese characters, and it was very annoying.
tallStatus () {
const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80
return lengthScore > 20
},
longSubject () {
return this.status.summary.length > 900
},
// When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
mightHideBecauseSubject () {
return this.status.summary && (!this.tallStatus || this.localCollapseSubjectDefault)
},
mightHideBecauseTall () {
return this.tallStatus && (!this.status.summary || !this.localCollapseSubjectDefault)
},
hideSubjectStatus () {
return this.mightHideBecauseSubject && !this.expandingSubject
},
hideTallStatus () {
return this.mightHideBecauseTall && !this.showingTall
},
showingMore () {
return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject)
},
nsfwClickthrough () {
if (!this.status.nsfw) {
return false
}
if (this.status.summary && this.localCollapseSubjectDefault) {
return false
}
return true
},
attachmentSize () {
if ((this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation) ||
(this.status.attachments.length > this.maxThumbnails)) {
return 'hide'
} else if (this.compact) {
return 'small'
}
return 'normal'
},
galleryTypes () {
if (this.attachmentSize === 'hide') {
return []
}
return this.mergedConfig.playVideosInModal
? ['image', 'video']
: ['image']
},
galleryAttachments () {
return this.status.attachments.filter(
file => fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
nonGalleryAttachments () {
return this.status.attachments.filter(
file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
hasImageAttachments () {
return this.status.attachments.some(
file => fileType.fileType(file.mimetype) === 'image'
)
},
hasVideoAttachments () {
return this.status.attachments.some(
file => fileType.fileType(file.mimetype) === 'video'
)
},
maxThumbnails () {
return this.mergedConfig.maxThumbnails
},
postBodyHtml () {
const html = this.status.statusnet_html
if (this.mergedConfig.greentext) {
try {
if (html.includes('&gt;')) {
// This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works
return processHtml(html, (string) => {
if (string.includes('&gt;') &&
string
.replace(/<[^>]+?>/gi, '') // remove all tags
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
.startsWith('&gt;')) {
return `<span class='greentext'>${string}</span>`
} else {
return string
}
})
} else {
return html
}
} catch (e) {
console.err('Failed to process status html', e)
return html
}
} else {
return html
}
},
contentHtml () {
if (!this.status.summary_html) {
return this.postBodyHtml
}
return this.status.summary_html + '<br />' + this.postBodyHtml
},
...mapGetters(['mergedConfig']),
...mapState({
betterShadow: state => state.interface.browserSupport.cssFilter,
currentUser: state => state.users.currentUser
})
},
components: {
Attachment,
Poll,
Gallery,
LinkPreview
},
methods: {
linkClicked (event) {
const target = event.target.closest('.status-content a')
if (target) {
if (target.className.match(/mention/)) {
const href = target.href
const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href))
if (attn) {
event.stopPropagation()
event.preventDefault()
const link = this.generateUserProfileLink(attn.id, attn.screen_name)
this.$router.push(link)
return
}
}
if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) {
// Extract tag name from dataset or link url
const tag = target.dataset.tag || extractTagFromUrl(target.href)
if (tag) {
const link = this.generateTagLink(tag)
this.$router.push(link)
return
}
}
window.open(target.href, '_blank')
}
},
toggleShowMore () {
if (this.mightHideBecauseTall) {
this.showingTall = !this.showingTall
} else if (this.mightHideBecauseSubject) {
this.expandingSubject = !this.expandingSubject
}
},
generateUserProfileLink (id, name) {
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
},
generateTagLink (tag) {
return `/tag/${tag}`
},
setMedia () {
const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
return () => this.$store.dispatch('setMedia', attachments)
}
}
}
export default StatusContent

View File

@ -0,0 +1,240 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<div class="status-body">
<slot name="header" />
<div
v-if="longSubject"
class="status-content-wrapper"
:class="{ 'tall-status': !showingLongSubject }"
>
<a
v-if="!showingLongSubject"
class="tall-status-hider"
:class="{ 'tall-status-hider_focused': focused }"
href="#"
@click.prevent="showingLongSubject=true"
>
{{ $t("general.show_more") }}
<span
v-if="hasImageAttachments"
class="icon-picture"
/>
<span
v-if="hasVideoAttachments"
class="icon-video"
/>
<span
v-if="status.card"
class="icon-link"
/>
</a>
<div
class="status-content media-body"
@click.prevent="linkClicked"
v-html="contentHtml"
/>
<a
v-if="showingLongSubject"
href="#"
class="status-unhider"
@click.prevent="showingLongSubject=false"
>{{ $t("general.show_less") }}</a>
</div>
<div
v-else
:class="{'tall-status': hideTallStatus}"
class="status-content-wrapper"
>
<a
v-if="hideTallStatus"
class="tall-status-hider"
:class="{ 'tall-status-hider_focused': focused }"
href="#"
@click.prevent="toggleShowMore"
>{{ $t("general.show_more") }}</a>
<div
v-if="!hideSubjectStatus"
class="status-content media-body"
@click.prevent="linkClicked"
v-html="contentHtml"
/>
<div
v-else
class="status-content media-body"
@click.prevent="linkClicked"
v-html="status.summary_html"
/>
<a
v-if="hideSubjectStatus"
href="#"
class="cw-status-hider"
@click.prevent="toggleShowMore"
>{{ $t("general.show_more") }}</a>
<a
v-if="showingMore"
href="#"
class="status-unhider"
@click.prevent="toggleShowMore"
>{{ $t("general.show_less") }}</a>
</div>
<div v-if="status.poll && status.poll.options">
<poll :base-poll="status.poll" />
</div>
<div
v-if="status.attachments.length !== 0 && (!hideSubjectStatus || showingLongSubject)"
class="attachments media-body"
>
<attachment
v-for="attachment in nonGalleryAttachments"
:key="attachment.id"
class="non-gallery"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
:attachment="attachment"
:allow-play="true"
:set-media="setMedia()"
/>
<gallery
v-if="galleryAttachments.length > 0"
:nsfw="nsfwClickthrough"
:attachments="galleryAttachments"
:set-media="setMedia()"
/>
</div>
<div
v-if="status.card && !hideSubjectStatus && !noHeading"
class="link-preview media-body"
>
<link-preview
:card="status.card"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
/>
</div>
<slot name="footer" />
</div>
<!-- eslint-enable vue/no-v-html -->
</template>
<script src="./status_content.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
$status-margin: 0.75em;
.status-body {
flex: 1;
min-width: 0;
.tall-status {
position: relative;
height: 220px;
overflow-x: hidden;
overflow-y: hidden;
z-index: 1;
.status-content {
height: 100%;
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;
}
}
.tall-status-hider {
display: inline-block;
word-break: break-all;
position: absolute;
height: 70px;
margin-top: 150px;
width: 100%;
text-align: center;
line-height: 110px;
z-index: 2;
}
.status-unhider, .cw-status-hider {
width: 100%;
text-align: center;
display: inline-block;
word-break: break-all;
}
.status-content {
font-family: var(--postFont, sans-serif);
line-height: 1.4em;
white-space: pre-wrap;
img, video {
max-width: 100%;
max-height: 400px;
vertical-align: middle;
object-fit: contain;
&.emoji {
width: 32px;
height: 32px;
}
}
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: 1.0em 0;
}
h3 {
font-size: 1em;
margin: 1.2em 0;
}
h4 {
margin: 1.1em 0;
}
}
}
.greentext {
color: $fallback--cGreen;
color: var(--postGreentext, $fallback--cGreen);
}
.timeline :not(.panel-disabled) > {
.status-el:last-child {
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
border-bottom: none;
}
}
</style>

View File

@ -5,6 +5,11 @@ const StatusPopover = {
props: [ props: [
'statusId' 'statusId'
], ],
data () {
return {
error: false
}
},
computed: { computed: {
status () { status () {
return find(this.$store.state.statuses.allStatuses, { id: this.statusId }) return find(this.$store.state.statuses.allStatuses, { id: this.statusId })
@ -18,6 +23,8 @@ const StatusPopover = {
enter () { enter () {
if (!this.status) { if (!this.status) {
this.$store.dispatch('fetchStatus', this.statusId) this.$store.dispatch('fetchStatus', this.statusId)
.then(data => (this.error = false))
.catch(e => (this.error = true))
} }
} }
} }

View File

@ -17,9 +17,15 @@
:statusoid="status" :statusoid="status"
:compact="true" :compact="true"
/> />
<div
v-else-if="error"
class="status-preview-no-content faint"
>
{{ $t('status.status_unavailable') }}
</div>
<div <div
v-else v-else
class="status-preview-loading" class="status-preview-no-content"
> >
<i class="icon-spin4 animate-spin" /> <i class="icon-spin4 animate-spin" />
</div> </div>
@ -50,7 +56,7 @@
border: none; border: none;
} }
.status-preview-loading { .status-preview-no-content {
padding: 1em; padding: 1em;
text-align: center; text-align: center;

View File

@ -23,12 +23,15 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.still-image { .still-image {
position: relative; position: relative;
line-height: 0; line-height: 0;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex;
align-items: center;
&:hover canvas { &:hover canvas {
display: none; display: none;
@ -36,7 +39,7 @@
img { img {
width: 100%; width: 100%;
height: 100%; min-height: 100%;
object-fit: contain; object-fit: contain;
} }

View File

@ -24,6 +24,11 @@ export default Vue.component('tab-switcher', {
required: false, required: false,
type: Boolean, type: Boolean,
default: false default: false
},
sideTabBar: {
required: false,
type: Boolean,
default: false
} }
}, },
data () { data () {
@ -55,6 +60,9 @@ export default Vue.component('tab-switcher', {
this.onSwitch.call(null, this.$slots.default[index].key) this.onSwitch.call(null, this.$slots.default[index].key)
} }
this.active = index this.active = index
if (this.scrollableTabs) {
this.$refs.contents.scrollTop = 0
}
} }
} }
}, },
@ -64,7 +72,6 @@ export default Vue.component('tab-switcher', {
if (!slot.tag) return if (!slot.tag) return
const classesTab = ['tab'] const classesTab = ['tab']
const classesWrapper = ['tab-wrapper'] const classesWrapper = ['tab-wrapper']
if (this.activeIndex === index) { if (this.activeIndex === index) {
classesTab.push('active') classesTab.push('active')
classesWrapper.push('active') classesWrapper.push('active')
@ -87,8 +94,14 @@ export default Vue.component('tab-switcher', {
<button <button
disabled={slot.data.attrs.disabled} disabled={slot.data.attrs.disabled}
onClick={this.activateTab(index)} onClick={this.activateTab(index)}
class={classesTab.join(' ')}> class={classesTab.join(' ')}
{slot.data.attrs.label}</button> type="button"
>
{!slot.data.attrs.icon ? '' : (<i class={'tab-icon icon-' + slot.data.attrs.icon}/>)}
<span class="text">
{slot.data.attrs.label}
</span>
</button>
</div> </div>
) )
}) })
@ -96,20 +109,32 @@ export default Vue.component('tab-switcher', {
const contents = this.$slots.default.map((slot, index) => { const contents = this.$slots.default.map((slot, index) => {
if (!slot.tag) return if (!slot.tag) return
const active = this.activeIndex === index const active = this.activeIndex === index
if (this.renderOnlyFocused) { const classes = [ active ? 'active' : 'hidden' ]
return active if (slot.data.attrs.fullHeight) {
? <div class="active">{slot}</div> classes.push('full-height')
: <div class="hidden"></div>
} }
return <div class={active ? 'active' : 'hidden' }>{slot}</div> const renderSlot = (!this.renderOnlyFocused || active)
? slot
: ''
return (
<div class={classes}>
{
this.sideTabBar
? <h1 class="mobile-label">{slot.data.attrs.label}</h1>
: ''
}
{renderSlot}
</div>
)
}) })
return ( return (
<div class="tab-switcher"> <div class={'tab-switcher ' + (this.sideTabBar ? 'side-tabs' : 'top-tabs')}>
<div class="tabs"> <div class="tabs">
{tabs} {tabs}
</div> </div>
<div class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}> <div ref="contents" class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}>
{contents} {contents}
</div> </div>
</div> </div>

View File

@ -2,8 +2,145 @@
.tab-switcher { .tab-switcher {
display: flex; display: flex;
.tab-icon {
font-size: 2em;
display: block;
}
&.top-tabs {
flex-direction: column; flex-direction: column;
> .tabs {
width: 100%;
overflow-y: hidden;
overflow-x: auto;
padding-top: 5px;
flex-direction: row;
&::after, &::before {
content: '';
flex: 1 1 auto;
border-bottom: 1px solid;
border-bottom-color: $fallback--border;
border-bottom-color: var(--border, $fallback--border);
}
.tab-wrapper {
height: 28px;
&:not(.active)::after {
left: 0;
right: 0;
bottom: 0;
border-bottom: 1px solid;
border-bottom-color: $fallback--border;
border-bottom-color: var(--border, $fallback--border);
}
}
.tab {
width: 100%;
min-width: 1px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
padding-bottom: 99px;
margin-bottom: 6px - 99px;
}
}
.contents.scrollable-tabs {
flex-basis: 0;
}
}
&.side-tabs {
flex-direction: row;
@media all and (max-width: 800px) {
overflow-x: auto;
}
> .contents {
flex: 1 1 auto;
}
> .tabs {
flex: 0 0 auto;
overflow-y: auto;
overflow-x: hidden;
flex-direction: column;
&::after, &::before {
flex-shrink: 0;
flex-basis: .5em;
content: '';
border-right: 1px solid;
border-right-color: $fallback--border;
border-right-color: var(--border, $fallback--border);
}
&::after {
flex-grow: 1;
}
&::before {
flex-grow: 0;
}
.tab-wrapper {
min-width: 10em;
display: flex;
flex-direction: column;
@media all and (max-width: 800px) {
min-width: 1em;
}
&:not(.active)::after {
top: 0;
right: 0;
bottom: 0;
border-right: 1px solid;
border-right-color: $fallback--border;
border-right-color: var(--border, $fallback--border);
}
&::before {
flex: 0 0 6px;
content: '';
border-right: 1px solid;
border-right-color: $fallback--border;
border-right-color: var(--border, $fallback--border);
}
&:last-child .tab {
margin-bottom: 0;
}
}
.tab {
flex: 1;
box-sizing: content-box;
min-width: 10em;
min-width: 1px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding-left: 1em;
padding-right: calc(1em + 200px);
margin-right: -200px;
margin-left: 1em;
@media all and (max-width: 800px) {
padding-left: .25em;
padding-right: calc(.25em + 200px);
margin-right: calc(.25em - 200px);
margin-left: .25em;
.text {
display: none
}
}
}
}
}
.contents { .contents {
flex: 1 0 auto; flex: 1 0 auto;
min-height: 0px; min-height: 0px;
@ -11,52 +148,32 @@
.hidden { .hidden {
display: none; display: none;
} }
.full-height:not(.hidden) {
height: 100%;
display: flex;
flex-direction: column;
> *:not(.mobile-label) {
flex: 1;
}
}
&.scrollable-tabs { &.scrollable-tabs {
flex-basis: 0;
overflow-y: auto; overflow-y: auto;
} }
} }
.tabs {
display: flex;
position: relative;
width: 100%;
overflow-y: hidden;
overflow-x: auto;
padding-top: 5px;
box-sizing: border-box;
&::after, &::before {
display: block;
content: '';
flex: 1 1 auto;
border-bottom: 1px solid;
border-bottom-color: $fallback--border;
border-bottom-color: var(--border, $fallback--border);
}
.tab-wrapper {
height: 28px;
position: relative;
display: flex;
flex: 0 0 auto;
.tab { .tab {
width: 100%;
min-width: 1px;
position: relative; position: relative;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
padding: 6px 1em;
padding-bottom: 99px;
margin-bottom: 6px - 99px;
white-space: nowrap; white-space: nowrap;
padding: 6px 1em;
color: $fallback--text;
color: var(--tabText, $fallback--text);
background-color: $fallback--fg; background-color: $fallback--fg;
background-color: var(--tab, $fallback--fg); background-color: var(--tab, $fallback--fg);
&, &:active .tab-icon {
color: $fallback--text;
color: var(--tabText, $fallback--text);
}
&:not(.active) { &:not(.active) {
z-index: 4; z-index: 4;
@ -79,20 +196,41 @@
} }
} }
.tabs {
display: flex;
position: relative;
box-sizing: border-box;
&::after, &::before {
display: block;
flex: 1 1 auto;
}
}
.tab-wrapper {
position: relative;
display: flex;
flex: 0 0 auto;
&:not(.active) { &:not(.active) {
&::after { &::after {
content: ''; content: '';
position: absolute; position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 7; z-index: 7;
border-bottom: 1px solid;
border-bottom-color: $fallback--border;
border-bottom-color: var(--border, $fallback--border);
} }
} }
} }
.mobile-label {
padding-left: .3em;
padding-bottom: .25em;
margin-top: .5em;
margin-left: .2em;
margin-bottom: .25em;
border-bottom: 1px solid var(--border, $fallback--border);
@media all and (min-width: 800px) {
display: none;
}
} }
} }

View File

@ -9,7 +9,7 @@ import { mapGetters } from 'vuex'
export default { export default {
props: [ props: [
'user', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar' 'userId', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar'
], ],
data () { data () {
return { return {
@ -21,6 +21,12 @@ export default {
this.$store.dispatch('fetchUserRelationship', this.user.id) this.$store.dispatch('fetchUserRelationship', this.user.id)
}, },
computed: { computed: {
user () {
return this.$store.getters.findUser(this.userId)
},
relationship () {
return this.$store.getters.relationship(this.userId)
},
classes () { classes () {
return [{ return [{
'user-card-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius 'user-card-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius

View File

@ -50,15 +50,6 @@
> >
{{ user.name }} {{ user.name }}
</div> </div>
<router-link
v-if="!isOtherUser"
:to="{ name: 'user-settings' }"
>
<i
class="button-icon icon-wrench usersettings"
:title="$t('tool_tip.user_settings')"
/>
</router-link>
<a <a
v-if="isOtherUser && !user.is_local" v-if="isOtherUser && !user.is_local"
:href="user.statusnet_profile_url" :href="user.statusnet_profile_url"
@ -69,6 +60,7 @@
<AccountActions <AccountActions
v-if="isOtherUser && loggedIn" v-if="isOtherUser && loggedIn"
:user="user" :user="user"
:relationship="relationship"
/> />
</div> </div>
<div class="bottom-line"> <div class="bottom-line">
@ -92,7 +84,7 @@
</div> </div>
<div class="user-meta"> <div class="user-meta">
<div <div
v-if="user.follows_you && loggedIn && isOtherUser" v-if="relationship.followed_by && loggedIn && isOtherUser"
class="following" class="following"
> >
{{ $t('user_card.follows_you') }} {{ $t('user_card.follows_you') }}
@ -117,7 +109,7 @@
type="color" type="color"
> >
<label <label
for="style-switcher" for="theme_tab"
class="userHighlightSel select" class="userHighlightSel select"
> >
<select <select
@ -139,10 +131,10 @@
class="user-interactions" class="user-interactions"
> >
<div class="btn-group"> <div class="btn-group">
<FollowButton :user="user" /> <FollowButton :relationship="relationship" />
<template v-if="user.following"> <template v-if="relationship.following">
<ProgressButton <ProgressButton
v-if="!user.subscribed" v-if="!relationship.subscribing"
class="btn btn-default" class="btn btn-default"
:click="subscribeUser" :click="subscribeUser"
:title="$t('user_card.subscribe')" :title="$t('user_card.subscribe')"
@ -161,7 +153,7 @@
</div> </div>
<div> <div>
<button <button
v-if="user.muted" v-if="relationship.muting"
class="btn btn-default btn-block toggled" class="btn btn-default btn-block toggled"
@click="unmuteUser" @click="unmuteUser"
> >

View File

@ -6,7 +6,7 @@
class="panel panel-default signed-in" class="panel panel-default signed-in"
> >
<UserCard <UserCard
:user="user" :user-id="user.id"
:hide-bio="true" :hide-bio="true"
rounded="top" rounded="top"
/> />

View File

@ -3,6 +3,7 @@ import UserCard from '../user_card/user_card.vue'
import FollowCard from '../follow_card/follow_card.vue' import FollowCard from '../follow_card/follow_card.vue'
import Timeline from '../timeline/timeline.vue' import Timeline from '../timeline/timeline.vue'
import Conversation from '../conversation/conversation.vue' import Conversation from '../conversation/conversation.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
import List from '../list/list.vue' import List from '../list/list.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more' import withLoadMore from '../../hocs/with_load_more/with_load_more'
@ -146,6 +147,7 @@ const UserProfile = {
FollowerList, FollowerList,
FriendList, FriendList,
FollowCard, FollowCard,
TabSwitcher,
Conversation Conversation
} }
} }

View File

@ -5,7 +5,7 @@
class="user-profile panel panel-default" class="user-profile panel panel-default"
> >
<UserCard <UserCard
:user="user" :user-id="userId"
:switcher="true" :switcher="true"
:selected="timeline.viewing" :selected="timeline.viewing"
:allow-zooming-avatar="true" :allow-zooming-avatar="true"

View File

@ -1,393 +0,0 @@
import unescape from 'lodash/unescape'
import get from 'lodash/get'
import map from 'lodash/map'
import reject from 'lodash/reject'
import TabSwitcher from '../tab_switcher/tab_switcher.js'
import ImageCropper from '../image_cropper/image_cropper.vue'
import StyleSwitcher from '../style_switcher/style_switcher.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
import BlockCard from '../block_card/block_card.vue'
import MuteCard from '../mute_card/mute_card.vue'
import DomainMuteCard from '../domain_mute_card/domain_mute_card.vue'
import SelectableList from '../selectable_list/selectable_list.vue'
import ProgressButton from '../progress_button/progress_button.vue'
import EmojiInput from '../emoji_input/emoji_input.vue'
import suggestor from '../emoji_input/suggestor.js'
import Autosuggest from '../autosuggest/autosuggest.vue'
import Importer from '../importer/importer.vue'
import Exporter from '../exporter/exporter.vue'
import withSubscription from '../../hocs/with_subscription/with_subscription'
import Checkbox from '../checkbox/checkbox.vue'
import Mfa from './mfa.vue'
const BlockList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchBlocks'),
select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []),
childPropName: 'items'
})(SelectableList)
const MuteList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []),
childPropName: 'items'
})(SelectableList)
const DomainMuteList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchDomainMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'domainMutes', []),
childPropName: 'items'
})(SelectableList)
const UserSettings = {
data () {
return {
newEmail: '',
newName: this.$store.state.users.currentUser.name,
newBio: unescape(this.$store.state.users.currentUser.description),
newLocked: this.$store.state.users.currentUser.locked,
newNoRichText: this.$store.state.users.currentUser.no_rich_text,
newDefaultScope: this.$store.state.users.currentUser.default_scope,
hideFollows: this.$store.state.users.currentUser.hide_follows,
hideFollowers: this.$store.state.users.currentUser.hide_followers,
hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
hideFollowersCount: this.$store.state.users.currentUser.hide_followers_count,
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
discoverable: this.$store.state.users.currentUser.discoverable,
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
pickAvatarBtnVisible: true,
bannerUploading: false,
backgroundUploading: false,
banner: null,
bannerPreview: null,
background: null,
backgroundPreview: null,
bannerUploadError: null,
backgroundUploadError: null,
changeEmailError: false,
changeEmailPassword: '',
changedEmail: false,
deletingAccount: false,
deleteAccountConfirmPasswordInput: '',
deleteAccountError: false,
changePasswordInputs: [ '', '', '' ],
changedPassword: false,
changePasswordError: false,
activeTab: 'profile',
notificationSettings: this.$store.state.users.currentUser.notification_settings,
newDomainToMute: ''
}
},
created () {
this.$store.dispatch('fetchTokens')
},
components: {
StyleSwitcher,
ScopeSelector,
TabSwitcher,
ImageCropper,
BlockList,
MuteList,
DomainMuteList,
EmojiInput,
Autosuggest,
BlockCard,
MuteCard,
DomainMuteCard,
ProgressButton,
Importer,
Exporter,
Mfa,
Checkbox
},
computed: {
user () {
return this.$store.state.users.currentUser
},
emojiUserSuggestor () {
return suggestor({
emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
],
users: this.$store.state.users.users,
updateUsersList: (input) => this.$store.dispatch('searchUsers', input)
})
},
emojiSuggestor () {
return suggestor({ emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
] })
},
pleromaBackend () {
return this.$store.state.instance.pleromaBackend
},
minimalScopesMode () {
return this.$store.state.instance.minimalScopesMode
},
vis () {
return {
public: { selected: this.newDefaultScope === 'public' },
unlisted: { selected: this.newDefaultScope === 'unlisted' },
private: { selected: this.newDefaultScope === 'private' },
direct: { selected: this.newDefaultScope === 'direct' }
}
},
currentSaveStateNotice () {
return this.$store.state.interface.settings.currentSaveStateNotice
},
oauthTokens () {
return this.$store.state.oauthTokens.tokens.map(oauthToken => {
return {
id: oauthToken.id,
appName: oauthToken.app_name,
validUntil: new Date(oauthToken.valid_until).toLocaleDateString()
}
})
}
},
methods: {
updateProfile () {
this.$store.state.api.backendInteractor
.updateProfile({
params: {
note: this.newBio,
locked: this.newLocked,
// Backend notation.
/* eslint-disable camelcase */
display_name: this.newName,
default_scope: this.newDefaultScope,
no_rich_text: this.newNoRichText,
hide_follows: this.hideFollows,
hide_followers: this.hideFollowers,
discoverable: this.discoverable,
allow_following_move: this.allowFollowingMove,
hide_follows_count: this.hideFollowsCount,
hide_followers_count: this.hideFollowersCount,
show_role: this.showRole
/* eslint-enable camelcase */
} }).then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
updateNotificationSettings () {
this.$store.state.api.backendInteractor
.updateNotificationSettings({ settings: this.notificationSettings })
},
changeVis (visibility) {
this.newDefaultScope = visibility
},
uploadFile (slot, e) {
const file = e.target.files[0]
if (!file) { return }
if (file.size > this.$store.state.instance[slot + 'limit']) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit'])
this[slot + 'UploadError'] = this.$t('upload.error.base') + ' ' + this.$t('upload.error.file_too_big', { filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit })
return
}
// eslint-disable-next-line no-undef
const reader = new FileReader()
reader.onload = ({ target }) => {
const img = target.result
this[slot + 'Preview'] = img
this[slot] = file
}
reader.readAsDataURL(file)
},
submitAvatar (cropper, file) {
const that = this
return new Promise((resolve, reject) => {
function updateAvatar (avatar) {
that.$store.state.api.backendInteractor.updateAvatar({ avatar })
.then((user) => {
that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user)
resolve()
})
.catch((err) => {
reject(new Error(that.$t('upload.error.base') + ' ' + err.message))
})
}
if (cropper) {
cropper.getCroppedCanvas().toBlob(updateAvatar, file.type)
} else {
updateAvatar(file)
}
})
},
clearUploadError (slot) {
this[slot + 'UploadError'] = null
},
submitBanner () {
if (!this.bannerPreview) { return }
this.bannerUploading = true
this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.bannerPreview = null
})
.catch((err) => {
this.bannerUploadError = this.$t('upload.error.base') + ' ' + err.message
})
.then(() => { this.bannerUploading = false })
},
submitBg () {
if (!this.backgroundPreview) { return }
let background = this.background
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => {
if (!data.error) {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
} else {
this.backgroundUploadError = this.$t('upload.error.base') + data.error
}
this.backgroundUploading = false
})
},
importFollows (file) {
return this.$store.state.api.backendInteractor.importFollows({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
importBlocks (file) {
return this.$store.state.api.backendInteractor.importBlocks({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
generateExportableUsersContent (users) {
// Get addresses
return users.map((user) => {
// check is it's a local user
if (user && user.is_local) {
// append the instance address
// eslint-disable-next-line no-undef
return user.screen_name + '@' + location.hostname
}
return user.screen_name
}).join('\n')
},
getFollowsContent () {
return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id })
.then(this.generateExportableUsersContent)
},
getBlocksContent () {
return this.$store.state.api.backendInteractor.fetchBlocks()
.then(this.generateExportableUsersContent)
},
confirmDelete () {
this.deletingAccount = true
},
deleteAccount () {
this.$store.state.api.backendInteractor.deleteAccount({ password: this.deleteAccountConfirmPasswordInput })
.then((res) => {
if (res.status === 'success') {
this.$store.dispatch('logout')
this.$router.push({ name: 'root' })
} else {
this.deleteAccountError = res.error
}
})
},
changePassword () {
const params = {
password: this.changePasswordInputs[0],
newPassword: this.changePasswordInputs[1],
newPasswordConfirmation: this.changePasswordInputs[2]
}
this.$store.state.api.backendInteractor.changePassword(params)
.then((res) => {
if (res.status === 'success') {
this.changedPassword = true
this.changePasswordError = false
this.logout()
} else {
this.changedPassword = false
this.changePasswordError = res.error
}
})
},
changeEmail () {
const params = {
email: this.newEmail,
password: this.changeEmailPassword
}
this.$store.state.api.backendInteractor.changeEmail(params)
.then((res) => {
if (res.status === 'success') {
this.changedEmail = true
this.changeEmailError = false
} else {
this.changedEmail = false
this.changeEmailError = res.error
}
})
},
activateTab (tabName) {
this.activeTab = tabName
},
logout () {
this.$store.dispatch('logout')
this.$router.replace('/')
},
revokeToken (id) {
if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) {
this.$store.dispatch('revokeToken', id)
}
},
filterUnblockedUsers (userIds) {
return reject(userIds, (userId) => {
const user = this.$store.getters.findUser(userId)
return !user || user.statusnet_blocking || user.id === this.$store.state.users.currentUser.id
})
},
filterUnMutedUsers (userIds) {
return reject(userIds, (userId) => {
const user = this.$store.getters.findUser(userId)
return !user || user.muted || user.id === this.$store.state.users.currentUser.id
})
},
queryUserIds (query) {
return this.$store.dispatch('searchUsers', query)
.then((users) => map(users, 'id'))
},
blockUsers (ids) {
return this.$store.dispatch('blockUsers', ids)
},
unblockUsers (ids) {
return this.$store.dispatch('unblockUsers', ids)
},
muteUsers (ids) {
return this.$store.dispatch('muteUsers', ids)
},
unmuteUsers (ids) {
return this.$store.dispatch('unmuteUsers', ids)
},
unmuteDomains (domains) {
return this.$store.dispatch('unmuteDomains', domains)
},
muteDomain () {
return this.$store.dispatch('muteDomain', this.newDomainToMute)
.then(() => { this.newDomainToMute = '' })
},
identity (value) {
return value
}
}
}
export default UserSettings

View File

@ -1,716 +0,0 @@
<template>
<div class="settings panel panel-default">
<div class="panel-heading">
<div class="title">
{{ $t('settings.user_settings') }}
</div>
<transition name="fade">
<template v-if="currentSaveStateNotice">
<div
v-if="currentSaveStateNotice.error"
class="alert error"
@click.prevent
>
{{ $t('settings.saving_err') }}
</div>
<div
v-if="!currentSaveStateNotice.error"
class="alert transparent"
@click.prevent
>
{{ $t('settings.saving_ok') }}
</div>
</template>
</transition>
</div>
<div class="panel-body profile-edit">
<tab-switcher>
<div :label="$t('settings.profile_tab')">
<div class="setting-item">
<h2>{{ $t('settings.name_bio') }}</h2>
<p>{{ $t('settings.name') }}</p>
<EmojiInput
v-model="newName"
enable-emoji-picker
:suggest="emojiSuggestor"
>
<input
id="username"
v-model="newName"
classname="name-changer"
>
</EmojiInput>
<p>{{ $t('settings.bio') }}</p>
<EmojiInput
v-model="newBio"
enable-emoji-picker
:suggest="emojiUserSuggestor"
>
<textarea
v-model="newBio"
classname="bio"
/>
</EmojiInput>
<p>
<Checkbox v-model="newLocked">
{{ $t('settings.lock_account_description') }}
</Checkbox>
</p>
<div>
<label for="default-vis">{{ $t('settings.default_vis') }}</label>
<div
id="default-vis"
class="visibility-tray"
>
<scope-selector
:show-all="true"
:user-default="newDefaultScope"
:initial-scope="newDefaultScope"
:on-scope-change="changeVis"
/>
</div>
</div>
<p>
<Checkbox v-model="newNoRichText">
{{ $t('settings.no_rich_text_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="hideFollows">
{{ $t('settings.hide_follows_description') }}
</Checkbox>
</p>
<p class="setting-subitem">
<Checkbox
v-model="hideFollowsCount"
:disabled="!hideFollows"
>
{{ $t('settings.hide_follows_count_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="hideFollowers">
{{ $t('settings.hide_followers_description') }}
</Checkbox>
</p>
<p class="setting-subitem">
<Checkbox
v-model="hideFollowersCount"
:disabled="!hideFollowers"
>
{{ $t('settings.hide_followers_count_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="allowFollowingMove">
{{ $t('settings.allow_following_move') }}
</Checkbox>
</p>
<p v-if="role === 'admin' || role === 'moderator'">
<Checkbox v-model="showRole">
<template v-if="role === 'admin'">
{{ $t('settings.show_admin_badge') }}
</template>
<template v-if="role === 'moderator'">
{{ $t('settings.show_moderator_badge') }}
</template>
</Checkbox>
</p>
<p>
<Checkbox v-model="discoverable">
{{ $t('settings.discoverable') }}
</Checkbox>
</p>
<button
:disabled="newName && newName.length === 0"
class="btn btn-default"
@click="updateProfile"
>
{{ $t('general.submit') }}
</button>
</div>
<div class="setting-item">
<h2>{{ $t('settings.avatar') }}</h2>
<p class="visibility-notice">
{{ $t('settings.avatar_size_instruction') }}
</p>
<p>{{ $t('settings.current_avatar') }}</p>
<img
:src="user.profile_image_url_original"
class="current-avatar"
>
<p>{{ $t('settings.set_new_avatar') }}</p>
<button
v-show="pickAvatarBtnVisible"
id="pick-avatar"
class="btn"
type="button"
>
{{ $t('settings.upload_a_photo') }}
</button>
<image-cropper
trigger="#pick-avatar"
:submit-handler="submitAvatar"
@open="pickAvatarBtnVisible=false"
@close="pickAvatarBtnVisible=true"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.profile_banner') }}</h2>
<p>{{ $t('settings.current_profile_banner') }}</p>
<img
:src="user.cover_photo"
class="banner"
>
<p>{{ $t('settings.set_new_profile_banner') }}</p>
<img
v-if="bannerPreview"
class="banner"
:src="bannerPreview"
>
<div>
<input
type="file"
@change="uploadFile('banner', $event)"
>
</div>
<i
v-if="bannerUploading"
class=" icon-spin4 animate-spin uploading"
/>
<button
v-else-if="bannerPreview"
class="btn btn-default"
@click="submitBanner"
>
{{ $t('general.submit') }}
</button>
<div
v-if="bannerUploadError"
class="alert error"
>
Error: {{ bannerUploadError }}
<i
class="button-icon icon-cancel"
@click="clearUploadError('banner')"
/>
</div>
</div>
<div class="setting-item">
<h2>{{ $t('settings.profile_background') }}</h2>
<p>{{ $t('settings.set_new_profile_background') }}</p>
<img
v-if="backgroundPreview"
class="bg"
:src="backgroundPreview"
>
<div>
<input
type="file"
@change="uploadFile('background', $event)"
>
</div>
<i
v-if="backgroundUploading"
class=" icon-spin4 animate-spin uploading"
/>
<button
v-else-if="backgroundPreview"
class="btn btn-default"
@click="submitBg"
>
{{ $t('general.submit') }}
</button>
<div
v-if="backgroundUploadError"
class="alert error"
>
Error: {{ backgroundUploadError }}
<i
class="button-icon icon-cancel"
@click="clearUploadError('background')"
/>
</div>
</div>
</div>
<div :label="$t('settings.security_tab')">
<div class="setting-item">
<h2>{{ $t('settings.change_email') }}</h2>
<div>
<p>{{ $t('settings.new_email') }}</p>
<input
v-model="newEmail"
type="email"
autocomplete="email"
>
</div>
<div>
<p>{{ $t('settings.current_password') }}</p>
<input
v-model="changeEmailPassword"
type="password"
autocomplete="current-password"
>
</div>
<button
class="btn btn-default"
@click="changeEmail"
>
{{ $t('general.submit') }}
</button>
<p v-if="changedEmail">
{{ $t('settings.changed_email') }}
</p>
<template v-if="changeEmailError !== false">
<p>{{ $t('settings.change_email_error') }}</p>
<p>{{ changeEmailError }}</p>
</template>
</div>
<div class="setting-item">
<h2>{{ $t('settings.change_password') }}</h2>
<div>
<p>{{ $t('settings.current_password') }}</p>
<input
v-model="changePasswordInputs[0]"
type="password"
>
</div>
<div>
<p>{{ $t('settings.new_password') }}</p>
<input
v-model="changePasswordInputs[1]"
type="password"
>
</div>
<div>
<p>{{ $t('settings.confirm_new_password') }}</p>
<input
v-model="changePasswordInputs[2]"
type="password"
>
</div>
<button
class="btn btn-default"
@click="changePassword"
>
{{ $t('general.submit') }}
</button>
<p v-if="changedPassword">
{{ $t('settings.changed_password') }}
</p>
<p v-else-if="changePasswordError !== false">
{{ $t('settings.change_password_error') }}
</p>
<p v-if="changePasswordError">
{{ changePasswordError }}
</p>
</div>
<div class="setting-item">
<h2>{{ $t('settings.oauth_tokens') }}</h2>
<table class="oauth-tokens">
<thead>
<tr>
<th>{{ $t('settings.app_name') }}</th>
<th>{{ $t('settings.valid_until') }}</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="oauthToken in oauthTokens"
:key="oauthToken.id"
>
<td>{{ oauthToken.appName }}</td>
<td>{{ oauthToken.validUntil }}</td>
<td class="actions">
<button
class="btn btn-default"
@click="revokeToken(oauthToken.id)"
>
{{ $t('settings.revoke_token') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<mfa />
<div class="setting-item">
<h2>{{ $t('settings.delete_account') }}</h2>
<p v-if="!deletingAccount">
{{ $t('settings.delete_account_description') }}
</p>
<div v-if="deletingAccount">
<p>{{ $t('settings.delete_account_instructions') }}</p>
<p>{{ $t('login.password') }}</p>
<input
v-model="deleteAccountConfirmPasswordInput"
type="password"
>
<button
class="btn btn-default"
@click="deleteAccount"
>
{{ $t('settings.delete_account') }}
</button>
</div>
<p v-if="deleteAccountError !== false">
{{ $t('settings.delete_account_error') }}
</p>
<p v-if="deleteAccountError">
{{ deleteAccountError }}
</p>
<button
v-if="!deletingAccount"
class="btn btn-default"
@click="confirmDelete"
>
{{ $t('general.submit') }}
</button>
</div>
</div>
<div
v-if="pleromaBackend"
:label="$t('settings.notifications')"
>
<div class="setting-item">
<div class="select-multiple">
<span class="label">{{ $t('settings.notification_setting') }}</span>
<ul class="option-list">
<li>
<Checkbox v-model="notificationSettings.follows">
{{ $t('settings.notification_setting_follows') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationSettings.followers">
{{ $t('settings.notification_setting_followers') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationSettings.non_follows">
{{ $t('settings.notification_setting_non_follows') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationSettings.non_followers">
{{ $t('settings.notification_setting_non_followers') }}
</Checkbox>
</li>
</ul>
</div>
<p>{{ $t('settings.notification_mutes') }}</p>
<p>{{ $t('settings.notification_blocks') }}</p>
<button
class="btn btn-default"
@click="updateNotificationSettings"
>
{{ $t('general.submit') }}
</button>
</div>
</div>
<div
v-if="pleromaBackend"
:label="$t('settings.data_import_export_tab')"
>
<div class="setting-item">
<h2>{{ $t('settings.follow_import') }}</h2>
<p>{{ $t('settings.import_followers_from_a_csv_file') }}</p>
<Importer
:submit-handler="importFollows"
:success-message="$t('settings.follows_imported')"
:error-message="$t('settings.follow_import_error')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.follow_export') }}</h2>
<Exporter
:get-content="getFollowsContent"
filename="friends.csv"
:export-button-label="$t('settings.follow_export_button')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.block_import') }}</h2>
<p>{{ $t('settings.import_blocks_from_a_csv_file') }}</p>
<Importer
:submit-handler="importBlocks"
:success-message="$t('settings.blocks_imported')"
:error-message="$t('settings.block_import_error')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.block_export') }}</h2>
<Exporter
:get-content="getBlocksContent"
filename="blocks.csv"
:export-button-label="$t('settings.block_export_button')"
/>
</div>
</div>
<div :label="$t('settings.blocks_tab')">
<div class="profile-edit-usersearch-wrapper">
<Autosuggest
:filter="filterUnblockedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_block')"
>
<BlockCard
slot-scope="row"
:user-id="row.item"
/>
</Autosuggest>
</div>
<BlockList
:refresh="true"
:get-key="identity"
>
<template
slot="header"
slot-scope="{selected}"
>
<div class="profile-edit-bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => blockUsers(selected)"
>
{{ $t('user_card.block') }}
<template slot="progress">
{{ $t('user_card.block_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => unblockUsers(selected)"
>
{{ $t('user_card.unblock') }}
<template slot="progress">
{{ $t('user_card.unblock_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template
slot="item"
slot-scope="{item}"
>
<BlockCard :user-id="item" />
</template>
<template slot="empty">
{{ $t('settings.no_blocks') }}
</template>
</BlockList>
</div>
<div :label="$t('settings.mutes_tab')">
<tab-switcher>
<div label="Users">
<div class="profile-edit-usersearch-wrapper">
<Autosuggest
:filter="filterUnMutedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')"
>
<MuteCard
slot-scope="row"
:user-id="row.item"
/>
</Autosuggest>
</div>
<MuteList
:refresh="true"
:get-key="identity"
>
<template
slot="header"
slot-scope="{selected}"
>
<div class="profile-edit-bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => muteUsers(selected)"
>
{{ $t('user_card.mute') }}
<template slot="progress">
{{ $t('user_card.mute_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => unmuteUsers(selected)"
>
{{ $t('user_card.unmute') }}
<template slot="progress">
{{ $t('user_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template
slot="item"
slot-scope="{item}"
>
<MuteCard :user-id="item" />
</template>
<template slot="empty">
{{ $t('settings.no_mutes') }}
</template>
</MuteList>
</div>
<div :label="$t('settings.domain_mutes')">
<div class="profile-edit-domain-mute-form">
<input
v-model="newDomainToMute"
:placeholder="$t('settings.type_domains_to_mute')"
type="text"
@keyup.enter="muteDomain"
>
<ProgressButton
class="btn btn-default"
:click="muteDomain"
>
{{ $t('domain_mute_card.mute') }}
<template slot="progress">
{{ $t('domain_mute_card.mute_progress') }}
</template>
</ProgressButton>
</div>
<DomainMuteList
:refresh="true"
:get-key="identity"
>
<template
slot="header"
slot-scope="{selected}"
>
<div class="profile-edit-bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => unmuteDomains(selected)"
>
{{ $t('domain_mute_card.unmute') }}
<template slot="progress">
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template
slot="item"
slot-scope="{item}"
>
<DomainMuteCard :domain="item" />
</template>
<template slot="empty">
{{ $t('settings.no_mutes') }}
</template>
</DomainMuteList>
</div>
</tab-switcher>
</div>
</tab-switcher>
</div>
</div>
</template>
<script src="./user_settings.js">
</script>
<style lang="scss">
@import '../../_variables.scss';
.profile-edit {
.bio {
margin: 0;
}
.visibility-tray {
padding-top: 5px;
}
input[type=file] {
padding: 5px;
height: auto;
}
.banner {
max-width: 100%;
}
.uploading {
font-size: 1.5em;
margin: 0.25em;
}
.name-changer {
width: 100%;
}
.bg {
max-width: 100%;
}
.current-avatar {
display: block;
width: 150px;
height: 150px;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
}
.oauth-tokens {
width: 100%;
th {
text-align: left;
}
.actions {
text-align: right;
}
}
&-usersearch-wrapper {
padding: 1em;
}
&-bulk-actions {
text-align: right;
padding: 0 1em;
min-height: 28px;
button {
width: 10em;
}
}
&-domain-mute-form {
padding: 1em;
display: flex;
flex-direction: column;
button {
align-self: flex-end;
margin-top: 1em;
width: 10em;
}
}
.setting-subitem {
margin-left: 1.75em;
}
}
</style>

View File

@ -7,8 +7,8 @@
"gopher": "Gopher", "gopher": "Gopher",
"media_proxy": "Medienproxy", "media_proxy": "Medienproxy",
"scope_options": "Reichweitenoptionen", "scope_options": "Reichweitenoptionen",
"text_limit": "Textlimit", "text_limit": "Zeichenlimit",
"title": "Features", "title": "Funktionen",
"who_to_follow": "Wem folgen?" "who_to_follow": "Wem folgen?"
}, },
"finder": { "finder": {
@ -17,7 +17,18 @@
}, },
"general": { "general": {
"apply": "Anwenden", "apply": "Anwenden",
"submit": "Absenden" "submit": "Absenden",
"more": "Mehr",
"generic_error": "Ein Fehler ist aufgetreten",
"optional": "Optional",
"show_more": "Zeige mehr",
"show_less": "Zeige weniger",
"dismiss": "Ablehnen",
"cancel": "Abbrechen",
"disable": "Deaktivieren",
"enable": "Aktivieren",
"confirm": "Bestätigen",
"verify": "Verifizieren"
}, },
"login": { "login": {
"login": "Anmelden", "login": "Anmelden",
@ -26,7 +37,16 @@
"password": "Passwort", "password": "Passwort",
"placeholder": "z.B. lain", "placeholder": "z.B. lain",
"register": "Registrieren", "register": "Registrieren",
"username": "Benutzername" "username": "Benutzername",
"authentication_code": "Authentifizierungscode",
"enter_recovery_code": "Gebe einen Wiederherstellungscode ein",
"recovery_code": "Wiederherstellungscode",
"heading": {
"totp": "Zwei-Faktor Authentifizierung",
"recovery": "Zwei-Faktor Wiederherstellung"
},
"hint": "Anmelden um an der Diskussion teilzunehmen",
"enter_two_factor_code": "Gebe einen Zwei-Faktor-Code ein"
}, },
"nav": { "nav": {
"about": "Über", "about": "Über",
@ -41,7 +61,9 @@
"twkn": "Das gesamte bekannte Netzwerk", "twkn": "Das gesamte bekannte Netzwerk",
"user_search": "Benutzersuche", "user_search": "Benutzersuche",
"search": "Suche", "search": "Suche",
"preferences": "Voreinstellungen" "preferences": "Voreinstellungen",
"administration": "Administration",
"who_to_follow": "Wem folgen"
}, },
"notifications": { "notifications": {
"broken_favorite": "Unbekannte Nachricht, suche danach...", "broken_favorite": "Unbekannte Nachricht, suche danach...",
@ -50,7 +72,11 @@
"load_older": "Ältere Benachrichtigungen laden", "load_older": "Ältere Benachrichtigungen laden",
"notifications": "Benachrichtigungen", "notifications": "Benachrichtigungen",
"read": "Gelesen!", "read": "Gelesen!",
"repeated_you": "wiederholte deine Nachricht" "repeated_you": "wiederholte deine Nachricht",
"follow_request": "möchte dir folgen",
"migrated_to": "migrierte zu",
"reacted_with": "reagierte mit {0}",
"no_more_notifications": "Keine Benachrichtigungen mehr"
}, },
"post_status": { "post_status": {
"new_status": "Neuen Status veröffentlichen", "new_status": "Neuen Status veröffentlichen",
@ -58,7 +84,10 @@
"account_not_locked_warning_link": "gesperrt", "account_not_locked_warning_link": "gesperrt",
"attachments_sensitive": "Anhänge als heikel markieren", "attachments_sensitive": "Anhänge als heikel markieren",
"content_type": { "content_type": {
"text/plain": "Nur Text" "text/plain": "Nur Text",
"text/bbcode": "BBCode",
"text/markdown": "Markdown",
"text/html": "HTML"
}, },
"content_warning": "Betreff (optional)", "content_warning": "Betreff (optional)",
"default": "Sitze gerade im Hofbräuhaus.", "default": "Sitze gerade im Hofbräuhaus.",
@ -69,6 +98,13 @@
"private": "Nur Follower - Beitrag nur für Follower sichtbar", "private": "Nur Follower - Beitrag nur für Follower sichtbar",
"public": "Öffentlich - Beitrag an öffentliche Zeitleisten", "public": "Öffentlich - Beitrag an öffentliche Zeitleisten",
"unlisted": "Nicht gelistet - Nicht in öffentlichen Zeitleisten anzeigen" "unlisted": "Nicht gelistet - Nicht in öffentlichen Zeitleisten anzeigen"
},
"direct_warning_to_all": "Dieser Beitrag wird für alle erwähnten Benutzer sichtbar sein.",
"direct_warning_to_first_only": "Dieser Beitrag wird für alle Benutzer, die am Anfang der Nachricht erwähnt wurden, sichtbar sein.",
"scope_notice": {
"public": "Dieser Beitrag wird für alle sichtbar sein",
"private": "Dieser Beitrag wird nur für deine Follower sichtbar sein",
"unlisted": "Dieser Beitrag wird weder in der öffentlichen Zeitleiste noch im gesamten bekannten Netzwerk sichtbar sein"
} }
}, },
"registration": { "registration": {
@ -86,8 +122,11 @@
"email_required": "darf nicht leer sein", "email_required": "darf nicht leer sein",
"password_required": "darf nicht leer sein", "password_required": "darf nicht leer sein",
"password_confirmation_required": "darf nicht leer sein", "password_confirmation_required": "darf nicht leer sein",
"password_confirmation_match": "sollte mit dem Passwort identisch sein." "password_confirmation_match": "sollte mit dem Passwort identisch sein"
} },
"bio_placeholder": "z.B.\nHallo, ich bin Lain.\nIch bin ein Anime Mödchen aus dem vorstädtischen Japan. Du kennst mich vielleicht vom Wired.",
"fullname_placeholder": "z.B. Lain Iwakura",
"username_placeholder": "z.B. lain"
}, },
"settings": { "settings": {
"attachmentRadius": "Anhänge", "attachmentRadius": "Anhänge",
@ -99,7 +138,7 @@
"background": "Hintergrund", "background": "Hintergrund",
"bio": "Bio", "bio": "Bio",
"btnRadius": "Buttons", "btnRadius": "Buttons",
"cBlue": "Blau (Antworten, Folgt dir)", "cBlue": "Blau (Antworten, folgt dir)",
"cGreen": "Grün (Retweet)", "cGreen": "Grün (Retweet)",
"cOrange": "Orange (Favorisieren)", "cOrange": "Orange (Favorisieren)",
"cRed": "Rot (Abbrechen)", "cRed": "Rot (Abbrechen)",
@ -115,21 +154,21 @@
"data_import_export_tab": "Datenimport/-export", "data_import_export_tab": "Datenimport/-export",
"default_vis": "Standard-Sichtbarkeitsumfang", "default_vis": "Standard-Sichtbarkeitsumfang",
"delete_account": "Account löschen", "delete_account": "Account löschen",
"delete_account_description": "Lösche deinen Account und alle deine Nachrichten unwiderruflich.", "delete_account_description": "Lösche deine Daten und deaktiviere deinen Account unwiderruflich.",
"delete_account_error": "Es ist ein Fehler beim Löschen deines Accounts aufgetreten. Tritt dies weiterhin auf, wende dich an den Administrator der Instanz.", "delete_account_error": "Es ist ein Fehler beim Löschen deines Accounts aufgetreten. Tritt dies weiterhin auf, wende dich an den Administrator der Instanz.",
"delete_account_instructions": "Tippe dein Passwort unten in das Feld ein, um die Löschung deines Accounts zu bestätigen.", "delete_account_instructions": "Tippe dein Passwort unten in das Feld ein, um die Löschung deines Accounts zu bestätigen.",
"discoverable": "Erlaubnis für automatisches Suchen nach diesem Account", "discoverable": "Erlaube, dass dieser Account in Suchergebnissen auftaucht",
"avatar_size_instruction": "Die empfohlene minimale Größe für Avatare ist 150x150 Pixel.", "avatar_size_instruction": "Die empfohlene minimale Größe für Avatare ist 150x150 Pixel.",
"pad_emoji": "Emojis mit Leerzeichen umrahmen", "pad_emoji": "Emojis mit Leerzeichen umrahmen",
"export_theme": "Farbschema speichern", "export_theme": "Farbschema speichern",
"filtering": "Filtern", "filtering": "Filtern",
"filtering_explanation": "Alle Beiträge die diese Wörter enthalten werden ausgeblendet. Ein Wort pro Zeile.", "filtering_explanation": "Alle Beiträge, welche diese Wörter enthalten, werden ausgeblendet. Ein Wort pro Zeile.",
"follow_export": "Follower exportieren", "follow_export": "Follower exportieren",
"follow_export_button": "Exportiere deine Follows in eine csv-Datei", "follow_export_button": "Exportiere deine Follows in eine csv-Datei",
"follow_export_processing": "In Bearbeitung. Die Liste steht gleich zum herunterladen bereit.", "follow_export_processing": "In Bearbeitung. Die Liste steht gleich zum herunterladen bereit.",
"follow_import": "Followers importieren", "follow_import": "Follower importieren",
"follow_import_error": "Fehler beim importieren der Follower", "follow_import_error": "Fehler beim Importieren der Follower",
"follows_imported": "Followers importiert! Die Bearbeitung kann eine Zeit lang dauern.", "follows_imported": "Follower importiert! Die Bearbeitung kann einen Moment dauern.",
"foreground": "Vordergrund", "foreground": "Vordergrund",
"general": "Allgemein", "general": "Allgemein",
"hide_attachments_in_convo": "Anhänge in Unterhaltungen ausblenden", "hide_attachments_in_convo": "Anhänge in Unterhaltungen ausblenden",
@ -142,7 +181,7 @@
"hide_post_stats": "Beitragsstatistiken verbergen (z.B. die Anzahl der Favoriten)", "hide_post_stats": "Beitragsstatistiken verbergen (z.B. die Anzahl der Favoriten)",
"hide_user_stats": "Benutzerstatistiken verbergen (z.B. die Anzahl der Follower)", "hide_user_stats": "Benutzerstatistiken verbergen (z.B. die Anzahl der Follower)",
"hide_filtered_statuses": "Gefilterte Beiträge verbergen", "hide_filtered_statuses": "Gefilterte Beiträge verbergen",
"import_followers_from_a_csv_file": "Importiere Follower, denen du folgen möchtest, aus einer CSV-Datei", "import_followers_from_a_csv_file": "Importiere Follower aus einer CSV-Datei",
"import_theme": "Farbschema laden", "import_theme": "Farbschema laden",
"inputRadius": "Eingabefelder", "inputRadius": "Eingabefelder",
"checkboxRadius": "Auswahlfelder", "checkboxRadius": "Auswahlfelder",
@ -156,7 +195,7 @@
"lock_account_description": "Sperre deinen Account, um neue Follower zu genehmigen oder abzulehnen", "lock_account_description": "Sperre deinen Account, um neue Follower zu genehmigen oder abzulehnen",
"loop_video": "Videos wiederholen", "loop_video": "Videos wiederholen",
"loop_video_silent_only": "Nur Videos ohne Ton wiederholen (z.B. Mastodons \"gifs\")", "loop_video_silent_only": "Nur Videos ohne Ton wiederholen (z.B. Mastodons \"gifs\")",
"mutes_tab": "Mutes", "mutes_tab": "Stummschaltungen",
"play_videos_in_modal": "Videos in größerem Medienfenster abspielen", "play_videos_in_modal": "Videos in größerem Medienfenster abspielen",
"use_contain_fit": "Vorschaubilder nicht zuschneiden", "use_contain_fit": "Vorschaubilder nicht zuschneiden",
"name": "Name", "name": "Name",
@ -329,8 +368,44 @@
"checkbox": "Ich habe die Allgemeinen Geschäftsbedingungen überflogen", "checkbox": "Ich habe die Allgemeinen Geschäftsbedingungen überflogen",
"link": "ein netter kleiner Link" "link": "ein netter kleiner Link"
} }
},
"app_name": "Anwendungsname",
"mfa": {
"otp": "OTP",
"recovery_codes_warning": "Schreibe dir die Codes auf oder speichere sie an einem sicheren Ort - ansonsten wirst du sie nicht wiederfinden. Wenn du den Zugriff zu deiner 2FA App und die Wiederherstellungs-Codes verlierst, wirst du aus deinem Account ausgeschlossen sein.",
"recovery_codes": "Wiederherstellungs-Codes.",
"warning_of_generate_new_codes": "Wenn du neue Wiederherstellungs-Codes generierst, werden die alten Codes nicht mehr funktionieren.",
"generate_new_recovery_codes": "Generiere neue Wiederherstellungs-Codes",
"title": "Zwei-Faktor Authentifizierung",
"waiting_a_recovery_codes": "Erhalte Wiederherstellungscodes...",
"authentication_methods": "Authentifizierungsmethoden",
"scan": {
"title": "Scan",
"secret_code": "Schlüssel",
"desc": "Wenn du deine 2FA App verwendest, scanne diesen QR Code oder gebe den Schlüssel ein:"
},
"verify": {
"desc": "Um 2FA zu aktivieren, gib den Code von deiner 2FA-App ein:"
} }
}, },
"enter_current_password_to_confirm": "Gib dein aktuelles Passwort ein, um deine Identität zu bestätigen",
"security": "Sicherheit",
"allow_following_move": "Erlaube automatisches Folgen, sobald ein gefolgter Nutzer umzieht",
"blocks_imported": "Blocks importiert! Die Verarbeitung wird einen Moment brauchen.",
"block_import_error": "Fehler beim Importieren der Blocks",
"block_import": "Block Import",
"block_export_button": "Exportiere deine Blocks in eine csv Datei",
"block_export": "Block Export",
"emoji_reactions_on_timeline": "Zeige Emoji-Reaktionen auf der Zeitleiste",
"domain_mutes": "Domains",
"changed_email": "Email Adresse erfolgreich geändert!",
"change_email_error": "Es trat ein Problem auf beim Versuch, deine Email Adresse zu ändern.",
"change_email": "Ändere Email",
"notification_setting_non_followers": "Nutzer, die dir nicht folgen",
"notification_setting_followers": "Nutzer, die dir folgen",
"import_blocks_from_a_csv_file": "Importiere Blocks von einer CSV Datei",
"accent": "Akzent"
},
"timeline": { "timeline": {
"collapse": "Einklappen", "collapse": "Einklappen",
"conversation": "Unterhaltung", "conversation": "Unterhaltung",
@ -352,7 +427,7 @@
"follow_again": "Anfrage erneut senden?", "follow_again": "Anfrage erneut senden?",
"follow_unfollow": "Folgen beenden", "follow_unfollow": "Folgen beenden",
"followees": "Folgt", "followees": "Folgt",
"followers": "Followers", "followers": "Folgende",
"following": "Folgst du!", "following": "Folgst du!",
"follows_you": "Folgt dir!", "follows_you": "Folgt dir!",
"its_you": "Das bist du!", "its_you": "Das bist du!",
@ -360,7 +435,10 @@
"muted": "Stummgeschaltet", "muted": "Stummgeschaltet",
"per_day": "pro Tag", "per_day": "pro Tag",
"remote_follow": "Folgen", "remote_follow": "Folgen",
"statuses": "Beiträge" "statuses": "Beiträge",
"admin_menu": {
"sandbox": "Erzwinge Beiträge nur für Follower sichtbar zu sein"
}
}, },
"user_profile": { "user_profile": {
"timeline_title": "Beiträge" "timeline_title": "Beiträge"
@ -376,7 +454,7 @@
"favorite": "Favorisieren", "favorite": "Favorisieren",
"user_settings": "Benutzereinstellungen" "user_settings": "Benutzereinstellungen"
}, },
"upload":{ "upload": {
"error": { "error": {
"base": "Hochladen fehlgeschlagen.", "base": "Hochladen fehlgeschlagen.",
"file_too_big": "Datei ist zu groß [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", "file_too_big": "Datei ist zu groß [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
@ -409,5 +487,98 @@
"password_reset_disabled": "Passwortzurücksetzen deaktiviert. Bitte Administrator kontaktieren.", "password_reset_disabled": "Passwortzurücksetzen deaktiviert. Bitte Administrator kontaktieren.",
"password_reset_required": "Passwortzurücksetzen erforderlich", "password_reset_required": "Passwortzurücksetzen erforderlich",
"password_reset_required_but_mailer_is_disabled": "Passwortzurücksetzen wäre erforderlich, ist aber deaktiviert. Bitte Administrator kontaktieren." "password_reset_required_but_mailer_is_disabled": "Passwortzurücksetzen wäre erforderlich, ist aber deaktiviert. Bitte Administrator kontaktieren."
},
"about": {
"mrf": {
"federation": "Föderation",
"mrf_policies": "Aktivierte MRF Richtlinien",
"simple": {
"simple_policies": "Instanzspezifische Richtlinien",
"accept": "Akzeptieren",
"reject": "Ablehnen",
"reject_desc": "Diese Instanz akzeptiert keine Nachrichten der folgenden Instanzen:",
"quarantine": "Quarantäne",
"ftl_removal": "Von der Zeitleiste \"Das gesamte bekannte Netzwerk\" entfernen",
"media_removal": "Medienentfernung",
"media_removal_desc": "Diese Instanz entfernt Medien von den Beiträgen der folgenden Instanzen:",
"media_nsfw": "Erzwingen Medien als heikel zu makieren",
"media_nsfw_desc": "Diese Instanz makiert die Medien in Beiträgen der folgenden Instanzen als heikel:",
"accept_desc": "Diese Instanz akzeptiert nur Nachrichten von den folgenden Instanzen:",
"quarantine_desc": "Diese Instanz sendet nur öffentliche Beiträge zu den folgenden Instanzen:",
"ftl_removal_desc": "Dieser Instanz entfernt folgende Instanzen von der \"Das gesamte bekannte Netzwerk\" Zeitleiste:"
},
"keyword": {
"keyword_policies": "Keyword Richtlinien",
"reject": "Ablehnen",
"replace": "Ersetzen",
"is_replaced_by": "→",
"ftl_removal": "Von der Zeitleiste \"Das gesamte bekannte Netzwerk\" entfernen"
},
"mrf_policies_desc": "MRF Richtlinien manipulieren das Föderationsverhalten dieser Instanz. Die folgenden Richtlinien sind aktiv:"
},
"staff": "Mitarbeiter"
},
"domain_mute_card": {
"mute": "Stummschalten",
"mute_progress": "Wird stummgeschaltet..",
"unmute": "Stummschaltung aufheben",
"unmute_progress": "Stummschaltung wird aufgehoben.."
},
"exporter": {
"export": "Exportieren",
"processing": "Verarbeitung läuft, bald wird Du dazu aufgefordert, deine Datei herunterzuladen"
},
"image_cropper": {
"crop_picture": "Bild zuschneiden",
"save": "Speichern",
"cancel": "Abbrechen",
"save_without_cropping": "Ohne Zuschneiden speichern"
},
"importer": {
"submit": "Absenden",
"success": "Erfolgreich importiert.",
"error": "Ein Fehler ist beim Verabeiten der Datei aufgetreten."
},
"media_modal": {
"previous": "Zurück",
"next": "Weiter"
},
"polls": {
"add_poll": "Umfrage hinzufügen",
"add_option": "Option hinzufügen",
"option": "Option",
"votes": "Stimmen",
"vote": "Abstimmen",
"type": "Umfragetyp",
"multiple_choices": "Mehrere Auswahlmöglichkeiten",
"single_choice": "Eine Auswahlmöglichkeit",
"expiry": "Alter der Umfrage",
"expired": "Die Umfrage endete vor {0}",
"not_enough_options": "Zu wenig einzigartige Auswahlmöglichkeiten in der Umfrage",
"expires_in": "Die Umfrage endet in {0}"
},
"emoji": {
"stickers": "Sticker",
"emoji": "Emoji",
"search_emoji": "Nach einem Emoji suchen",
"custom": "Benutzerdefinierter Emoji",
"keep_open": "Auswahlfenster offen halten",
"add_emoji": "Emoji einfügen",
"load_all": "Lade alle {emojiAmount} Emoji",
"load_all_hint": "Erfolgreich erste {saneAmount} Emoji geladen, alle Emojis zu laden würde Leistungsprobleme hervorrufen.",
"unicode": "Unicode Emoji"
},
"interactions": {
"load_older": "Lade ältere Interaktionen",
"follows": "Neue Follows",
"favs_repeats": "Wiederholungen und Favoriten",
"moves": "Benutzer migriert zu"
},
"selectable_list": {
"select_all": "Wähle alle"
},
"remote_user_resolver": {
"searching_for": "Suche nach",
"error": "Nicht gefunden."
} }
} }

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