Merge branch 'direct-conversations' of https://git.pleroma.social/eugenijm/pleroma-fe into direct-conversations

This commit is contained in:
Absturztaube 2020-07-08 19:16:44 +02:00
commit 7fa2dfecc6
181 changed files with 12240 additions and 9377 deletions

View file

@ -4,18 +4,50 @@ 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 ### Changed
- Greentext now has separate color slot for it
- Removed the use of with_move parameters when fetching notifications - Removed the use of with_move parameters when fetching notifications
- Push notifications now are the same as normal notfication, and are localized.
### Fixed
- Weird bug related to post being sent seemingly after pasting with keyboard (hopefully)
- Multiple issues with muted statuses/notifications
## [Unreleased patch] ## [Unreleased patch]
### Add ### Add
- Added private notifications option for push notifications - Added private notifications option for push notifications
- 'Copy link' button for statuses (in the ellipsis menu) - 'Copy link' button for statuses (in the ellipsis menu)
- Autocomplete domains from list of known instances
- 'Bot' settings option and badge
- Added profile meta data fields that can be set in profile settings
- Added option to reset avatar/banner in profile settings
- Descriptions can be set on uploaded files before posting
- Added status preview option to preview your statuses before posting
- When a post is a reply to an unavailable post, the 'Reply to'-text has a strike-through style
### Changed ### Changed
- Registration page no longer requires email if the server is configured not to require it - 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
- When disabling attachments, the placeholder links now show an icon and the description instead of just IMAGE or VIDEO etc
### Fixed ### Fixed
- Custom Emoji will display in poll options now.
- Status ellipsis menu closes properly when selecting certain options - Status ellipsis menu closes properly when selecting certain options
- Cropped images look correct in Chrome
- Newlines in the muted words settings work again
- Clicking on non-latin hashtags won't open a new window
- Uploading and drag-dropping multiple files works correctly now.
- Subject field now appears disabled when posting
- Fix status ellipsis menu being cut off in notifications column
- Fixed autocomplete sometimes not returning the right user when there's already some results
- Videos and audio and misc files show description as alt/title properly now
- Clicking on non-image/video files no longer opens an empty modal
- Audio files can now be played back in the frontend with hidden attachments
- Videos are not cropped awkwardly in the uploads section anymore
- Reply filtering options in Settings -> Filtering now work again using filtering on server
- Don't show just blank-screen when cookies are disabled
## [2.0.3] - 2020-05-02 ## [2.0.3] - 2020-05-02
### Fixed ### Fixed
@ -77,6 +109,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Ability to change user's email - Ability to change user's email
- About page - About page
- Added remote user redirect - Added remote user redirect
- Bookmarks
### Changed ### Changed
- changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes - changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes
### Fixed ### 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

@ -8,8 +8,6 @@
> >
> --Catbag > --Catbag
Pleroma-FE user interface is modeled after Qvitter which is modeled after older Twitter design. It provides a simple 2-column interface for microblogging. While being simple by default it also provides many powerful customization options.
## Posting, reading, basic functions. ## Posting, reading, basic functions.
After registering and logging in you're presented with your timeline in right column and new post form with timeline list and notifications in the left column. After registering and logging in you're presented with your timeline in right column and new post form with timeline list and notifications in the left column.
@ -33,7 +31,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.

8
docs/index.md Normal file
View file

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

View file

@ -22,23 +22,21 @@
"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", "parse-link-header": "^1.0.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,12 +6,14 @@ 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'
import MobileNav from './components/mobile_nav/mobile_nav.vue' import MobileNav from './components/mobile_nav/mobile_nav.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue' import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
import PostStatusModal from './components/post_status_modal/post_status_modal.vue' import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
import { windowWidth, windowHeight } from './services/window_utils/window_utils' import { windowWidth, windowHeight } from './services/window_utils/window_utils'
export default { export default {
@ -29,8 +31,10 @@ export default {
SideDrawer, SideDrawer,
MobilePostStatusButton, MobilePostStatusButton,
MobileNav, MobileNav,
SettingsModal,
UserReportingModal, UserReportingModal,
PostStatusModal PostStatusModal,
GlobalNoticeList
}, },
data: () => ({ data: () => ({
mobileActivePanel: 'timeline', mobileActivePanel: 'timeline',
@ -46,7 +50,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 () {
@ -118,6 +123,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 layoutHeight = windowHeight() const layoutHeight = windowHeight()
@ -127,14 +135,5 @@ export default {
} }
this.$store.dispatch('setLayoutHeight', layoutHeight) this.$store.dispatch('setLayoutHeight', layoutHeight)
} }
},
watch: {
'$route' (to, from) {
if ((to.name === 'chat' && from.name === 'chats') || (to.name === 'chats' && from.name === 'chat')) {
this.transitionName = 'none'
} else {
this.transitionName = 'fade'
}
}
} }
} }

View file

@ -47,6 +47,7 @@ html {
} }
body { body {
overscroll-behavior-y: none;
font-family: sans-serif; font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif); font-family: var(--interfaceFont, sans-serif);
margin: 0; margin: 0;
@ -319,7 +320,7 @@ option {
i[class*=icon-] { i[class*=icon-] {
color: $fallback--icon; color: $fallback--icon;
color: var(--icon, $fallback--icon) color: var(--icon, $fallback--icon);
} }
.btn-block { .btn-block {
@ -566,7 +567,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;
} }
@ -858,53 +859,12 @@ nav {
display: block; display: block;
margin-right: 0.8em; margin-right: 0.8em;
} }
}
.setting-item { .main {
border-bottom: 2px solid var(--fg, $fallback--fg); margin-bottom: 7em;
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 {
@ -970,24 +930,50 @@ nav {
background-color: var(--panel, $fallback--fg); background-color: var(--panel, $fallback--fg);
} }
.alert-dot-number { .unread-chat-count {
display: inline-block; font-size: 0.9em;
border-radius: 1em; font-weight: bolder;
font-style: normal;
position: absolute;
right: 0.6rem;
padding: 0 0.3em;
min-width: 1.3rem; min-width: 1.3rem;
min-height: 1.3rem; min-height: 1.3rem;
max-height: 1.3rem; max-height: 1.3rem;
font-size: 0.9em;
font-weight: bolder;
line-height: 1.3rem; line-height: 1.3rem;
text-align: center; }
vertical-align: middle;
white-space: nowrap; .chat-layout {
padding: 0 0.3em; // Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens).
position: absolute; overflow: hidden;
right: 0.6rem; height: 100%;
background-color: $fallback--cRed;
background-color: var(--badgeNotification, $fallback--cRed); // Ensures the fixed position of the mobile browser bars on scroll up / down events.
color: white; // Prevents the mobile browser bars from overlapping or hiding the message posting form.
color: var(--badgeNotificationText, white); @media all and (max-width: 800px) {
font-style: normal; body {
height: 100%;
}
#app {
height: 100%;
overflow: hidden;
min-height: auto;
}
#app_bg_wrapper {
overflow: hidden;
}
.main {
overflow: hidden;
height: 100%;
}
#content {
padding-top: 0;
height: 100%;
overflow: visible;
}
}
} }

View file

@ -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"
@ -112,9 +113,7 @@
{{ $t("login.hint") }} {{ $t("login.hint") }}
</router-link> </router-link>
</div> </div>
<transition :name="transitionName"> <router-view />
<router-view />
</transition>
</div> </div>
<media-modal /> <media-modal />
</div> </div>
@ -126,7 +125,9 @@
<MobilePostStatusButton /> <MobilePostStatusButton />
<UserReportingModal /> <UserReportingModal />
<PostStatusModal /> <PostStatusModal />
<SettingsModal />
<portal-target name="modal" /> <portal-target name="modal" />
<GlobalNoticeList />
</div> </div>
</template> </template>

View file

@ -8,38 +8,64 @@ import backendInteractorService from '../services/backend_interactor_service/bac
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js' import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import { applyTheme } from '../services/style_setter/style_setter.js' import { applyTheme } from '../services/style_setter/style_setter.js'
const getStatusnetConfig = async ({ store }) => { let staticInitialResults = null
const parsedInitialResults = () => {
if (!document.getElementById('initial-results')) {
return null
}
if (!staticInitialResults) {
staticInitialResults = JSON.parse(document.getElementById('initial-results').textContent)
}
return staticInitialResults
}
const preloadFetch = async (request) => {
const data = parsedInitialResults()
if (!data || !data[request]) {
return window.fetch(request)
}
const requestData = JSON.parse(atob(data[request]))
return {
ok: true,
json: () => requestData,
text: () => requestData
}
}
const getInstanceConfig = async ({ store }) => {
try { try {
const res = await window.fetch('/api/statusnet/config.json') const res = await preloadFetch('/api/v1/instance')
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey, safeDMMentionsEnabled } = data.site const textlimit = data.max_toot_chars
const vapidPublicKey = data.pleroma.vapid_public_key
store.dispatch('setInstanceOption', { name: 'name', value: name }) store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') })
store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) })
store.dispatch('setInstanceOption', { name: 'server', value: server })
store.dispatch('setInstanceOption', { name: 'safeDM', value: safeDMMentionsEnabled !== '0' })
// TODO: default values for this stuff, added if to not make it break on
// my dev config out of the box.
if (uploadlimit) {
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadlimit.uploadlimit) })
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadlimit.avatarlimit) })
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadlimit.backgroundlimit) })
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadlimit.bannerlimit) })
}
if (vapidPublicKey) { if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey }) store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
} }
return data.site.pleromafe
} else { } else {
throw (res) throw (res)
} }
} catch (error) { } catch (error) {
console.error('Could not load statusnet config, potentially fatal') console.error('Could not load instance config, potentially fatal')
console.error(error)
}
}
const getBackendProvidedConfig = async ({ store }) => {
try {
const res = await window.fetch('/api/pleroma/frontend_configurations')
if (res.ok) {
const data = await res.json()
return data.pleroma_fe
} else {
throw (res)
}
} catch (error) {
console.error('Could not load backend-provided frontend config, potentially fatal')
console.error(error) console.error(error)
} }
} }
@ -132,7 +158,7 @@ const getTOS = async ({ store }) => {
const getInstancePanel = async ({ store }) => { const getInstancePanel = async ({ store }) => {
try { try {
const res = await window.fetch('/instance/panel.html') const res = await preloadFetch('/instance/panel.html')
if (res.ok) { if (res.ok) {
const html = await res.text() const html = await res.text()
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html }) store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
@ -189,24 +215,34 @@ const getAppSecret = async ({ store }) => {
const resolveStaffAccounts = ({ store, accounts }) => { const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map(uri => uri.split('/').pop()) const nicknames = accounts.map(uri => uri.split('/').pop())
nicknames.map(nickname => store.dispatch('fetchUser', nickname))
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames }) store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
} }
const getNodeInfo = async ({ store }) => { const getNodeInfo = async ({ store }) => {
try { try {
const res = await window.fetch('/nodeinfo/2.0.json') const res = await preloadFetch('/nodeinfo/2.0.json')
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
const metadata = data.metadata const metadata = data.metadata
const features = metadata.features const features = metadata.features
store.dispatch('setInstanceOption', { name: 'name', value: metadata.nodeName })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations })
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') }) store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') }) store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled }) store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
const uploadLimits = metadata.uploadLimits
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) })
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadLimits.avatar) })
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadLimits.background) })
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadLimits.banner) })
store.dispatch('setInstanceOption', { name: 'fieldsLimits', value: metadata.fieldsLimits })
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames }) store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats }) store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
@ -257,7 +293,7 @@ const getNodeInfo = async ({ store }) => {
const setConfig = async ({ store }) => { const setConfig = async ({ store }) => {
// apiConfig, staticConfig // apiConfig, staticConfig
const configInfos = await Promise.all([getStatusnetConfig({ store }), getStaticConfig()]) const configInfos = await Promise.all([getBackendProvidedConfig({ store }), getStaticConfig()])
const apiConfig = configInfos[0] const apiConfig = configInfos[0]
const staticConfig = configInfos[1] const staticConfig = configInfos[1]
@ -280,6 +316,11 @@ const checkOAuthToken = async ({ store }) => {
const afterStoreSetup = async ({ store, i18n }) => { const afterStoreSetup = async ({ store, i18n }) => {
const width = windowWidth() const width = windowWidth()
store.dispatch('setMobileLayout', width <= 800) store.dispatch('setMobileLayout', width <= 800)
const overrides = window.___pleromafe_dev_overrides || {}
const server = (typeof overrides.target !== 'undefined') ? overrides.target : window.location.origin
store.dispatch('setInstanceOption', { name: 'server', value: server })
await setConfig({ store }) await setConfig({ store })
const { customTheme, customThemeSource } = store.state.config const { customTheme, customThemeSource } = store.state.config
@ -299,16 +340,18 @@ const afterStoreSetup = async ({ store, i18n }) => {
} }
// Now we can try getting the server settings and logging in // Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized
await Promise.all([ await Promise.all([
checkOAuthToken({ store }), checkOAuthToken({ store }),
getTOS({ store }),
getInstancePanel({ store }), getInstancePanel({ store }),
getStickers({ store }), getNodeInfo({ store }),
getNodeInfo({ store }) getInstanceConfig({ store })
]) ])
// Start fetching things that don't need to block the UI // Start fetching things that don't need to block the UI
store.dispatch('fetchMutes') store.dispatch('fetchMutes')
getTOS({ store })
getStickers({ store })
const router = new VueRouter({ const router = new VueRouter({
mode: 'history', mode: 'history',

View file

@ -2,6 +2,7 @@ import PublicTimeline from 'components/public_timeline/public_timeline.vue'
import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue' import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue' import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
import TagTimeline from 'components/tag_timeline/tag_timeline.vue' import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue'
import ConversationPage from 'components/conversation-page/conversation-page.vue' import ConversationPage from 'components/conversation-page/conversation-page.vue'
import Interactions from 'components/interactions/interactions.vue' import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue' import DMs from 'components/dm_timeline/dm_timeline.vue'
@ -9,10 +10,8 @@ import ChatList from 'components/chat_list/chat_list.vue'
import Chat from 'components/chat/chat.vue' import Chat from 'components/chat/chat.vue'
import UserProfile from 'components/user_profile/user_profile.vue' import UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue' import 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'
@ -31,7 +30,7 @@ export default (store) => {
} }
} }
return [ let routes = [
{ name: 'root', { name: 'root',
path: '/', path: '/',
redirect: _to => { redirect: _to => {
@ -44,6 +43,7 @@ export default (store) => {
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline }, { name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute }, { name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline }, { name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } }, { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'remote-user-profile-acct', { name: 'remote-user-profile-acct',
path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)', path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)',
@ -58,21 +58,26 @@ 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: 'chat', path: '/users/:username/chats/:recipient_id', component: Chat, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute },
{ name: 'chats', path: '/users/:username/chats', component: ChatList, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute },
{ 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-panel', path: '/chat-panel', component: ChatPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) }, { name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) }, { name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute }, { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About }, { name: 'about', path: '/about', component: About },
{ name: 'user-profile', path: '/(users/)?:name', component: UserProfile } { name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
] ]
if (store.state.instance.pleromaChatMessagesAvailable) {
routes = routes.concat([
{ name: 'chat', path: '/users/:username/chats/:recipient_id', component: Chat, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute },
{ name: 'chats', path: '/users/:username/chats', component: ChatList, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute }
])
}
return routes
} }

View file

@ -1,3 +1,4 @@
import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue' import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue' import Popover from '../popover/popover.vue'
@ -34,6 +35,11 @@ const AccountActions = {
params: { recipient_id: this.user.id } params: { recipient_id: this.user.id }
}) })
} }
},
computed: {
...mapState({
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
})
} }
} }

View file

@ -3,6 +3,7 @@
<Popover <Popover
trigger="click" trigger="click"
placement="bottom" placement="bottom"
:bound-to="{ x: 'container' }"
> >
<div <div
slot="content" slot="content"
@ -50,6 +51,7 @@
{{ $t('user_card.report') }} {{ $t('user_card.report') }}
</button> </button>
<button <button
v-if="pleromaChatMessagesAvailable"
class="btn btn-default btn-block dropdown-item" class="btn btn-default btn-block dropdown-item"
@click="openChat" @click="openChat"
> >

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

@ -8,7 +8,6 @@ const Attachment = {
props: [ props: [
'attachment', 'attachment',
'nsfw', 'nsfw',
'statusId',
'size', 'size',
'allowPlay', 'allowPlay',
'setMedia', 'setMedia',
@ -30,9 +29,21 @@ const Attachment = {
VideoAttachment VideoAttachment
}, },
computed: { computed: {
usePlaceHolder () { usePlaceholder () {
return this.size === 'hide' || this.type === 'unknown' return this.size === 'hide' || this.type === 'unknown'
}, },
placeholderName () {
if (this.attachment.description === '' || !this.attachment.description) {
return this.type.toUpperCase()
}
return this.attachment.description
},
placeholderIconClass () {
if (this.type === 'image') return 'icon-picture'
if (this.type === 'video') return 'icon-video'
if (this.type === 'audio') return 'icon-music'
return 'icon-doc'
},
referrerpolicy () { referrerpolicy () {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer' return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
}, },
@ -49,7 +60,15 @@ const Attachment = {
return this.size === 'small' return this.size === 'small'
}, },
fullwidth () { fullwidth () {
return this.type === 'html' || this.type === 'audio' if (this.size === 'hide') return false
return this.type === 'html' || this.type === 'audio' || this.type === 'unknown'
},
useModal () {
const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio']
: this.mergedConfig.playVideosInModal
? ['image', 'video']
: ['image']
return modalTypes.includes(this.type)
}, },
...mapGetters(['mergedConfig']) ...mapGetters(['mergedConfig'])
}, },
@ -60,12 +79,7 @@ const Attachment = {
} }
}, },
openModal (event) { openModal (event) {
const modalTypes = this.mergedConfig.playVideosInModal if (this.useModal) {
? ['image', 'video']
: ['image']
if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) ||
this.usePlaceHolder
) {
event.stopPropagation() event.stopPropagation()
event.preventDefault() event.preventDefault()
this.setMedia() this.setMedia()

View file

@ -1,6 +1,7 @@
<template> <template>
<div <div
v-if="usePlaceHolder" v-if="usePlaceholder"
:class="{ 'fullwidth': fullwidth }"
@click="openModal" @click="openModal"
> >
<a <a
@ -8,8 +9,11 @@
class="placeholder" class="placeholder"
target="_blank" target="_blank"
:href="attachment.url" :href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
> >
[{{ nsfw ? "NSFW/" : "" }}{{ type.toUpperCase() }}] <span :class="placeholderIconClass" />
<b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }}
</a> </a>
</div> </div>
<div <div
@ -22,6 +26,8 @@
v-if="hidden" v-if="hidden"
class="image-attachment" class="image-attachment"
:href="attachment.url" :href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
@click.prevent="toggleHidden" @click.prevent="toggleHidden"
> >
<img <img
@ -51,7 +57,6 @@
:class="{'hidden': hidden && preloadImage }" :class="{'hidden': hidden && preloadImage }"
:href="attachment.url" :href="attachment.url"
target="_blank" target="_blank"
:title="attachment.description"
@click="openModal" @click="openModal"
> >
<StillImage <StillImage
@ -59,6 +64,7 @@
:mimetype="attachment.mimetype" :mimetype="attachment.mimetype"
:src="attachment.large_thumb_url || attachment.url" :src="attachment.large_thumb_url || attachment.url"
:image-load-handler="onImageLoad" :image-load-handler="onImageLoad"
:alt="attachment.description"
/> />
</a> </a>
@ -83,6 +89,8 @@
<audio <audio
v-if="type === 'audio'" v-if="type === 'audio'"
:src="attachment.url" :src="attachment.url"
:alt="attachment.description"
:title="attachment.description"
controls controls
/> />
@ -116,22 +124,19 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
.attachment.media-upload-container { .non-gallery {
flex: 0 0 auto;
max-height: 200px;
max-width: 100%; max-width: 100%;
display: flex;
align-items: center;
video {
max-width: 100%;
}
} }
.placeholder { .placeholder {
margin-right: 8px; display: inline-block;
margin-bottom: 4px; padding: 0.3em 1em 0.3em 0;
color: $fallback--link; color: $fallback--link;
color: var(--postLink, $fallback--link); color: var(--postLink, $fallback--link);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
} }
.nsfw-placeholder { .nsfw-placeholder {

View file

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

View file

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

View file

@ -1,32 +1,29 @@
import _ from 'lodash' import _ from 'lodash'
import { WSConnectionStatus } from '../../services/api/api.service.js'
import { mapGetters, mapState } from 'vuex' import { mapGetters, mapState } from 'vuex'
import ChatMessage from '../chat_message/chat_message.vue' import ChatMessage from '../chat_message/chat_message.vue'
import ChatAvatar from '../chat_avatar/chat_avatar.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue' import PostStatusForm from '../post_status_form/post_status_form.vue'
import ChatTitle from '../chat_title/chat_title.vue' import ChatTitle from '../chat_title/chat_title.vue'
import chatService from '../../services/chat_service/chat_service.js' import chatService from '../../services/chat_service/chat_service.js'
import ChatLayout from './chat_layout.js' import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight } from './chat_layout_utils.js'
const BOTTOMED_OUT_OFFSET = 10
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
const SAFE_RESIZE_TIME_OFFSET = 100
const Chat = { const Chat = {
components: { components: {
ChatMessage, ChatMessage,
ChatTitle, ChatTitle,
ChatAvatar,
PostStatusForm PostStatusForm
}, },
mixins: [ChatLayout],
data () { data () {
return { return {
loadingOlderMessages: false,
loadingMessages: true,
loadingChat: false,
editedStatusId: undefined,
fetcher: undefined,
jumpToBottomButtonVisible: false, jumpToBottomButtonVisible: false,
mobileLayout: this.$store.state.interface.mobileLayout, hoveredMessageChainId: undefined,
recipientId: this.$route.params.recipient_id, lastScrollPosition: {},
hoveredSequenceId: undefined, scrollableContainerHeight: '100%',
lastPosition: undefined errorLoadingChat: false
} }
}, },
created () { created () {
@ -34,20 +31,16 @@ const Chat = {
window.addEventListener('resize', this.handleLayoutChange) window.addEventListener('resize', this.handleLayoutChange)
}, },
mounted () { mounted () {
window.addEventListener('scroll', this.handleScroll)
if (typeof document.hidden !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
}
this.$nextTick(() => { this.$nextTick(() => {
let scrollable = this.$refs.scrollable this.updateScrollableContainerHeight()
if (scrollable) {
window.addEventListener('scroll', this.handleScroll)
}
this.updateSize()
this.handleResize() this.handleResize()
}) })
this.setChatLayout() this.setChatLayout()
if (typeof document.hidden !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
this.$store.commit('setChatFocused', !document.hidden)
}
}, },
destroyed () { destroyed () {
window.removeEventListener('scroll', this.handleScroll) window.removeEventListener('scroll', this.handleScroll)
@ -57,26 +50,17 @@ const Chat = {
this.$store.dispatch('clearCurrentChat') this.$store.dispatch('clearCurrentChat')
}, },
computed: { computed: {
chatParticipants () {
if (this.currentChat) {
return [this.currentChat.account]
} else {
const user = this.findUser(this.recipientId)
if (user) {
return [user]
} else {
return []
}
}
},
recipient () { recipient () {
return this.currentChat && this.currentChat.account return this.currentChat && this.currentChat.account
}, },
recipientId () {
return this.$route.params.recipient_id
},
formPlaceholder () { formPlaceholder () {
if (this.recipient) { if (this.recipient) {
return this.$t('chats.message_user', { nickname: this.recipient.screen_name }) return this.$t('chats.message_user', { nickname: this.recipient.screen_name })
} else { } else {
return this.$t('chats.write_message') return ''
} }
}, },
chatViewItems () { chatViewItems () {
@ -85,155 +69,148 @@ const Chat = {
newMessageCount () { newMessageCount () {
return this.currentChatMessageService && this.currentChatMessageService.newMessageCount return this.currentChatMessageService && this.currentChatMessageService.newMessageCount
}, },
streamingEnabled () {
return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
},
...mapGetters([ ...mapGetters([
'currentChat', 'currentChat',
'currentChatMessageService', 'currentChatMessageService',
'findUser',
'findOpenedChatByRecipientId', 'findOpenedChatByRecipientId',
'mergedConfig' 'mergedConfig'
]), ]),
...mapState({ ...mapState({
backendInteractor: state => state.api.backendInteractor, backendInteractor: state => state.api.backendInteractor,
currentUser: state => state.users.currentUser, mastoUserSocketStatus: state => state.api.mastoUserSocketStatus,
isMobileLayout: state => state.interface.mobileLayout, mobileLayout: state => state.interface.mobileLayout,
openedChats: state => state.chats.openedChats, layoutHeight: state => state.interface.layoutHeight,
openedChatMessageServices: state => state.chats.openedChatMessageServices, currentUser: state => state.users.currentUser
windowHeight: state => state.interface.layoutHeight
}) })
}, },
watch: { watch: {
chatViewItems (prev, next) { chatViewItems () {
let bottomedOut = this.bottomedOut(10) // We don't want to scroll to the bottom on a new message when the user is viewing older messages.
// Therefore we need to know whether the scroll position was at the bottom before the DOM update.
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
this.$nextTick(() => { this.$nextTick(() => {
if (bottomedOut && prev.length !== next.length) { if (bottomedOutBeforeUpdate) {
this.scrollDown({ forceRead: true }) this.scrollDown({ forceRead: !document.hidden })
} }
}) })
}, },
'$route': function (prev, next) { '$route': function () {
this.recipientId = this.$route.params.recipient_id
this.startFetching() this.startFetching()
}, },
windowHeight () { layoutHeight () {
this.handleResize({ expand: true }) this.handleResize({ expand: true })
},
mastoUserSocketStatus (newValue) {
if (newValue === WSConnectionStatus.JOINED) {
this.fetchChat({ isFirstFetch: true })
}
} }
}, },
methods: { methods: {
onStatusHover ({ state, sequenceId }) { // Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
this.hoveredSequenceId = state ? sequenceId : undefined onMessageHover ({ isHovered, messageChainId }) {
}, this.hoveredMessageChainId = isHovered ? messageChainId : undefined
onPosted (data) {
this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => {
this.$nextTick(() => {
this.updateSize()
this.scrollDown({ forceRead: true })
})
})
}, },
onFilesDropped () { onFilesDropped () {
this.$nextTick(() => { this.$nextTick(() => {
this.updateSize() this.handleResize()
this.updateScrollableContainerHeight()
}) })
}, },
handleVisibilityChange () { handleVisibilityChange () {
this.$store.commit('setChatFocused', !document.hidden)
},
handleLayoutChange () {
this.updateSize()
let mobileLayout = this.isMobileLayout
if (this.mobileLayout !== mobileLayout) {
if (this.mobileLayout === false && mobileLayout === true) {
this.setMobileChatLayout()
}
if (this.mobileLayout === true && mobileLayout === false) {
this.unsetMobileChatLayout()
}
this.mobileLayout = this.isMobileLayout
this.$nextTick(() => {
this.updateSize()
this.scrollDown()
})
}
},
handleResize (opts) {
this.$nextTick(() => { this.$nextTick(() => {
this.updateSize() if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
this.scrollDown({ forceRead: true })
let prevOffsetHeight
if (this.lastPosition) {
prevOffsetHeight = this.lastPosition.offsetHeight
}
this.lastPosition = {
scrollTop: this.$refs.scrollable.scrollTop,
totalHeight: this.$refs.scrollable.scrollHeight,
offsetHeight: this.$refs.scrollable.offsetHeight
}
if (this.lastPosition) {
const diff = this.lastPosition.offsetHeight - prevOffsetHeight
const bottomedOut = this.bottomedOut()
if (diff < 0 || (!bottomedOut && opts && opts.expand)) {
this.$nextTick(() => {
this.updateSize()
this.$nextTick(() => {
this.$refs.scrollable.scrollTo({
top: this.$refs.scrollable.scrollTop - diff,
left: 0
})
})
})
}
} }
}) })
}, },
updateSize (newHeight, _diff) { setChatLayout () {
let h = this.$refs.header // This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app).
let s = this.$refs.scrollable // This layout prevents empty spaces from being visible at the bottom
let f = this.$refs.footer // of the chat on iOS Safari (`safe-area-inset`) when
if (h && s && f) { // - the on-screen keyboard appears and the user starts typing
let height = 0 // - the user selects the text inside the input area
if (this.isMobileLayout) { // - the user selects and deletes the text that is multiple lines long
height = parseFloat(getComputedStyle(window.document.body, null).height.replace('px', '')) // TODO: unify the chat layout with the global layout.
let newHeight = (height - h.clientHeight - f.clientHeight) let html = document.querySelector('html')
s.style.height = newHeight + 'px' if (html) {
} else { html.classList.add('chat-layout')
height = parseFloat(getComputedStyle(this.$refs.inner, null).height.replace('px', '')) }
let newHeight = (height - h.clientHeight - f.clientHeight)
s.style.height = newHeight + 'px' this.$nextTick(() => {
} this.updateScrollableContainerHeight()
})
},
unsetChatLayout () {
let html = document.querySelector('html')
if (html) {
html.classList.remove('chat-layout')
} }
}, },
handleLayoutChange () {
this.$nextTick(() => {
this.updateScrollableContainerHeight()
this.scrollDown()
})
},
// Ensures the proper position of the posting form in the mobile layout (the mobile browser panel does not overlap or hide it)
updateScrollableContainerHeight () {
const header = this.$refs.header
const footer = this.$refs.footer
const inner = this.mobileLayout ? window.document.body : this.$refs.inner
this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px'
},
// Preserves the scroll position when OSK appears or the posting form changes its height.
handleResize (opts = {}) {
const { expand = false, delayed = false } = opts
if (delayed) {
setTimeout(() => {
this.handleResize({ ...opts, delayed: false })
}, SAFE_RESIZE_TIME_OFFSET)
return
}
this.$nextTick(() => {
this.updateScrollableContainerHeight()
const { offsetHeight = undefined } = this.lastScrollPosition
this.lastScrollPosition = getScrollPosition(this.$refs.scrollable)
const diff = this.lastScrollPosition.offsetHeight - offsetHeight
if (diff < 0 || (!this.bottomedOut() && expand)) {
this.$nextTick(() => {
this.updateScrollableContainerHeight()
this.$refs.scrollable.scrollTo({
top: this.$refs.scrollable.scrollTop - diff,
left: 0
})
})
}
})
},
scrollDown (options = {}) { scrollDown (options = {}) {
let { behavior = 'auto', forceRead = false } = options const { behavior = 'auto', forceRead = false } = options
let container = this.$refs.scrollable const scrollable = this.$refs.scrollable
let scrollable = this.$refs.scrollable if (!scrollable) { return }
this.doScrollDown(scrollable, container, behavior) this.$nextTick(() => {
scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior })
})
if (forceRead || this.newMessageCount > 0) { if (forceRead || this.newMessageCount > 0) {
this.readChat() this.readChat()
} }
}, },
doScrollDown (scrollable, container, behavior) { readChat () {
if (!container) { return } if (!(this.currentChatMessageService && this.currentChatMessageService.lastMessage)) { return }
this.$nextTick(() => { if (document.hidden) { return }
scrollable.scrollTo({ top: container.scrollHeight, left: 0, behavior }) const lastReadId = this.currentChatMessageService.lastMessage.id
}) this.$store.dispatch('readChat', { id: this.currentChat.id, lastReadId })
}, },
bottomedOut (offset) { bottomedOut (offset) {
let bottomedOut = false return isBottomedOut(this.$refs.scrollable, offset)
if (this.$refs.scrollable) {
let scrollHeight = this.$refs.scrollable.scrollTop + (offset || 0)
let totalHeight = this.$refs.scrollable.scrollHeight - this.$refs.scrollable.offsetHeight
bottomedOut = totalHeight <= scrollHeight
}
return bottomedOut
},
getPosition () {
let scrollHeight = this.$refs.scrollable.scrollTop
let totalHeight = this.$refs.scrollable.scrollHeight - this.$refs.scrollable.offsetHeight
return { scrollHeight, totalHeight }
}, },
reachedTop () { reachedTop () {
const scrollable = this.$refs.scrollable const scrollable = this.$refs.scrollable
@ -243,10 +220,8 @@ const Chat = {
if (!this.currentChat) { return } if (!this.currentChat) { return }
if (this.reachedTop()) { if (this.reachedTop()) {
this.fetchChat(false, this.currentChat.id, { this.fetchChat({ maxId: this.currentChatMessageService.minId })
maxId: this.currentChatMessageService.minId } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
})
} else if (this.bottomedOut(150)) {
this.jumpToBottomButtonVisible = false this.jumpToBottomButtonVisible = false
if (this.newMessageCount > 0) { if (this.newMessageCount > 0) {
this.readChat() this.readChat()
@ -255,95 +230,102 @@ const Chat = {
this.jumpToBottomButtonVisible = true this.jumpToBottomButtonVisible = true
} }
}, 100), }, 100),
goBack () { handleScrollUp (positionBeforeLoading) {
this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } }) const positionAfterLoading = getScrollPosition(this.$refs.scrollable)
this.$refs.scrollable.scrollTo({
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
left: 0
})
}, },
fetchChat (isFirstFetch, chatId, opts = {}) { fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) {
const chatMessageService = this.openedChatMessageServices[chatId] const chatMessageService = this.currentChatMessageService
let maxId = opts.maxId if (!chatMessageService) { return }
let sinceId if (fetchLatest && this.streamingEnabled) { return }
if (opts.sinceId && this.mergedConfig.useStreamingApi) {
return const chatId = chatMessageService.chatId
} const fetchOlderMessages = !!maxId
if (opts.sinceId) { const sinceId = fetchLatest && chatMessageService.lastMessage && chatMessageService.lastMessage.id
sinceId = chatMessageService.lastMessage && chatMessageService.lastMessage.id
}
if (isFirstFetch) {
this.scrollDown({ forceRead: true })
}
let positionBeforeLoading = null
let previousScrollTop
if (maxId) {
this.loadingOlderMessages = true
positionBeforeLoading = this.getPosition()
previousScrollTop = this.$refs.scrollable.scrollTop
}
this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId }) this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId })
.then((messages) => { .then((messages) => {
let bottomedOut = this.bottomedOut() // Clear the current chat in case we're recovering from a ws connection loss.
this.loadingOlderMessages = false if (isFirstFetch) {
this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => { chatService.clear(chatMessageService)
if (positionBeforeLoading) { }
this.$nextTick(() => {
let positionAfterLoading = this.getPosition()
let scrollable = this.$refs.scrollable
scrollable.scrollTo({
top: previousScrollTop + (positionAfterLoading.totalHeight - positionBeforeLoading.totalHeight),
left: 0
})
})
}
if (isFirstFetch) { const positionBeforeUpdate = getScrollPosition(this.$refs.scrollable)
this.$nextTick(() => { this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => {
this.updateSize() this.$nextTick(() => {
}) if (fetchOlderMessages) {
} else if (bottomedOut) { this.handleScrollUp(positionBeforeUpdate)
this.scrollDown() }
}
setTimeout(() => { if (isFirstFetch) {
this.loadingMessages = false this.updateScrollableContainerHeight()
}, 1000) }
})
}) })
}) })
}, },
readChat () {
if (!(this.currentChat && this.currentChat.id)) { return }
this.$store.dispatch('readChat', { id: this.currentChat.id })
},
async startFetching () { async startFetching () {
let chat = this.findOpenedChatByRecipientId(this.recipientId) let chat = this.findOpenedChatByRecipientId(this.recipientId)
if (!chat) { if (!chat) {
chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId }) try {
chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId })
} catch (e) {
console.error('Error creating or getting a chat', e)
this.errorLoadingChat = true
}
}
if (chat) {
this.$nextTick(() => {
this.scrollDown({ forceRead: true })
})
this.$store.dispatch('addOpenedChat', { chat })
this.doStartFetching()
} }
this.$nextTick(() => {
this.scrollDown({ forceRead: true })
})
this.$store.dispatch('addOpenedChat', { chat })
this.doStartFetching()
}, },
doStartFetching () { doStartFetching () {
let chatId = this.currentChat.id
this.$store.dispatch('startFetchingCurrentChat', { this.$store.dispatch('startFetchingCurrentChat', {
fetcher: () => setInterval(() => this.fetchChat(false, chatId, { sinceId: true }), 5000) fetcher: () => setInterval(() => this.fetchChat({ fetchLatest: true }), 5000)
}) })
this.fetchChat(true, chatId) this.fetchChat({ isFirstFetch: true })
}, },
poster (opts) { sendMessage ({ status, media }) {
const status = opts.status const params = {
let params = {
id: this.currentChat.id, id: this.currentChat.id,
content: status content: status
} }
if (opts.media && opts.media[0]) { if (media[0]) {
params.mediaId = opts.media[0].id params.mediaId = media[0].id
} }
return this.backendInteractor.postChatMessage(params) return this.backendInteractor.sendChatMessage(params)
.then(data => {
this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => {
this.$nextTick(() => {
this.handleResize()
// When the posting form size changes because of a media attachment, we need an extra resize
// to account for the potential delay in the DOM update.
setTimeout(() => {
this.updateScrollableContainerHeight()
}, SAFE_RESIZE_TIME_OFFSET)
this.scrollDown({ forceRead: true })
})
})
return data
})
.catch(error => {
console.error('Error sending message', error)
return {
error: this.$t('chats.error_sending_message')
}
})
},
goBack () {
this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
} }
} }
} }

View file

@ -1,218 +1,162 @@
.direct-conversation-view { .chat-view {
display: flex; display: flex;
height: calc(100vh - 60px); height: calc(100vh - 60px);
width: 100%; width: 100%;
.direct-conversation-view-inner { .chat-title {
// prevents chat header jumping on when the user avatar loads
height: 28px;
}
.chat-view-inner {
height: auto; height: auto;
width: 100%; width: 100%;
overflow: visible; overflow: visible;
display: flex; display: flex;
margin-top: 0.5em; margin: 0.5em 0.5em 0 0.5em;
margin-left: 0.5em; }
margin-right: 0.5em;
.direct-conversation-view-body { .chat-view-body {
background-color: var(--chatBg, $fallback--bg); background-color: var(--chatBg, $fallback--bg);
display: flex;
flex-direction: column;
width: 100%;
overflow: visible;
min-height: 100%;
margin: 0 0 0 0;
border-radius: 10px 10px 0 0;
border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ;
&::after {
border-radius: 0;
}
}
.scrollable-message-list {
padding: 0 0.8em;
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
.footer {
position: sticky;
bottom: 0;
}
.chat-view-heading {
align-items: center;
justify-content: space-between;
top: 50px;
display: flex;
z-index: 2;
position: sticky;
overflow: hidden;
}
.go-back-button {
cursor: pointer;
margin-right: 1.4em;
i {
display: flex; display: flex;
flex-direction: column; align-items: center;
}
}
.jump-to-bottom-button {
width: 2.5em;
height: 2.5em;
border-radius: 100%;
position: absolute;
right: 1.3em;
top: -3.2em;
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3), 0px 2px 4px rgba(0, 0, 0, 0.3);
z-index: 10;
transition: 0.35s all;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
opacity: 0;
visibility: hidden;
cursor: pointer;
&.visible {
opacity: 1;
visibility: visible;
}
i {
font-size: 1em;
color: $fallback--text;
color: var(--text, $fallback--text);
}
.unread-message-count {
font-size: 0.8em;
left: 50%;
transform: translate(-50%, 0);
border-radius: 100%;
margin-top: -1rem;
padding: 0;
}
.chat-loading-error {
width: 100%; width: 100%;
overflow: visible; display: flex;
border-radius: none; align-items: flex-end;
min-height: 100%; height: 100%;
margin-left: 0;
margin-right: 0;
margin-bottom: 0em;
margin-top: 0em;
border-radius: 10px 10px 0 0;
border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ;
&.panel { .error {
&::after { width: 100%;
border-radius: 0;
box-shadow: none;
}
}
.direct-conversation-view-heading {
align-items: center;
justify-content: space-between;
top: 50px;
display: flex;
z-index: 2;
border-radius: none;
position: -webkit-sticky;
position: sticky;
.direct-conversation-title {
display: flex;
a {
display: flex;
align-items: center;
}
}
.go-back-button-wrapper {
display: flex;
width: 100%;
}
.button-icon {
cursor: pointer;
display: flex;
align-content: center;
align-items: center;
}
.go-back-button {
cursor: pointer;
margin-right: 1.2em;
i {
color: $fallback--link;
color: var(--panelLink, $fallback--link);
}
}
.title {
flex-shrink: 1;
margin-right: 0em;
overflow: hidden;
text-overflow: ellipsis;
line-height: 28px;
flex-shrink: 1;
margin-right: 0em;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
max-width: 80%;
display: grid;
display: flex;
}
}
.scrollable {
padding: 0 10px;
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
.footer {
position: -webkit-sticky;
position: sticky;
bottom: 0px;
textarea {
outline: none
}
} }
} }
} }
}
@media all and (max-width: 800px) { @media all and (max-width: 800px) {
.direct-conversation-view {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
.direct-conversation-view-inner { .chat-view-inner {
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
margin-top: 0; margin-top: 0;
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
}
.direct-conversation-view-body { .chat-view-body {
display: flex; display: flex;
min-height: auto; min-height: auto;
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
margin: 0; margin: 0;
border-radius: 0 !important; border-radius: 0;
}
.direct-conversation-view-heading { .chat-view-heading {
position: static; position: static;
z-index: 9999; z-index: 9999;
top: 0; top: 0;
margin-top: 0; margin-top: 0;
border-radius: 0; border-radius: 0;
} }
.scrollable { .scrollable-message-list {
display: unset; display: unset;
overflow-y: scroll; overflow-y: scroll;
overflow-x: hidden; overflow-x: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.footer { .footer {
position: relative; position: sticky;
bottom: auto; bottom: auto;
.post-status-form form {
padding: 0;
}
}
}
} }
} }
} }
.jump-to-bottom-button {
width: 2.5em;
height: 2.5em;
border-radius: 100%;
position: absolute;
position: absolute;
right: 1.3em;
top: -3.2em;
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3), 0px 2px 4px rgba(0, 0, 0, 0.3);
z-index: 10;
transition: 0.35s all;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
opacity: 0;
visibility: hidden;
cursor: pointer;
&.visible {
opacity: 1;
visibility: visible;
}
i {
font-size: 1em;
color: $fallback--text;
color: var(--text, $fallback--text);
}
.new-messages-alert-dot {
left: 50%;
transform: translate(-50%, 0);
border-radius: 100%;
height: 1.3em;
width: 1.3em;
position: absolute;
top: calc(50% - 8px);
text-align: center;
font-style: normal;;
font-weight: bolder;
margin-top: -1rem;
font-size: 0.8em;
background-color: $fallback--cRed;
background-color: var(--badgeNotification, $fallback--cRed);
color: white;
color: var(--badgeNotificationText, white);
}
}

View file

@ -1,45 +1,53 @@
<template> <template>
<div class="direct-conversation-view"> <div class="chat-view">
<div class="direct-conversation-view-inner"> <div class="chat-view-inner">
<div <div
id="nav" id="nav"
ref="inner" ref="inner"
class="panel-default panel direct-conversation-view-body" class="panel-default panel chat-view-body"
> >
<div <div
ref="header" ref="header"
class="panel-heading direct-conversation-view-heading mobile-hidden" class="panel-heading chat-view-heading mobile-hidden"
> >
<div class="go-back-button-wrapper"> <a
<i class="go-back-button"
class="button-icon icon-left-open go-back-button" @click="goBack"
@click="goBack" >
<i class="button-icon icon-left-open" />
</a>
<div class="title text-center">
<ChatTitle
:user="recipient"
:with-avatar="true"
/> />
<div class="title text-center">
<ChatTitle
:users="chatParticipants"
:fallback-user="currentUser"
:with-avatar="true"
/>
</div>
</div>
<div style="visibility: hidden">
<i class="button-icon icon-info-circled" />
</div> </div>
</div> </div>
<template> <template>
<div <div
ref="scrollable" ref="scrollable"
class="scrollable" class="scrollable-message-list"
:style="{ height: scrollableContainerHeight }"
@scroll="handleScroll" @scroll="handleScroll"
> >
<ChatMessage <template v-if="!errorLoadingChat">
v-for="chatViewItem in chatViewItems" <ChatMessage
:key="chatViewItem.id" v-for="chatViewItem in chatViewItems"
:chat-view-item="chatViewItem" :key="chatViewItem.id"
:sequence-hovered="chatViewItem.sequenceId === hoveredSequenceId" :author="recipient"
@hover="onStatusHover" :chat-view-item="chatViewItem"
/> :hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId"
@hover="onMessageHover"
/>
</template>
<div
v-else
class="chat-loading-error"
>
<div class="alert error">
{{ $t('chats.error_loading_chat') }}
</div>
</div>
</div> </div>
<div <div
ref="footer" ref="footer"
@ -47,33 +55,36 @@
> >
<div <div
class="jump-to-bottom-button" class="jump-to-bottom-button"
:class="{ 'visible': !loadingMessages && jumpToBottomButtonVisible }" :class="{ 'visible': jumpToBottomButtonVisible }"
@click="scrollDown({ behavior: 'smooth' })" @click="scrollDown({ behavior: 'smooth' })"
> >
<i class="icon-down-open"> <i class="icon-down-open">
<div <div
v-if="newMessageCount" v-if="newMessageCount"
class="new-messages-alert-dot" class="badge badge-notification unread-chat-count unread-message-count"
> >
{{ newMessageCount }} {{ newMessageCount }}
</div> </div>
</i> </i>
</div> </div>
<PostStatusForm <PostStatusForm
:disabled="loadingChat"
:disable-subject="true" :disable-subject="true"
:disable-scope-selector="true" :disable-scope-selector="true"
:disable-notice="true" :disable-notice="true"
:disable-lock-warning="true"
:disable-polls="true" :disable-polls="true"
:poster="poster" :disable-sensitivity-checkbox="true"
:submit-on-enter="!isMobileLayout" :disable-submit="errorLoadingChat || !currentChat"
:preserve-focus="!isMobileLayout" :disable-preview="true"
:auto-focus="!isMobileLayout" :post-handler="sendMessage"
:submit-on-enter="!mobileLayout"
:preserve-focus="!mobileLayout"
:auto-focus="!mobileLayout"
:placeholder="formPlaceholder" :placeholder="formPlaceholder"
:file-limit="1" :file-limit="1"
max-height="160" max-height="160"
emoji-picker-placement="top"
@resize="handleResize" @resize="handleResize"
@posted="onPosted"
/> />
</div> </div>
</template> </template>

View file

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

View file

@ -1,26 +1,19 @@
import { mapState } from 'vuex' import { mapState, mapGetters } from 'vuex'
import ChatListItem from '../chat_list_item/chat_list_item.vue' import ChatListItem from '../chat_list_item/chat_list_item.vue'
import ChatNew from '../chat_new/chat_new.vue' import ChatNew from '../chat_new/chat_new.vue'
import List from '../list/list.vue' import List from '../list/list.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
const Chats = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchChats'),
select: (props, $store) => $store.getters.sortedChatList,
destroy: (props, $store) => undefined,
childPropName: 'items'
})(List)
const ChatList = { const ChatList = {
components: { components: {
ChatListItem, ChatListItem,
Chats, List,
ChatNew ChatNew
}, },
computed: { computed: {
...mapState({ ...mapState({
currentUser: state => state.users.currentUser currentUser: state => state.users.currentUser
}) }),
...mapGetters(['sortedChatList'])
}, },
data () { data () {
return { return {
@ -28,12 +21,12 @@ const ChatList = {
} }
}, },
created () { created () {
this.$store.dispatch('fetchChats', { reset: true }) this.$store.dispatch('fetchChats', { latest: true })
}, },
methods: { methods: {
cancelNewChat () { cancelNewChat () {
this.isNew = false this.isNew = false
this.$store.dispatch('fetchChats', { reset: true }) this.$store.dispatch('fetchChats', { latest: true })
}, },
newChat () { newChat () {
this.isNew = true this.isNew = true

View file

@ -4,21 +4,22 @@
</div> </div>
<div <div
v-else v-else
class="panel panel-default" class="chat-list panel panel-default"
style="min-height: calc(100vh - 67px); margin-bottom: 0; border-bottom-left-radius: 0; border-bottom-right-radius: 0;"
> >
<div class="panel-heading truncated-text-wrapper"> <div class="panel-heading">
<span class="title truncated-text"> <span class="title">
{{ $t("chats.chats") }} {{ $t("chats.chats") }}
</span> </span>
<span style="width: 0.75rem;">{{ ' ' }}</span>
<button @click="newChat"> <button @click="newChat">
{{ $t("chats.new") }} {{ $t("chats.new") }}
</button> </button>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="timeline"> <div
<Chats> v-if="sortedChatList.length > 0"
class="timeline"
>
<List :items="sortedChatList">
<template <template
slot="item" slot="item"
slot-scope="{item}" slot-scope="{item}"
@ -29,7 +30,13 @@
:chat="item" :chat="item"
/> />
</template> </template>
</Chats> </List>
</div>
<div
v-else
class="emtpy-chat-list-alert"
>
<span>{{ $t('chats.empty_chat_list_placeholder') }}</span>
</div> </div>
</div> </div>
</div> </div>
@ -40,17 +47,18 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.truncated-text-wrapper { .chat-list {
overflow-x: hidden; min-height: 25em;
display: flex; margin-bottom: 0;
}
.truncated-text { .emtpy-chat-list-alert {
flex: 1; padding: 3em;
overflow-x: hidden; font-size: 1.2em;
word-wrap: break-word; display: flex;
white-space: nowrap; justify-content: center;
text-overflow: ellipsis; color: $fallback--text;
} color: var(--faint, $fallback--text);
} }
</style> </style>

View file

@ -1,6 +1,7 @@
import { mapState } from 'vuex' import { mapState } from 'vuex'
import StatusContent from '../status_content/status_content.vue'
import fileType from 'src/services/file_type/file_type.service' import fileType from 'src/services/file_type/file_type.service'
import ChatAvatar from '../chat_avatar/chat_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import AvatarList from '../avatar_list/avatar_list.vue' import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue' import Timeago from '../timeago/timeago.vue'
import ChatTitle from '../chat_title/chat_title.vue' import ChatTitle from '../chat_title/chat_title.vue'
@ -11,10 +12,11 @@ const ChatListItem = {
'chat' 'chat'
], ],
components: { components: {
ChatAvatar, UserAvatar,
AvatarList, AvatarList,
Timeago, Timeago,
ChatTitle ChatTitle,
StatusContent
}, },
computed: { computed: {
...mapState({ ...mapState({
@ -23,7 +25,7 @@ const ChatListItem = {
attachmentInfo () { attachmentInfo () {
if (this.chat.lastMessage.attachments.length === 0) { return } if (this.chat.lastMessage.attachments.length === 0) { return }
let types = this.chat.lastMessage.attachments.map(file => fileType.fileType(file.mimetype)) const types = this.chat.lastMessage.attachments.map(file => fileType.fileType(file.mimetype))
if (types.includes('video')) { if (types.includes('video')) {
return this.$t('file_type.video') return this.$t('file_type.video')
} else if (types.includes('audio')) { } else if (types.includes('audio')) {
@ -33,6 +35,16 @@ const ChatListItem = {
} else { } else {
return this.$t('file_type.file') return this.$t('file_type.file')
} }
},
messageForStatusContent () {
const content = this.chat.lastMessage ? (this.attachmentInfo || this.chat.lastMessage.content) : ''
return {
summary: '',
statusnet_html: content,
text: content,
attachments: []
}
} }
}, },
methods: { methods: {

View file

@ -1,18 +1,8 @@
.chat-list-item { .chat-list-item {
&:hover .animated.avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
display: flex; display: flex;
flex-direction: row; flex-direction: row;
padding: 0.75em; padding: 0.75em;
height: 4.85em; height: 5em;
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
cursor: pointer; cursor: pointer;
@ -23,7 +13,7 @@
&:hover { &:hover {
background-color: var(--selectedPost, $fallback--lightBg); background-color: var(--selectedPost, $fallback--lightBg);
box-shadow: 0 0px 3px 1px rgba(0, 0, 0, 0.1); box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1);
} }
.chat-list-item-left { .chat-list-item-left {
@ -35,122 +25,70 @@
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
word-wrap: break-word; word-wrap: break-word;
}
.chat-preview { .heading {
display: inline-flex; width: 100%;
overflow: hidden; display: inline-flex;
white-space: nowrap; justify-content: space-between;
text-overflow: ellipsis; line-height: 1em;
margin: 0.35rem 0; }
height: 16px;
color: $fallback--text;
color: var(--faintText, $fallback--text);
width: 100%;
justify-content: space-between;
line-height: 1em;
.unread-indicator-wrapper { .heading-right {
display: flex; white-space: nowrap;
align-items: center; }
margin-left: 10px;
.unread-indicator { .name-and-account-name {
border-radius: 100%; text-overflow: ellipsis;
height: 8px; white-space: nowrap;
width: 8px; overflow: hidden;
background-color: $fallback--link; flex-shrink: 1;
background-color: var(--link, $fallback--link); line-height: 1.4em;
} }
}
.content { .chat-preview {
white-space: nowrap; display: inline-flex;
text-overflow: ellipsis; overflow: hidden;
overflow: hidden; white-space: nowrap;
flex: 1; text-overflow: ellipsis;
margin-right: 15px; margin: 0.35em 0;
} color: $fallback--text;
color: var(--faint, $fallback--text);
width: 100%;
}
.faint-link { a {
color: var(--faintLink, $fallback--link); color: var(--faintLink, $fallback--link);
text-decoration: none; text-decoration: none;
} pointer-events: none;
}
.account-name { &:hover .animated.avatar {
min-width: 1.6em; canvas {
margin-right: 0.2em; display: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--faintLink, $fallback--link);
}
.user-name {
margin-right: 0.4em;
flex-shrink: 1;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
overflow: hidden;
flex-shrink: 0;
max-width: 55%;
font-weight: bold;
img {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
a {
color: $fallback--link;
color: var(--faintLink, $fallback--link);
}
p {
margin: 0;
display: inline;
word-wrap: break-word;
white-space: nowrap;
text-overflow: ellipsis;
}
img, video {
max-width: 100%;
max-height: 400px;
vertical-align: middle;
&.emoji {
width: 1.125rem;
height: 1.125rem;
}
}
} }
img {
.heading { visibility: visible;
width: 100%;
display: inline-flex;
justify-content: space-between;
line-height: 1em;
.heading-right {
white-space: nowrap;
}
.member-count {
color: $fallback--text;
color: var(--faintText, $fallback--text);
margin-right: 2px;
}
.name-and-account-name {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
flex-shrink: 1;
}
} }
} }
.avatar.still-image {
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
}
.status-body {
img.emoji {
width: 1.4em;
height: 1.4em;
}
}
.time-wrapper {
line-height: 1.4em;
}
.single-line {
padding-right: 1em;
}
} }

View file

@ -4,9 +4,8 @@
@click.capture.prevent="openChat" @click.capture.prevent="openChat"
> >
<div class="chat-list-item-left"> <div class="chat-list-item-left">
<ChatAvatar <UserAvatar
:users="[]" :user="chat.account"
:fallback-user="chat.account"
height="48px" height="48px"
width="48px" width="48px"
/> />
@ -18,29 +17,25 @@
class="name-and-account-name" class="name-and-account-name"
> >
<ChatTitle <ChatTitle
:users="[]" :user="chat.account"
:fallback-user="chat.account"
:with-links="false"
/> />
</span> </span>
<span class="heading-right" /> <span class="heading-right" />
</div> </div>
<!-- eslint-disable vue/no-v-html -->
<div class="chat-preview"> <div class="chat-preview">
<span <StatusContent
class="content" :status="messageForStatusContent"
v-html="chat.lastMessage && (attachmentInfo || chat.lastMessage.content)" :single-line="true"
/> />
<div <div
v-if="chat.unread > 0" v-if="chat.unread > 0"
class="alert-dot-number" class="badge badge-notification unread-chat-count"
> >
{{ chat.unread }} {{ chat.unread }}
</div> </div>
</div> </div>
<!-- eslint-enable vue/no-v-html -->
</div> </div>
<div style="float: right; text-align: right;"> <div class="time-wrapper">
<Timeago <Timeago
:time="chat.updated_at" :time="chat.updated_at"
:auto-update="60" :auto-update="60"

View file

@ -11,10 +11,11 @@ import generateProfileLink from 'src/services/user_profile_link_generator/user_p
const ChatMessage = { const ChatMessage = {
name: 'ChatMessage', name: 'ChatMessage',
props: [ props: [
'author',
'edited', 'edited',
'noHeading', 'noHeading',
'chatViewItem', 'chatViewItem',
'sequenceHovered' 'hoveredMessageChain'
], ],
components: { components: {
Popover, Popover,
@ -29,15 +30,11 @@ const ChatMessage = {
// Returns HH:MM (hours and minutes) in local time. // Returns HH:MM (hours and minutes) in local time.
createdAt () { createdAt () {
const time = this.chatViewItem.data.created_at const time = this.chatViewItem.data.created_at
const lang = this.mergedConfig.interfaceLanguage return time.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false })
return time.toLocaleTimeString(lang, { hour: '2-digit', minute: '2-digit', hour12: false })
}, },
isCurrentUser () { isCurrentUser () {
return this.message.account_id === this.currentUser.id return this.message.account_id === this.currentUser.id
}, },
author () {
return this.findUser(this.message.account_id)
},
message () { message () {
return this.chatViewItem.data return this.chatViewItem.data
}, },
@ -55,14 +52,19 @@ const ChatMessage = {
attachments: this.message.attachments attachments: this.message.attachments
} }
}, },
hasAttachment () {
return this.message.attachments.length > 0
},
...mapState({ ...mapState({
betterShadow: state => state.interface.browserSupport.cssFilter, betterShadow: state => state.interface.browserSupport.cssFilter,
currentUser: state => state.users.currentUser, currentUser: state => state.users.currentUser,
restrictedNicknames: state => state.instance.restrictedNicknames restrictedNicknames: state => state.instance.restrictedNicknames
}), }),
wrapperStyle () { popoverMarginStyle () {
return { if (this.isCurrentUser) {
'opacity': this.hovered || this.menuOpened ? '1' : '0' return {}
} else {
return { left: 50 }
} }
}, },
...mapGetters(['mergedConfig', 'findUser']) ...mapGetters(['mergedConfig', 'findUser'])
@ -75,7 +77,7 @@ const ChatMessage = {
}, },
methods: { methods: {
onHover (bool) { onHover (bool) {
this.$emit('hover', { state: bool, sequenceId: this.chatViewItem.sequenceId }) this.$emit('hover', { isHovered: bool, messageChainId: this.chatViewItem.messageChainId })
}, },
async deleteMessage () { async deleteMessage () {
const confirmed = window.confirm(this.$t('chats.delete_confirm')) const confirmed = window.confirm(this.$t('chats.delete_confirm'))

View file

@ -1,7 +1,7 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.direct-conversation-status-wrapper { .chat-message-wrapper {
&.sequence-hovered { &.hovered-message-chain {
.animated.avatar { .animated.avatar {
canvas { canvas {
display: none; display: none;
@ -12,183 +12,149 @@
} }
} }
&:last-child { .chat-message-menu {
margin-bottom: 16px; transition: opacity 0.1s;
opacity: 0;
position: absolute;
top: -0.8em;
button {
padding-top: 0.2em;
padding-bottom: 0.2em;
}
} }
.media-heading { .icon-ellipsis {
margin-left: 43px; cursor: pointer;
margin-bottom: 4px;
&:hover, .extra-button-popover.open & {
color: $fallback--text;
color: var(--text, $fallback--text);
}
border-radius: $fallback--chatMessageRadius;
border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
}
.popover {
width: 12em;
}
.chat-message {
display: flex;
padding-bottom: 0.5em;
}
.avatar-wrapper {
margin-right: 0.72em;
width: 32px;
}
.link-preview, .attachments {
margin-bottom: 1em;
}
.chat-message-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 80%;
min-width: 10em;
width: 100%;
&.with-media {
width: 100%;
.gallery-row {
overflow: hidden;
}
.status {
width: 100%;
}
}
} }
.status { .status {
border-radius: $fallback--chatMessageRadius;
border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
display: flex;
padding: 0.75em; padding: 0.75em;
}
.chat-message-menu { .created-at {
transition: opacity 0.1s; position: relative;
opacity: 0; float: right;
font-size: 0.8em;
margin: -1em 0 -0.5em 0;
font-style: italic;
opacity: 0.8;
}
button { .without-attachment {
padding-top: 3px; .status-content {
padding-bottom: 3px; &::after {
} margin-right: 5.4em;
} content: " ";
display: inline-block;
.icon-ellipsis {
cursor: pointer;
&:hover,
.extra-button-popover.open & {
color: $fallback--text;
color: var(--text, $fallback--text);
} }
} }
} }
.direct-conversation { .incoming {
overflow-wrap: break-word; a {
word-wrap: break-word; color: var(--chatMessageIncomingLink, $fallback--link);
word-break: break-word;
border-left-width: 0px;
min-width: 0;
display: flex;
width: 100%;
padding-bottom: 7px;
.avatar-wrapper {
margin-right: 10px;
// margin-top: 13px;
// top: 5px;
width: 32px;
position: relative;
// img {
// position: absolute;
// }
} }
.link-preview, .attachments { .status {
margin-bottom: 0.9em; color: var(--chatMessageIncomingText, $fallback--text);
background-color: var(--chatMessageIncomingBg, $fallback--bg);
border: 1px solid var(--chatMessageIncomingBorder, --border);
} }
.status-content { .created-at {
line-height: 1.4em;
}
.direct-conversation-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 80%;
width: 100%;
position: relative;
float: right;
min-width: 10rem;
.trigger {
width: 100%;
}
.popover {
width: 12rem;
.tooltip-arrow.popover-arrow {
// overrides inline html arrow position
left: calc(100% - 30px) !important;
}
}
.poll {
margin-bottom: 1em;
}
&.with-media {
width: 100%;
.gallery-row {
overflow: hidden;
}
.status {
width: 100%;
}
}
.status {
box-sizing: border-box;
border-radius: $fallback--chatMessageRadius;
border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
}
.created-at {
float: right;
font-size: 0.8em;
margin: -10px 0 -5px 4px;
position: relative;
font-style: italic;
opacity: 0.8;
a {
color: var(--chatMessageOutgoingText, $fallback--text);
}
::before {
font-size: 1em;
}
}
.status-content {
white-space: normal;
&::after {
margin-right: 75px;
content: " ";
display: inline-block;
}
}
}
&.incoming {
.status {
background-color: var(--chatMessageIncomingBg, $fallback--bg);
border: 1px solid var(--chatMessageIncomingBorder, --border);
color: var(--chatMessageIncomingText, $fallback--text);
a {
color: var(--chatMessageIncomingLink, $fallback--link);
}
}
.created-at {
a {
color: var(--chatMessageIncomingText, $fallback--text);
}
}
}
&.outgoing {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-content: end;
justify-content: flex-end;
a { a {
color: var(--chatMessageOutgoingLink, $fallback--link); color: var(--chatMessageIncomingText, $fallback--text);
}
.status {
border: 1px solid var(--chatMessageOutgoingBorder, --lightBg);
color: var(--chatMessageOutgoingText, $fallback--text);
background-color: var(--chatMessageOutgoingBg, $fallback--lightBg);
}
.direct-conversation-inner {
align-items: flex-end;
} }
} }
.chat-message-menu {
left: 0.4rem;
}
}
.outgoing {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-content: end;
justify-content: flex-end;
a {
color: var(--chatMessageOutgoingLink, $fallback--link);
}
.status {
color: var(--chatMessageOutgoingText, $fallback--text);
background-color: var(--chatMessageOutgoingBg, $fallback--lightBg);
border: 1px solid var(--chatMessageOutgoingBorder, --lightBg);
}
.chat-message-inner {
align-items: flex-end;
}
.chat-message-menu {
right: 0.4rem;
}
}
.visible {
opacity: 1;
} }
} }
.date-separator { .chat-message-date-separator {
text-align: center; text-align: center;
margin: 1.4em 0; margin: 1.4em 0;
font-size: 0.9em; font-size: 0.9em;

View file

@ -1,13 +1,13 @@
<template> <template>
<div <div
v-if="isMessage" v-if="isMessage"
class="direct-conversation-status-wrapper" class="chat-message-wrapper"
:class="{ 'sequence-hovered': sequenceHovered }" :class="{ 'hovered-message-chain': hoveredMessageChain }"
@mouseover="onHover(true)" @mouseover="onHover(true)"
@mouseleave="onHover(false)" @mouseleave="onHover(false)"
> >
<div <div
class="direct-conversation" class="chat-message"
:class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]" :class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]"
> >
<div <div
@ -25,34 +25,32 @@
/> />
</router-link> </router-link>
</div> </div>
<div class="direct-conversation-inner"> <div class="chat-message-inner">
<div <div
class="status-body" class="status-body"
:style="{ 'min-width': message.attachment ? '80%' : '' }" :style="{ 'min-width': message.attachment ? '80%' : '' }"
> >
<div <div
class="media status" class="media status"
:class="{ 'without-attachment': !hasAttachment }"
style="position: relative" style="position: relative"
@mouseenter="hovered = true" @mouseenter="hovered = true"
@mouseleave="hovered = false" @mouseleave="hovered = false"
> >
<div <div
v-if="isCurrentUser"
class="chat-message-menu" class="chat-message-menu"
style="position: absolute; right: 5px; top: -10px" :class="{ 'visible': hovered || menuOpened }"
:style="wrapperStyle"
> >
<Popover <Popover
trigger="click" trigger="click"
placement="top" placement="top"
:bound-to-selector="isCurrentUser ? '' : '.scrollable-message-list'"
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
:margin="popoverMarginStyle"
@show="menuOpened = true" @show="menuOpened = true"
@close="menuOpened = false" @close="menuOpened = false"
> >
<div <div slot="content">
slot="content"
slot-scope=""
>
<div class="dropdown-menu"> <div class="dropdown-menu">
<button <button
class="dropdown-item dropdown-item-icon" class="dropdown-item dropdown-item-icon"
@ -66,7 +64,7 @@
slot="trigger" slot="trigger"
:title="$t('chats.more')" :title="$t('chats.more')"
> >
<i class="icon-dot-3" /> <i class="icon-ellipsis" />
</button> </button>
</Popover> </Popover>
</div> </div>
@ -88,7 +86,7 @@
</div> </div>
<div <div
v-else v-else
class="date-separator" class="chat-message-date-separator"
> >
<ChatMessageDate :date="chatViewItem.date" /> <ChatMessageDate :date="chatViewItem.date" />
</div> </div>

View file

@ -10,13 +10,13 @@ export default {
props: ['date'], props: ['date'],
computed: { computed: {
displayDate () { displayDate () {
let today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0)
if (this.date.getTime() === today.getTime()) { if (this.date.getTime() === today.getTime()) {
return this.$t('display_date.today') return this.$t('display_date.today')
} else { } else {
const lang = this.$store.getters.mergedConfig.interfaceLanguage return this.date.toLocaleDateString('en', { day: 'numeric', month: 'long' })
return this.date.toLocaleDateString(lang, { day: 'numeric', month: 'long' })
} }
} }
} }

View file

@ -1,4 +1,3 @@
import { throttle } from 'lodash'
import { mapState, mapGetters } from 'vuex' import { mapState, mapGetters } from 'vuex'
import BasicUserCard from '../basic_user_card/basic_user_card.vue' import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
@ -41,7 +40,7 @@ const chatNew = {
goBack () { goBack () {
this.$emit('cancel') this.$emit('cancel')
}, },
goToNewChat (user) { goToChat (user) {
this.$router.push({ name: 'chat', params: { recipient_id: user.id } }) this.$router.push({ name: 'chat', params: { recipient_id: user.id } })
}, },
onInput () { onInput () {
@ -54,7 +53,7 @@ const chatNew = {
removeUser (userId) { removeUser (userId) {
this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId) this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId)
}, },
search: throttle(function (query) { search (query) {
if (!query) { if (!query) {
this.loading = false this.loading = false
return return
@ -67,7 +66,7 @@ const chatNew = {
this.loading = false this.loading = false
this.userIds = data.accounts.map(a => a.id) this.userIds = data.accounts.map(a => a.id)
}) })
}) }
} }
} }

View file

@ -1,87 +1,29 @@
.direct-conversation-new { .chat-new {
.panel-heading {
display: flex;
justify-content: space-between;
}
.btn {
padding-left: 1em;
padding-right: 1em;
}
.selected-user-list {
padding-left: 0.7em;
padding-top: 0.5em;
.selected-user {
cursor: pointer;
display: inline-block;
border: 1px solid;
border-color: $fallback--link;
border-color: var(--link, $fallback--link);
color: $fallback--link;
color: var(--link, $fallback--link);
padding: 0.5em;
border-radius: $fallback--attachmentRadius;
border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
margin-right: 0.5em;
margin-top: 0.5em;
margin-bottom: 0.5em;
&:hover {
background-color: var(--selectedPost, $fallback--lightBg);
}
}
.notice-dismissible {
padding-right: 2.3rem;
position: relative;
.dismiss {
position: absolute;
top: 0;
right: 0;
padding: .35em;
color: inherit;
i {
color: $fallback--link;
color: var(--link, $fallback--link);
}
}
}
}
.member-list {
padding-bottom: 0.67rem;
.user-card-wrap {
.basic-user-card {
&:hover {
cursor: pointer;
background-color: var(--selectedPost, $fallback--lightBg);
}
}
}
}
.input-wrap { .input-wrap {
display: flex; display: flex;
margin: 0.7em 0.5em 0.7em 0.5em; margin: 0.7em 0.5em 0.7em 0.5em;
.button-icon {
font-size: 1.5em;
float: right;
margin-right: 0.3em;
}
.btn {
margin: 0 0.7em;
width: 6em;
}
input { input {
width: 100%; width: 100%;
} }
} }
.icon-search {
font-size: 1.5em;
float: right;
margin-right: 0.3em;
}
.member-list {
padding-bottom: 0.7rem;
}
.basic-user-card:hover {
cursor: pointer;
background-color: var(--selectedPost, $fallback--lightBg);
}
.go-back-button {
cursor: pointer;
}
} }

View file

@ -1,7 +1,7 @@
<template> <template>
<div <div
id="nav" id="nav"
class="panel-default panel direct-conversation-new" class="panel-default panel chat-new"
> >
<div <div
ref="header" ref="header"
@ -9,7 +9,6 @@
> >
<a <a
class="go-back-button" class="go-back-button"
style="cursor: pointer; margin-right: 0.7em"
@click="goBack" @click="goBack"
> >
<i class="button-icon icon-left-open" /> <i class="button-icon icon-left-open" />
@ -32,10 +31,7 @@
:key="user.id" :key="user.id"
class="member" class="member"
> >
<div <div @click.capture.prevent="goToChat(user)">
class="user-card-wrap"
@click.capture.prevent="goToNewChat(user)"
>
<BasicUserCard :user="user" /> <BasicUserCard :user="user" />
</div> </div>
</div> </div>

View file

@ -84,54 +84,56 @@
max-width: 25em; max-width: 25em;
} }
.chat-heading {
cursor: pointer;
.icon-comment-empty {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
.chat-window {
overflow-y: auto;
overflow-x: hidden;
max-height: 20em;
}
.chat-window-container {
height: 100%;
}
.chat-message {
display: flex;
padding: 0.2em 0.5em
}
.chat-avatar {
img {
height: 24px;
width: 24px;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
margin-right: 0.5em;
margin-top: 0.25em;
}
}
.chat-input {
display: flex;
textarea {
flex: 1;
margin: 0.6em;
min-height: 3.5em;
resize: none;
}
}
.chat-panel { .chat-panel {
.title { .chat-heading {
cursor: pointer;
.icon-comment-empty {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
.chat-window {
overflow-y: auto;
overflow-x: hidden;
max-height: 20em;
}
.chat-window-container {
height: 100%;
}
.chat-message {
display: flex; display: flex;
justify-content: space-between; padding: 0.2em 0.5em
}
.chat-avatar {
img {
height: 24px;
width: 24px;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
margin-right: 0.5em;
margin-top: 0.25em;
}
}
.chat-input {
display: flex;
textarea {
flex: 1;
margin: 0.6em;
min-height: 3.5em;
resize: none;
}
}
.chat-panel {
.title {
display: flex;
justify-content: space-between;
}
} }
} }
</style> </style>

View file

@ -1,38 +1,21 @@
import Vue from 'vue' import Vue from '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 ChatAvatar from '../chat_avatar/chat_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import { mapState } from 'vuex'
const USER_LIMIT = 10 export default Vue.component('chat-title', {
export default Vue.component('direct-conversation-title', {
name: 'ChatTitle', name: 'ChatTitle',
components: { components: {
ChatAvatar UserAvatar
}, },
props: [ props: [
'users', 'fallbackUser', 'withAvatar' 'user', 'withAvatar'
], ],
computed: { computed: {
...mapState({
currentUser: state => state.users.currentUser
}),
otherUsersTruncated () {
return this.otherUsers.slice(0, USER_LIMIT)
},
otherUsers () {
let otherUsers = this.users.filter(recipient => recipient.id !== this.currentUser.id)
if (otherUsers.length === 0) {
return [this.fallbackUser]
} else {
return otherUsers
}
},
restCount () {
return this.otherUsers.length - USER_LIMIT
},
title () { title () {
return this.otherUsers.map(u => u.screen_name).join(', ') return this.user ? this.user.screen_name : ''
},
htmlTitle () {
return this.user ? this.user.name_html : ''
} }
}, },
methods: { methods: {

View file

@ -1,25 +1,22 @@
<template> <template>
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<div <div
class="direct-conversation-title" class="chat-title"
:title="title" :title="title"
> >
<ChatAvatar <router-link
v-if="withAvatar" v-if="withAvatar && user"
:users="otherUsers" :to="getUserProfileLink(user)"
:fallback-user="currentUser" >
width="23px" <UserAvatar
height="23px" :user="user"
/> width="23px"
height="23px"
/>
</router-link>
<span <span
v-if="withAvatar"
style="margin-right: 0.5em"
/>
<span
v-for="(user, index) in otherUsersTruncated"
:key="user.id"
class="username" class="username"
v-html="user.name_html + (index + 1 < otherUsersTruncated.length ? ', ' : '')" v-html="htmlTitle"
/> />
</div> </div>
<!-- eslint-enable vue/no-v-html --> <!-- eslint-enable vue/no-v-html -->
@ -30,9 +27,12 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.direct-conversation-title { .chat-title {
display: flex;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
align-items: center;
.username { .username {
max-width: 100%; max-width: 100%;
@ -40,13 +40,28 @@
white-space: nowrap; white-space: nowrap;
display: inline; display: inline;
word-wrap: break-word; word-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
img { .emoji {
width: 14px; width: 14px;
height: 14px; height: 14px;
vertical-align: middle; vertical-align: middle;
object-fit: contain object-fit: contain
} }
} }
.still-image.avatar {
width: 23px;
height: 23px;
margin-right: 0.5em;
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
&.animated::before {
display: none;
}
}
} }
</style> </style>

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

@ -79,6 +79,20 @@ const EmojiInput = {
required: false, required: false,
type: Boolean, type: Boolean,
default: false default: false
},
placement: {
/**
* Forces the panel to take a specific position relative to the input element.
* The 'auto' placement chooses either bottom or top depending on which has the available space (when both have available space, bottom is preferred).
*/
required: false,
type: String, // 'auto', 'top', 'bottom'
default: 'auto'
},
newlineOnCtrlEnter: {
required: false,
type: Boolean,
default: false
} }
}, },
data () { data () {
@ -195,7 +209,7 @@ const EmojiInput = {
this.$emit('input', newValue) this.$emit('input', newValue)
this.caret = 0 this.caret = 0
}, },
insert ({ insertion, keepOpen }) { insert ({ insertion, keepOpen, surroundingSpace = true }) {
const before = this.value.substring(0, this.caret) || '' const before = this.value.substring(0, this.caret) || ''
const after = this.value.substring(this.caret) || '' const after = this.value.substring(this.caret) || ''
@ -214,8 +228,8 @@ const EmojiInput = {
* them, masto seem to be rendering :emoji::emoji: correctly now so why not * them, masto seem to be rendering :emoji::emoji: correctly now so why not
*/ */
const isSpaceRegex = /\s/ const isSpaceRegex = /\s/
const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0 ? ' ' : '' const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : ''
const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : '' const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : ''
const newValue = [ const newValue = [
before, before,
@ -372,6 +386,18 @@ const EmojiInput = {
}, },
onKeyDown (e) { onKeyDown (e) {
const { ctrlKey, shiftKey, key } = e const { ctrlKey, shiftKey, key } = e
if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') {
this.insert({ insertion: '\n', surroundingSpace: false })
// Ensure only one new line is added on macos
e.stopPropagation()
e.preventDefault()
// Scroll the input element to the position of the cursor
this.$nextTick(() => {
this.input.elm.blur()
this.input.elm.focus()
})
}
// Disable suggestions hotkeys if suggestions are hidden // Disable suggestions hotkeys if suggestions are hidden
if (!this.temporarilyHideSuggestions) { if (!this.temporarilyHideSuggestions) {
if (key === 'Tab') { if (key === 'Tab') {
@ -430,14 +456,29 @@ const EmojiInput = {
this.caret = selectionStart this.caret = selectionStart
}, },
resize () { resize () {
const { panel, picker } = this.$refs const panel = this.$refs.panel
if (!panel) return if (!panel) return
const picker = this.$refs.picker.$el
const panelBody = this.$refs['panel-body']
const { offsetHeight, offsetTop } = this.input.elm const { offsetHeight, offsetTop } = this.input.elm
const offsetBottom = offsetTop + offsetHeight const offsetBottom = offsetTop + offsetHeight
panel.style.top = offsetBottom + 'px' this.setPlacement(panelBody, panel, offsetBottom)
picker.$el.style.top = offsetBottom + 'px' this.setPlacement(picker, picker, offsetBottom)
picker.$el.style.bottom = 'auto' },
setPlacement (container, target, offsetBottom) {
if (!container || !target) return
target.style.top = offsetBottom + 'px'
target.style.bottom = 'auto'
if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) {
target.style.top = 'auto'
target.style.bottom = this.input.elm.offsetHeight + 'px'
}
},
overflowsBottom (el) {
return el.getBoundingClientRect().bottom > window.innerHeight
} }
} }
} }

View file

@ -29,7 +29,10 @@
class="autocomplete-panel" class="autocomplete-panel"
:class="{ hide: !showSuggestions }" :class="{ hide: !showSuggestions }"
> >
<div class="autocomplete-panel-body"> <div
ref="panel-body"
class="autocomplete-panel-body"
>
<div <div
v-for="(suggestion, index) in suggestions" v-for="(suggestion, index) in suggestions"
:key="index" :key="index"

View file

@ -13,7 +13,7 @@ import { debounce } from 'lodash'
const debounceUserSearch = debounce((data, input) => { const debounceUserSearch = debounce((data, input) => {
data.updateUsersList(input) data.updateUsersList(input)
}, 500, { leading: true, trailing: false }) }, 500)
export default data => input => { export default data => input => {
const firstChar = input[0] const firstChar = input[0]
@ -97,8 +97,8 @@ export const suggestUsers = data => input => {
replacement: '@' + screen_name + ' ' replacement: '@' + screen_name + ' '
})) }))
// BE search users if there are no matches // BE search users to get more comprehensive results
if (newUsers.length === 0 && data.updateUsersList) { if (data.updateUsersList) {
debounceUserSearch(data, noPrefix) debounceUserSearch(data, noPrefix)
} }
return newUsers return newUsers

View file

@ -34,6 +34,16 @@ const ExtraButtons = {
navigator.clipboard.writeText(this.statusLink) navigator.clipboard.writeText(this.statusLink)
.then(() => this.$emit('onSuccess')) .then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error)) .catch(err => this.$emit('onError', err.error.error))
},
bookmarkStatus () {
this.$store.dispatch('bookmark', { id: this.status.id })
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
unbookmarkStatus () {
this.$store.dispatch('unbookmark', { id: this.status.id })
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
} }
}, },
computed: { computed: {

View file

@ -3,6 +3,7 @@
trigger="click" trigger="click"
placement="top" placement="top"
class="extra-button-popover" class="extra-button-popover"
:bound-to="{ x: 'container' }"
> >
<div <div
slot="content" slot="content"
@ -39,6 +40,22 @@
> >
<i class="icon-pin" /><span>{{ $t("status.unpin") }}</span> <i class="icon-pin" /><span>{{ $t("status.unpin") }}</span>
</button> </button>
<button
v-if="!status.bookmarked"
class="dropdown-item dropdown-item-icon"
@click.prevent="bookmarkStatus"
@click="close"
>
<i class="icon-bookmark-empty" /><span>{{ $t("status.bookmark") }}</span>
</button>
<button
v-if="status.bookmarked"
class="dropdown-item dropdown-item-icon"
@click.prevent="unbookmarkStatus"
@click="close"
>
<i class="icon-bookmark" /><span>{{ $t("status.unbookmark") }}</span>
</button>
<button <button
v-if="canDelete" v-if="canDelete"
class="dropdown-item dropdown-item-icon" class="dropdown-item dropdown-item-icon"

View file

@ -1,6 +1,7 @@
const FeaturesPanel = { const FeaturesPanel = {
computed: { computed: {
chat: function () { return this.$store.state.instance.chatAvailable }, chat: function () { return this.$store.state.instance.chatAvailable },
pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable },
gopher: function () { return this.$store.state.instance.gopherAvailable }, gopher: function () { return this.$store.state.instance.gopherAvailable },
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled }, whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable }, mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },

View file

@ -11,6 +11,9 @@
<li v-if="chat"> <li v-if="chat">
{{ $t('features_panel.chat') }} {{ $t('features_panel.chat') }}
</li> </li>
<li v-if="pleromaChatMessages">
{{ $t('features_panel.pleroma_chat_messages') }}
</li>
<li v-if="gopher"> <li v-if="gopher">
{{ $t('features_panel.gopher') }} {{ $t('features_panel.gopher') }}
</li> </li>

View file

@ -50,9 +50,7 @@
align-content: stretch; align-content: stretch;
} }
// FIXME: specificity problem with this and .attachments.attachment .gallery-row-inner .attachment {
// we shouldn't have the need for .image here
.attachment.image {
margin: 0 0.5em 0 0; margin: 0 0.5em 0 0;
flex-grow: 1; flex-grow: 1;
height: 100%; height: 100%;
@ -78,6 +76,7 @@
video, video,
canvas { canvas {
object-fit: contain; object-fit: contain;
height: 100%;
} }
} }

View file

@ -0,0 +1,15 @@
const GlobalNoticeList = {
computed: {
notices () {
return this.$store.state.interface.globalNotices
}
},
methods: {
closeNotice (notice) {
this.$store.dispatch('removeGlobalNotice', notice)
}
}
}
export default GlobalNoticeList

View file

@ -0,0 +1,77 @@
<template>
<div class="global-notice-list">
<div
v-for="(notice, index) in notices"
:key="index"
class="alert global-notice"
:class="{ ['global-' + notice.level]: true }"
>
<div class="notice-message">
{{ $t(notice.messageKey, notice.messageArgs) }}
</div>
<i
class="button-icon icon-cancel"
@click="closeNotice(notice)"
/>
</div>
</div>
</template>
<script src="./global_notice_list.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.global-notice-list {
position: fixed;
top: 50px;
width: 100%;
pointer-events: none;
z-index: 1001;
display: flex;
flex-direction: column;
align-items: center;
.global-notice {
pointer-events: auto;
text-align: center;
width: 40em;
max-width: calc(100% - 3em);
display: flex;
padding-left: 1.5em;
line-height: 2em;
.notice-message {
flex: 1 1 100%;
}
i {
flex: 0 0;
width: 1.5em;
cursor: pointer;
}
}
.global-error {
background-color: var(--alertPopupError, $fallback--cRed);
color: var(--alertPopupErrorText, $fallback--text);
i {
color: var(--alertPopupErrorText, $fallback--text);
}
}
.global-warning {
background-color: var(--alertPopupWarning, $fallback--cOrange);
color: var(--alertPopupWarningText, $fallback--text);
i {
color: var(--alertPopupWarningText, $fallback--text);
}
}
.global-info {
background-color: var(--alertPopupNeutral, $fallback--fg);
color: var(--alertPopupNeutralText, $fallback--text);
i {
color: var(--alertPopupNeutralText, $fallback--text);
}
}
}
</style>

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

@ -8,6 +8,8 @@
v-if="type === 'image'" v-if="type === 'image'"
class="modal-image" class="modal-image"
:src="currentMedia.url" :src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
@touchstart.stop="mediaTouchStart" @touchstart.stop="mediaTouchStart"
@touchmove.stop="mediaTouchMove" @touchmove.stop="mediaTouchMove"
@click="hide" @click="hide"
@ -18,6 +20,14 @@
:attachment="currentMedia" :attachment="currentMedia"
:controls="true" :controls="true"
/> />
<audio
v-if="type === 'audio'"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
controls
/>
<button <button
v-if="canNavigate" v-if="canNavigate"
:title="$t('media_modal.previous')" :title="$t('media_modal.previous')"

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: [
@ -68,7 +67,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

@ -2,9 +2,6 @@
<div <div
class="media-upload" class="media-upload"
:class="{ disabled: disabled }" :class="{ disabled: disabled }"
@drop.prevent
@dragover.prevent="fileDrag"
@drop="fileDrop"
> >
<label <label
class="label" class="label"

View file

@ -31,8 +31,8 @@ const MobileNav = {
}, },
hideSitename () { return this.$store.state.instance.hideSitename }, hideSitename () { return this.$store.state.instance.hideSitename },
sitename () { return this.$store.state.instance.name }, sitename () { return this.$store.state.instance.name },
navBarStyle () { isChat () {
return { 'visibility': this.$route.name === 'chat' ? 'hidden' : 'visible' } return this.$route.name === 'chat'
} }
}, },
methods: { methods: {

View file

@ -3,7 +3,7 @@
<nav <nav
id="nav" id="nav"
class="nav-bar container" class="nav-bar container"
:style="navBarStyle" :class="{ 'mobile-hidden': isChat }"
> >
<div <div
class="mobile-inner-nav" class="mobile-inner-nav"

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;
opacity: 0;
body:not(.scroll-locked) & { > * {
opacity: 0; pointer-events: initial;
}
&.modal-background {
pointer-events: initial;
background-color: rgba(0, 0, 0, 0.5);
}
&.open {
opacity: 1;
} }
} }

View file

@ -12,9 +12,10 @@ const NavPanel = {
chat: state => state.chat.channel, chat: state => state.chat.channel,
followRequestCount: state => state.api.followRequests.length, followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private, privateMode: state => state.instance.private,
federating: state => state.instance.federating federating: state => state.instance.federating,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
}), }),
...mapGetters(['unreadChatCount', 'currentChat']) ...mapGetters(['unreadChatCount'])
} }
} }

View file

@ -18,12 +18,17 @@
</router-link> </router-link>
</li> </li>
<li v-if="currentUser"> <li v-if="currentUser">
<router-link :to="{ name: 'bookmarks'}">
<i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }}
</router-link>
</li>
<li v-if="currentUser && pleromaChatMessagesAvailable">
<router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }"> <router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }">
<div <div
v-if="unreadChatCount(currentChat)" v-if="unreadChatCount"
class="alert-dot-number" class="badge badge-notification unread-chat-count"
> >
{{ unreadChatCount(currentChat) }} {{ unreadChatCount }}
</div> </div>
<i class="button-icon icon-chat" /> {{ $t("nav.chats") }} <i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
</router-link> </router-link>

View file

@ -1,3 +1,4 @@
import StatusContent from '../status_content/status_content.vue'
import { mapState } from 'vuex' import { mapState } from 'vuex'
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'
@ -18,11 +19,11 @@ const Notification = {
}, },
props: [ 'notification' ], props: [ 'notification' ],
components: { components: {
Status, StatusContent,
UserAvatar, UserAvatar,
UserCard, UserCard,
Timeago, Timeago,
StatusContent Status
}, },
methods: { methods: {
toggleUserExpanded () { toggleUserExpanded () {
@ -83,15 +84,6 @@ const Notification = {
isStatusNotification () { isStatusNotification () {
return isStatusNotification(this.notification.type) return isStatusNotification(this.notification.type)
}, },
// TODO:
messageForStatusContent () {
return {
summary: '',
statusnet_html: this.notification.chatMessage.content,
text: this.notification.chatMessage.content,
attachments: []
}
},
...mapState({ ...mapState({
currentUser: state => state.users.currentUser currentUser: state => state.users.currentUser
}) })

View file

@ -160,11 +160,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

@ -1,3 +1,4 @@
import { mapGetters } from 'vuex'
import Notification from '../notification/notification.vue' import Notification from '../notification/notification.vue'
import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js' import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
import { import {
@ -27,6 +28,11 @@ const Notifications = {
seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT
} }
}, },
created () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
notificationsFetcher.fetchAndUpdate({ store, credentials })
},
computed: { computed: {
mainClass () { mainClass () {
return this.minimalMode ? '' : 'panel panel-default' return this.minimalMode ? '' : 'panel panel-default'
@ -46,23 +52,22 @@ const Notifications = {
unseenCount () { unseenCount () {
return this.unseenNotifications.length return this.unseenNotifications.length
}, },
unseenCountTitle () {
return this.unseenCount + (this.unreadChatCount)
},
loading () { loading () {
return this.$store.state.statuses.notifications.loading return this.$store.state.statuses.notifications.loading
}, },
notificationsToDisplay () { notificationsToDisplay () {
return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount) return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
} },
...mapGetters(['unreadChatCount'])
}, },
components: { components: {
Notification Notification
}, },
created () {
const { dispatch } = this.$store
dispatch('fetchAndUpdateNotifications')
},
watch: { watch: {
unseenCount (count) { unseenCountTitle (count) {
if (count > 0) { if (count > 0) {
this.$store.dispatch('setPageTitle', `(${count})`) this.$store.dispatch('setPageTitle', `(${count})`)
} else { } else {

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,35 +48,26 @@
} }
} }
.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 { color: var(--faintLink);
color: var(--faintLink);
}
.status-content a {
color: var(--postFaintLink);
}
} }
padding: 0; .status-content a {
.media-body { color: var(--postFaintLink);
margin: 0;
} }
} }
} }

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

@ -17,7 +17,7 @@
<span class="result-percentage"> <span class="result-percentage">
{{ percentageForOption(option.votes_count) }}% {{ percentageForOption(option.votes_count) }}%
</span> </span>
<span>{{ option.title }}</span> <span v-html="option.title_html"></span>
</div> </div>
<div <div
class="result-fill" class="result-fill"

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

@ -3,11 +3,13 @@ import MediaUpload from '../media_upload/media_upload.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue' import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji_input/emoji_input.vue' import EmojiInput from '../emoji_input/emoji_input.vue'
import PollForm from '../poll/poll_form.vue' import PollForm from '../poll/poll_form.vue'
import Attachment from '../attachment/attachment.vue'
import StatusContent from '../status_content/status_content.vue'
import fileTypeService from '../../services/file_type/file_type.service.js' import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { reject, map, uniqBy } from 'lodash' import { reject, map, uniqBy, debounce } from 'lodash'
import suggestor from '../emoji_input/suggestor.js' import suggestor from '../emoji_input/suggestor.js'
import { mapGetters } from 'vuex' import { mapGetters, mapState } from 'vuex'
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from '../checkbox/checkbox.vue'
const buildMentionsString = ({ user, attentions = [] }, currentUser) => { const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
@ -35,26 +37,36 @@ const PostStatusForm = {
'disableSubject', 'disableSubject',
'disableScopeSelector', 'disableScopeSelector',
'disableNotice', 'disableNotice',
'disableLockWarning',
'disablePolls', 'disablePolls',
'disableSensitivityCheckbox',
'disableSubmit',
'disablePreview',
'placeholder', 'placeholder',
'maxHeight', 'maxHeight',
'poster', 'postHandler',
'preserveFocus', 'preserveFocus',
'autoFocus', 'autoFocus',
'fileLimit', 'fileLimit',
'submitOnEnter' 'submitOnEnter',
'emojiPickerPlacement'
], ],
components: { components: {
MediaUpload, MediaUpload,
EmojiInput, EmojiInput,
PollForm, PollForm,
ScopeSelector, ScopeSelector,
Checkbox Checkbox,
Attachment,
StatusContent
}, },
mounted () { mounted () {
this.resize(this.$refs.textarea) this.resize(this.$refs.textarea)
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength) if (this.replyTo) {
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
}
if (this.replyTo || this.autoFocus) { if (this.replyTo || this.autoFocus) {
this.$refs.textarea.focus() this.$refs.textarea.focus()
@ -79,7 +91,7 @@ const PostStatusForm = {
return { return {
dropFiles: [], dropFiles: [],
submitDisabled: false, uploadingFiles: false,
error: null, error: null,
posting: false, posting: false,
highlighted: 0, highlighted: 0,
@ -89,11 +101,16 @@ const PostStatusForm = {
nsfw: false, nsfw: false,
files: [], files: [],
poll: {}, poll: {},
mediaDescriptions: {},
visibility: scope, visibility: scope,
contentType contentType
}, },
caret: 0, caret: 0,
pollFormVisible: false, pollFormVisible: false,
showDropIcon: 'hide',
dropStopTimeout: null,
preview: null,
previewLoading: false,
emojiInputShown: false emojiInputShown: false
} }
}, },
@ -174,10 +191,30 @@ const PostStatusForm = {
this.newStatus.poll && this.newStatus.poll &&
this.newStatus.poll.error this.newStatus.poll.error
}, },
...mapGetters(['mergedConfig']) showPreview () {
return !this.disablePreview && (!!this.preview || this.previewLoading)
},
emptyStatus () {
return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
},
uploadFileLimitReached () {
return this.newStatus.files.length >= this.fileLimit
},
...mapGetters(['mergedConfig']),
...mapState({
mobileLayout: state => state.interface.mobileLayout
})
},
watch: {
'newStatus.contentType': function () {
this.autoPreview()
},
'newStatus.spoilerText': function () {
this.autoPreview()
}
}, },
methods: { methods: {
postStatus (event, newStatus, opts = {}) { async postStatus (event, newStatus, opts = {}) {
if (this.posting) { return } if (this.posting) { return }
if (this.submitDisabled) { return } if (this.submitDisabled) { return }
if (this.emojiInputShown) { return } if (this.emojiInputShown) { return }
@ -185,16 +222,10 @@ const PostStatusForm = {
event.stopPropagation() event.stopPropagation()
event.preventDefault() event.preventDefault()
} }
if (opts.control && this.submitOnEnter) {
newStatus.status = `${newStatus.status}\n`
return
}
if (this.newStatus.status === '') { if (this.emptyStatus) {
if (this.newStatus.files.length === 0) { this.error = this.$t('post_status.empty_status_error')
this.error = 'Cannot post an empty status with no files' return
return
}
} }
const poll = this.pollFormVisible ? this.newStatus.poll : {} const poll = this.pollFormVisible ? this.newStatus.poll : {}
@ -205,6 +236,14 @@ const PostStatusForm = {
this.posting = true this.posting = true
try {
await this.setAllMediaDescriptions()
} catch (e) {
this.error = this.$t('post_status.media_description_error')
this.posting = false
return
}
const postingOptions = { const postingOptions = {
status: newStatus.status, status: newStatus.status,
spoilerText: newStatus.spoilerText || null, spoilerText: newStatus.spoilerText || null,
@ -217,9 +256,9 @@ const PostStatusForm = {
poll poll
} }
const poster = this.poster ? this.poster : statusPoster.postStatus const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus
poster(postingOptions).then((data) => { postHandler(postingOptions).then((data) => {
if (!data.error) { if (!data.error) {
this.newStatus = { this.newStatus = {
status: '', status: '',
@ -227,7 +266,8 @@ const PostStatusForm = {
files: [], files: [],
visibility: newStatus.visibility, visibility: newStatus.visibility,
contentType: newStatus.contentType, contentType: newStatus.contentType,
poll: {} poll: {},
mediaDescriptions: {}
} }
this.pollFormVisible = false this.pollFormVisible = false
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile() this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
@ -242,20 +282,68 @@ const PostStatusForm = {
el.style.height = 'auto' el.style.height = 'auto'
el.style.height = undefined el.style.height = undefined
this.error = null this.error = null
if (this.preview) this.previewStatus()
} else { } else {
this.error = data.error this.error = data.error
} }
this.posting = false this.posting = false
}) })
}, },
previewStatus () {
if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
this.preview = { error: this.$t('post_status.preview_empty') }
this.previewLoading = false
return
}
const newStatus = this.newStatus
this.previewLoading = true
statusPoster.postStatus({
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: [],
store: this.$store,
inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType,
poll: {},
preview: true
}).then((data) => {
// Don't apply preview if not loading, because it means
// user has closed the preview manually.
if (!this.previewLoading) return
if (!data.error) {
this.preview = data
} else {
this.preview = { error: data.error }
}
}).catch((error) => {
this.preview = { error }
}).finally(() => {
this.previewLoading = false
})
},
debouncePreviewStatus: debounce(function () { this.previewStatus() }, 500),
autoPreview () {
if (!this.preview) return
this.previewLoading = true
this.debouncePreviewStatus()
},
closePreview () {
this.preview = null
this.previewLoading = false
},
togglePreview () {
if (this.showPreview) {
this.closePreview()
} else {
this.previewStatus()
}
},
addMediaFile (fileInfo) { addMediaFile (fileInfo) {
this.$emit('resize') this.$emit('resize')
this.newStatus.files.push(fileInfo) this.newStatus.files.push(fileInfo)
this.enableSubmit() this.$emit('resize', { delayed: true })
// TODO: use fixed dimensions instead so relying on timeout
setTimeout(() => {
this.$emit('resize')
}, 150)
}, },
removeMediaFile (fileInfo) { removeMediaFile (fileInfo) {
this.$emit('resize') this.$emit('resize')
@ -266,18 +354,19 @@ 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 () { startedUploadingFiles () {
this.submitDisabled = true this.uploadingFiles = true
}, },
enableSubmit () { finishedUploadingFiles () {
this.submitDisabled = false this.$emit('resize')
this.uploadingFiles = false
}, },
type (fileInfo) { type (fileInfo) {
return fileTypeService.fileType(fileInfo.mimetype) return fileTypeService.fileType(fileInfo.mimetype)
}, },
paste (e) { paste (e) {
this.autoPreview()
this.resize(e) this.resize(e)
if (e.clipboardData.files.length > 0) { if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text // prevent pasting of file as text
@ -289,15 +378,30 @@ 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 = this.uploadFileLimitReached ? 'none' : 'copy'
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show'
}
}, },
onEmojiInputInput (e) { onEmojiInputInput (e) {
this.autoPreview()
this.$nextTick(() => { this.$nextTick(() => {
this.resize(this.$refs['textarea']) this.resize(this.$refs['textarea'])
}) })
@ -309,7 +413,7 @@ const PostStatusForm = {
// Reset to default height for empty form, nothing else to do here. // Reset to default height for empty form, nothing else to do here.
if (target.value === '') { if (target.value === '') {
target.style.height = null target.style.height = null
this.$emit('resize', null) this.$emit('resize')
this.$refs['emoji-input'].resize() this.$refs['emoji-input'].resize()
return return
} }
@ -417,6 +521,15 @@ const PostStatusForm = {
dismissScopeNotice () { dismissScopeNotice () {
this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true }) this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true })
}, },
setMediaDescription (id) {
const description = this.newStatus.mediaDescriptions[id]
if (!description || description.trim() === '') return
return statusPoster.setMediaDescription({ store: this.$store, id, description })
},
setAllMediaDescriptions () {
const ids = this.newStatus.files.map(file => file.id)
return Promise.all(ids.map(id => this.setMediaDescription(id)))
},
handleEmojiInputShow (value) { handleEmojiInputShow (value) {
this.emojiInputShown = value this.emojiInputShown = value
} }

View file

@ -5,11 +5,20 @@
> >
<form <form
autocomplete="off" autocomplete="off"
@submit.prevent="postStatus(newStatus)" @submit.prevent
@dragover.prevent="fileDrag"
> >
<div
v-show="showDropIcon !== 'hide'"
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
class="drop-indicator"
:class="[uploadFileLimitReached ? 'icon-block' : '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' && !disableLockWarning"
path="post_status.account_not_locked_warning" path="post_status.account_not_locked_warning"
tag="p" tag="p"
class="visibility-notice" class="visibility-notice"
@ -61,6 +70,47 @@
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span> <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p> </p>
<div
v-if="!disablePreview"
class="preview-heading faint"
>
<a
class="preview-toggle faint"
@click.stop.prevent="togglePreview"
>
{{ $t('post_status.preview') }}
<i
class="icon-down-open"
:style="{ transform: showPreview ? 'rotate(0deg)' : 'rotate(-90deg)' }"
/>
</a>
<i
v-show="previewLoading"
class="icon-spin3 animate-spin"
/>
</div>
<div
v-if="showPreview"
class="preview-container"
>
<div
v-if="!preview"
class="preview-status"
>
{{ $t('general.loading') }}
</div>
<div
v-else-if="preview.error"
class="preview-status preview-error"
>
{{ preview.error }}
</div>
<StatusContent
v-else
:status="preview"
class="preview-status"
/>
</div>
<EmojiInput <EmojiInput
v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)" v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
@ -72,6 +122,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>
@ -79,9 +130,11 @@
ref="emoji-input" ref="emoji-input"
v-model="newStatus.status" v-model="newStatus.status"
:suggest="emojiUserSuggestor" :suggest="emojiUserSuggestor"
:placement="emojiPickerPlacement"
class="form-control main-input" class="form-control main-input"
enable-emoji-picker enable-emoji-picker
hide-emoji-button hide-emoji-button
:newline-on-ctrl-enter="submitOnEnter"
enable-sticker-picker enable-sticker-picker
@input="onEmojiInputInput" @input="onEmojiInputInput"
@sticker-uploaded="addMediaFile" @sticker-uploaded="addMediaFile"
@ -97,10 +150,8 @@
class="form-post-body" class="form-post-body"
:class="{ 'scrollable-form': !!maxHeight }" :class="{ 'scrollable-form': !!maxHeight }"
@keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)" @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
@keydown.meta.enter="postStatus($event, newStatus, { control: true })" @keydown.meta.enter="postStatus($event, newStatus)"
@keyup.ctrl.enter="postStatus($event, newStatus)" @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
@drop="fileDrop"
@dragover.prevent="fileDrag"
@input="resize" @input="resize"
@compositionupdate="resize" @compositionupdate="resize"
@paste="paste" @paste="paste"
@ -174,10 +225,11 @@
ref="mediaUpload" ref="mediaUpload"
class="media-upload-icon" class="media-upload-icon"
:drop-files="dropFiles" :drop-files="dropFiles"
:disabled="newStatus.files.length >= fileLimit" :disabled="uploadFileLimitReached"
@uploading="disableSubmit" @uploading="startedUploadingFiles"
@uploaded="addMediaFile" @uploaded="addMediaFile"
@upload-failed="uploadFailed" @upload-failed="uploadFailed"
@all-uploaded="finishedUploadingFiles"
/> />
<div <div
class="emoji-icon" class="emoji-icon"
@ -214,12 +266,13 @@
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}
</button> </button>
<!-- touchstart is used to keep the OSK at the same position after a message send -->
<button <button
v-else v-else
:disabled="submitDisabled" :disabled="uploadingFiles || disableSubmit"
class="btn btn-default" class="btn btn-default"
@touchstart.stop.prevent="postStatus($event, newStatus)" @touchstart.stop.prevent="postStatus($event, newStatus)"
@mousedown.stop.prevent="postStatus($event, newStatus)" @click.stop.prevent="postStatus($event, newStatus)"
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}
</button> </button>
@ -244,31 +297,22 @@
class="fa button-icon icon-cancel" class="fa button-icon icon-cancel"
@click="removeMediaFile(file)" @click="removeMediaFile(file)"
/> />
<div class="media-upload-container attachment"> <attachment
<img :attachment="file"
v-if="type(file) === 'image'" :set-media="() => $store.dispatch('setMedia', newStatus.files)"
class="thumbnail media-upload" size="small"
:src="file.url" allow-play="false"
> />
<video <input
v-if="type(file) === 'video'" v-model="newStatus.mediaDescriptions[file.id]"
:src="file.url" type="text"
controls :placeholder="$t('post_status.media_description')"
/> @keydown.enter.prevent=""
<audio >
v-if="type(file) === 'audio'"
:src="file.url"
controls
/>
<a
v-if="type(file) === 'unknown'"
:href="file.url"
>{{ file.url }}</a>
</div>
</div> </div>
</div> </div>
<div <div
v-if="newStatus.files.length > 0" v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
class="upload_settings" class="upload_settings"
> >
<Checkbox v-model="newStatus.nsfw"> <Checkbox v-model="newStatus.nsfw">
@ -304,14 +348,6 @@
.post-status-form { .post-status-form {
position: relative; position: relative;
.visibility-tray {
display: flex;
justify-content: space-between;
padding-top: 5px;
}
}
.post-status-form {
.form-bottom { .form-bottom {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -337,6 +373,48 @@
max-width: 10em; max-width: 10em;
} }
.preview-heading {
display: flex;
width: 100%;
.icon-spin3 {
margin-left: auto;
}
}
.preview-toggle {
display: flex;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.icon-down-open {
transition: transform 0.1s;
}
.preview-container {
margin-bottom: 1em;
}
.preview-error {
font-style: italic;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
.preview-status {
border: 1px solid $fallback--border;
border: 1px solid var(--border, $fallback--border);
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
padding: 0.5em;
margin: 0;
line-height: 1.4em;
}
.text-format { .text-format {
.only-format { .only-format {
color: $fallback--faint; color: $fallback--faint;
@ -344,6 +422,12 @@
} }
} }
.visibility-tray {
display: flex;
justify-content: space-between;
padding-top: 5px;
}
.media-upload-icon, .poll-icon, .emoji-icon { .media-upload-icon, .poll-icon, .emoji-icon {
font-size: 26px; font-size: 26px;
flex: 1; flex: 1;
@ -355,6 +439,19 @@
color: var(--lightText, $fallback--lightText); color: var(--lightText, $fallback--lightText);
} }
} }
&.disabled {
i {
cursor: not-allowed;
color: $fallback--icon;
color: var(--btnDisabledText, $fallback--icon);
&:hover {
color: $fallback--icon;
color: var(--btnDisabledText, $fallback--icon);
}
}
}
} }
// Order is not necessary but a good indicator // Order is not necessary but a good indicator
@ -382,11 +479,9 @@
} }
.media-upload-wrapper { .media-upload-wrapper {
flex: 0 0 auto;
max-width: 100%;
min-width: 50px;
margin-right: .2em; margin-right: .2em;
margin-bottom: .5em; margin-bottom: .5em;
width: 18em;
.icon-cancel { .icon-cancel {
display: inline-block; display: inline-block;
@ -400,6 +495,20 @@
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
} }
img, video {
object-fit: contain;
max-height: 10em;
}
.video {
max-height: 10em;
}
input {
flex: 1;
width: 100%;
}
} }
.status-input-wrapper { .status-input-wrapper {
@ -409,28 +518,13 @@
flex-direction: column; flex-direction: column;
} }
.attachments { .media-upload-wrapper .attachments {
padding: 0 0.5em; padding: 0 0.5em;
.attachment { .attachment {
margin: 0; margin: 0;
padding: 0;
position: relative; position: relative;
flex: 0 0 auto;
border: 1px solid $fallback--border;
border: 1px solid var(--border, $fallback--border);
text-align: center;
audio {
min-width: 300px;
flex: 1 0 auto;
}
a {
display: block;
text-align: left;
line-height: 1.2;
padding: .5em;
}
} }
i { i {
@ -455,7 +549,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 {
@ -517,6 +612,43 @@
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);
}
}
// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before)
img.media-upload, .media-upload-container > video {
line-height: 0;
max-height: 200px;
max-width: 100%;
} }
// todo: unify with attachment.vue (otherwise images the uploaded images are not minified unless a status with an attachment was displayed before) // todo: unify with attachment.vue (otherwise images the uploaded images are not minified unless a status with an attachment was displayed before)

View file

@ -24,7 +24,7 @@ const ReactButton = {
}, },
computed: { computed: {
commonEmojis () { commonEmojis () {
return ['❤️', '😠', '👀', '😂', '🔥'] return ['👍', '😠', '👀', '😂', '🔥']
}, },
emojis () { emojis () {
if (this.filterWord !== '') { if (this.filterWord !== '') {

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,47 @@
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
},
replyVisibility () {
this.$store.dispatch('queueFlushAll')
}
}
}
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,253 @@
import unescape from 'lodash/unescape'
import merge from 'lodash/merge'
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,
newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })),
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,
bot: this.$store.state.users.currentUser.bot,
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
] })
},
userSuggestor () {
return suggestor({
users: this.$store.state.users.users,
updateUsersList: (query) => this.$store.dispatch('searchUsers', { query })
})
},
fieldsLimits () {
return this.$store.state.instance.fieldsLimits
},
maxFields () {
return this.fieldsLimits ? this.fieldsLimits.maxFields : 0
},
defaultAvatar () {
return this.$store.state.instance.server + this.$store.state.instance.defaultAvatar
},
defaultBanner () {
return this.$store.state.instance.server + this.$store.state.instance.defaultBanner
},
isDefaultAvatar () {
const baseAvatar = this.$store.state.instance.defaultAvatar
return !(this.$store.state.users.currentUser.profile_image_url) ||
this.$store.state.users.currentUser.profile_image_url.includes(baseAvatar)
},
isDefaultBanner () {
const baseBanner = this.$store.state.instance.defaultBanner
return !(this.$store.state.users.currentUser.cover_photo) ||
this.$store.state.users.currentUser.cover_photo.includes(baseBanner)
},
isDefaultBackground () {
return !(this.$store.state.users.currentUser.background_image)
},
avatarImgSrc () {
const src = this.$store.state.users.currentUser.profile_image_url_original
return (!src) ? this.defaultAvatar : src
},
bannerImgSrc () {
const src = this.$store.state.users.currentUser.cover_photo
return (!src) ? this.defaultBanner : src
}
},
methods: {
updateProfile () {
this.$store.state.api.backendInteractor
.updateProfile({
params: {
note: this.newBio,
locked: this.newLocked,
// Backend notation.
/* eslint-disable camelcase */
display_name: this.newName,
fields_attributes: this.newFields.filter(el => el != null),
default_scope: this.newDefaultScope,
no_rich_text: this.newNoRichText,
hide_follows: this.hideFollows,
hide_followers: this.hideFollowers,
discoverable: this.discoverable,
bot: this.bot,
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.newFields.splice(user.fields.length)
merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
changeVis (visibility) {
this.newDefaultScope = visibility
},
addField () {
if (this.newFields.length < this.maxFields) {
this.newFields.push({ name: '', value: '' })
return true
}
return false
},
deleteField (index, event) {
this.$delete(this.newFields, index)
},
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)
},
resetAvatar () {
const confirmed = window.confirm(this.$t('settings.reset_avatar_confirm'))
if (confirmed) {
this.submitAvatar(undefined, '')
}
},
resetBanner () {
const confirmed = window.confirm(this.$t('settings.reset_banner_confirm'))
if (confirmed) {
this.submitBanner('')
}
},
resetBackground () {
const confirmed = window.confirm(this.$t('settings.reset_background_confirm'))
if (confirmed) {
this.submitBackground('')
}
},
submitAvatar (cropper, file) {
const that = this
return new Promise((resolve, reject) => {
function updateAvatar (avatar) {
that.$store.state.api.backendInteractor.updateProfileImages({ 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 (banner) {
if (!this.bannerPreview && banner !== '') { return }
this.bannerUploading = true
this.$store.state.api.backendInteractor.updateProfileImages({ 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 })
},
submitBackground (background) {
if (!this.backgroundPreview && background !== '') { return }
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateProfileImages({ 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,128 @@
@import '../../../_variables.scss';
.profile-tab {
.bio {
margin: 0;
}
.visibility-tray {
padding-top: 5px;
}
input[type=file] {
padding: 5px;
height: auto;
}
.banner-background-preview {
max-width: 100%;
width: 300px;
position: relative;
img {
width: 100%;
}
}
.uploading {
font-size: 1.5em;
margin: 0.25em;
}
.name-changer {
width: 100%;
}
.current-avatar-container {
position: relative;
width: 150px;
height: 150px;
}
.current-avatar {
display: block;
width: 100%;
height: 100%;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
}
.reset-button {
position: absolute;
top: 0.2em;
right: 0.2em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
background-color: rgba(0, 0, 0, 0.6);
opacity: 0.7;
color: white;
width: 1.5em;
height: 1.5em;
text-align: center;
line-height: 1.5em;
font-size: 1.5em;
cursor: pointer;
&:hover {
opacity: 1;
}
}
.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;
}
.profile-fields {
display: flex;
&>.emoji-input {
flex: 1 1 auto;
margin: 0 .2em .5em;
min-width: 0;
}
&>.icon-container {
width: 20px;
&>.icon-cancel {
vertical-align: sub;
}
}
}
}

View file

@ -0,0 +1,289 @@
<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>
<div v-if="maxFields > 0">
<p>{{ $t('settings.profile_fields.label') }}</p>
<div
v-for="(_, i) in newFields"
:key="i"
class="profile-fields"
>
<EmojiInput
v-model="newFields[i].name"
enable-emoji-picker
hide-emoji-button
:suggest="userSuggestor"
>
<input
v-model="newFields[i].name"
:placeholder="$t('settings.profile_fields.name')"
>
</EmojiInput>
<EmojiInput
v-model="newFields[i].value"
enable-emoji-picker
hide-emoji-button
:suggest="userSuggestor"
>
<input
v-model="newFields[i].value"
:placeholder="$t('settings.profile_fields.value')"
>
</EmojiInput>
<div
class="icon-container"
>
<i
v-show="newFields.length > 1"
class="icon-cancel"
@click="deleteField(i)"
/>
</div>
</div>
<a
v-if="newFields.length < maxFields"
class="add-field faint"
@click="addField"
>
<i class="icon-plus" />
{{ $t("settings.profile_fields.add_field") }}
</a>
</div>
<p>
<Checkbox v-model="bot">
{{ $t('settings.bot') }}
</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>
<div class="current-avatar-container">
<img
:src="user.profile_image_url_original"
class="current-avatar"
>
<i
v-if="!isDefaultAvatar && pickAvatarBtnVisible"
:title="$t('settings.reset_avatar')"
class="reset-button icon-cancel"
type="button"
@click="resetAvatar"
/>
</div>
<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>
<div class="banner-background-preview">
<img :src="user.cover_photo">
<i
v-if="!isDefaultBanner"
:title="$t('settings.reset_profile_banner')"
class="reset-button icon-cancel"
type="button"
@click="resetBanner"
/>
</div>
<p>{{ $t('settings.set_new_profile_banner') }}</p>
<img
v-if="bannerPreview"
class="banner-background-preview"
: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(banner)"
>
{{ $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>
<div class="banner-background-preview">
<img :src="user.background_image">
<i
v-if="!isDefaultBackground"
:title="$t('settings.reset_profile_background')"
class="reset-button icon-cancel"
type="button"
@click="resetBackground"
/>
</div>
<p>{{ $t('settings.set_new_profile_background') }}</p>
<img
v-if="backgroundPreview"
class="banner-background-preview"
: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="submitBackground(background)"
>
{{ $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 {
color: $fallback--cOrange; .warning {
color: var(--cOrange, $fallback--cOrange); color: $fallback--cOrange;
} color: var(--cOrange, $fallback--cOrange);
.backup-codes { }
font-family: var(--postCodeFont, monospace); .backup-codes {
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

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