diff options
116 files changed, 4438 insertions, 536 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 412cdfdd..0159fa01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - '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 - Registration page no longer requires email if the server is configured not to require it @@ -26,6 +31,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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 - Custom Emoji will display in poll options now. @@ -37,6 +43,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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 ### Fixed @@ -98,6 +110,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Ability to change user's email - About page - Added remote user redirect +- Bookmarks ### 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 ### Fixed diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index f417f33d..241ad331 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -8,8 +8,6 @@ > > --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. 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. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..8764f9ab --- /dev/null +++ b/docs/index.md @@ -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). diff --git a/package.json b/package.json index c0665f6e..96231171 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "cropperjs": "^1.4.3", "diff": "^3.0.1", "escape-html": "^1.0.3", + "parse-link-header": "^1.0.1", "localforage": "^1.5.0", "phoenix": "^1.3.0", "portal-vue": "^2.1.4", @@ -13,7 +13,8 @@ import MobilePostStatusButton from './components/mobile_post_status_button/mobil import MobileNav from './components/mobile_nav/mobile_nav.vue' import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue' import PostStatusModal from './components/post_status_modal/post_status_modal.vue' -import { windowWidth } from './services/window_utils/window_utils' +import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue' +import { windowWidth, windowHeight } from './services/window_utils/window_utils' export default { name: 'app', @@ -32,7 +33,8 @@ export default { MobileNav, SettingsModal, UserReportingModal, - PostStatusModal + PostStatusModal, + GlobalNoticeList }, data: () => ({ mobileActivePanel: 'timeline', @@ -125,10 +127,12 @@ export default { }, updateMobileState () { const mobileLayout = windowWidth() <= 800 + const layoutHeight = windowHeight() const changed = mobileLayout !== this.isMobileLayout if (changed) { this.$store.dispatch('setMobileLayout', mobileLayout) } + this.$store.dispatch('setLayoutHeight', layoutHeight) } } } diff --git a/src/App.scss b/src/App.scss index f2972eda..e2e2d079 100644 --- a/src/App.scss +++ b/src/App.scss @@ -47,6 +47,7 @@ html { } body { + overscroll-behavior-y: none; font-family: sans-serif; font-family: var(--interfaceFont, sans-serif); margin: 0; @@ -319,7 +320,7 @@ option { i[class*=icon-] { color: $fallback--icon; - color: var(--icon, $fallback--icon) + color: var(--icon, $fallback--icon); } .btn-block { @@ -858,6 +859,10 @@ nav { display: block; margin-right: 0.8em; } + + .main { + margin-bottom: 7em; + } } .select-multiple { @@ -924,3 +929,51 @@ nav { background-color: $fallback--fg; background-color: var(--panel, $fallback--fg); } + +.unread-chat-count { + font-size: 0.9em; + font-weight: bolder; + font-style: normal; + position: absolute; + right: 0.6rem; + padding: 0 0.3em; + min-width: 1.3rem; + min-height: 1.3rem; + max-height: 1.3rem; + line-height: 1.3rem; +} + +.chat-layout { + // Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens). + overflow: hidden; + height: 100%; + + // Ensures the fixed position of the mobile browser bars on scroll up / down events. + // Prevents the mobile browser bars from overlapping or hiding the message posting form. + @media all and (max-width: 800px) { + 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; + } + } +} diff --git a/src/App.vue b/src/App.vue index 7b9ad3dc..0276c6a6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -77,6 +77,7 @@ </div> </div> </nav> + <div class="app-bg-wrapper app-container-wrapper" /> <div id="content" class="container underlay" @@ -112,9 +113,7 @@ {{ $t("login.hint") }} </router-link> </div> - <transition name="fade"> - <router-view /> - </transition> + <router-view /> </div> <media-modal /> </div> @@ -128,6 +127,7 @@ <PostStatusModal /> <SettingsModal /> <portal-target name="modal" /> + <GlobalNoticeList /> </div> </template> diff --git a/src/_variables.scss b/src/_variables.scss index 30dc3e42..9004d551 100644 --- a/src/_variables.scss +++ b/src/_variables.scss @@ -27,5 +27,6 @@ $fallback--tooltipRadius: 5px; $fallback--avatarRadius: 4px; $fallback--avatarAltRadius: 10px; $fallback--attachmentRadius: 10px; +$fallback--chatMessageRadius: 10px; $fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset; diff --git a/src/boot/after_store.js b/src/boot/after_store.js index 0db03547..00ca74a2 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -8,38 +8,72 @@ import backendInteractorService from '../services/backend_interactor_service/bac import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.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 decodeUTF8Base64 = (data) => { + const rawData = atob(data) + const array = Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))) + const text = new TextDecoder().decode(array) + return text +} + +const preloadFetch = async (request) => { + const data = parsedInitialResults() + if (!data || !data[request]) { + return window.fetch(request) + } + const decoded = decodeUTF8Base64(data[request]) + const requestData = JSON.parse(decoded) + return { + ok: true, + json: () => requestData, + text: () => requestData + } +} + +const getInstanceConfig = async ({ store }) => { try { - const res = await window.fetch('/api/statusnet/config.json') + const res = await preloadFetch('/api/v1/instance') if (res.ok) { const data = await res.json() - const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey, safeDMMentionsEnabled } = data.site - - store.dispatch('setInstanceOption', { name: 'name', value: name }) - 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) }) - } + const textlimit = data.max_toot_chars + const vapidPublicKey = data.pleroma.vapid_public_key + + store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit }) if (vapidPublicKey) { store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey }) } + } else { + throw (res) + } + } catch (error) { + console.error('Could not load instance config, potentially fatal') + console.error(error) + } +} - return data.site.pleromafe +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 statusnet config, potentially fatal') + console.error('Could not load backend-provided frontend config, potentially fatal') console.error(error) } } @@ -132,7 +166,7 @@ const getTOS = async ({ store }) => { const getInstancePanel = async ({ store }) => { try { - const res = await window.fetch('/instance/panel.html') + const res = await preloadFetch('/instance/panel.html') if (res.ok) { const html = await res.text() store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html }) @@ -189,24 +223,34 @@ const getAppSecret = async ({ store }) => { const resolveStaffAccounts = ({ store, accounts }) => { const nicknames = accounts.map(uri => uri.split('/').pop()) - nicknames.map(nickname => store.dispatch('fetchUser', nickname)) store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames }) } const getNodeInfo = async ({ store }) => { try { - const res = await window.fetch('/nodeinfo/2.0.json') + const res = await preloadFetch('/nodeinfo/2.0.json') if (res.ok) { const data = await res.json() const metadata = data.metadata 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: 'safeDM', value: features.includes('safe_dm_mentions') }) 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: 'pollsAvailable', value: features.includes('polls') }) store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) 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: 'postFormats', value: metadata.postFormats }) @@ -257,7 +301,7 @@ const getNodeInfo = async ({ store }) => { const setConfig = async ({ store }) => { // apiConfig, staticConfig - const configInfos = await Promise.all([getStatusnetConfig({ store }), getStaticConfig()]) + const configInfos = await Promise.all([getBackendProvidedConfig({ store }), getStaticConfig()]) const apiConfig = configInfos[0] const staticConfig = configInfos[1] @@ -280,6 +324,11 @@ const checkOAuthToken = async ({ store }) => { const afterStoreSetup = async ({ store, i18n }) => { const width = windowWidth() 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 }) const { customTheme, customThemeSource } = store.state.config @@ -299,16 +348,18 @@ const afterStoreSetup = async ({ store, i18n }) => { } // 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([ checkOAuthToken({ store }), - getTOS({ store }), getInstancePanel({ store }), - getStickers({ store }), - getNodeInfo({ store }) + getNodeInfo({ store }), + getInstanceConfig({ store }) ]) // Start fetching things that don't need to block the UI store.dispatch('fetchMutes') + getTOS({ store }) + getStickers({ store }) const router = new VueRouter({ mode: 'history', diff --git a/src/boot/routes.js b/src/boot/routes.js index d98a3b50..b5d3c631 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -2,9 +2,12 @@ import PublicTimeline from 'components/public_timeline/public_timeline.vue' import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue' import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue' import TagTimeline from 'components/tag_timeline/tag_timeline.vue' +import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue' import ConversationPage from 'components/conversation-page/conversation-page.vue' import Interactions from 'components/interactions/interactions.vue' import DMs from 'components/dm_timeline/dm_timeline.vue' +import ChatList from 'components/chat_list/chat_list.vue' +import Chat from 'components/chat/chat.vue' import UserProfile from 'components/user_profile/user_profile.vue' import Search from 'components/search/search.vue' import Registration from 'components/registration/registration.vue' @@ -27,7 +30,7 @@ export default (store) => { } } - return [ + let routes = [ { name: 'root', path: '/', redirect: _to => { @@ -40,6 +43,7 @@ export default (store) => { { name: 'public-timeline', path: '/main/public', component: PublicTimeline }, { name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute }, { 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: 'remote-user-profile-acct', path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)', @@ -60,11 +64,20 @@ export default (store) => { { name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute }, { name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute }, { name: 'login', path: '/login', component: AuthForm }, - { name: 'chat', 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: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) }, { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute }, { name: 'about', path: '/about', component: About }, { name: '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 } diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js index 0826c275..6d345bc7 100644 --- a/src/components/account_actions/account_actions.js +++ b/src/components/account_actions/account_actions.js @@ -1,3 +1,4 @@ +import { mapState } from 'vuex' import ProgressButton from '../progress_button/progress_button.vue' import Popover from '../popover/popover.vue' @@ -27,7 +28,18 @@ const AccountActions = { }, reportUser () { this.$store.dispatch('openUserReportingModal', this.user.id) + }, + openChat () { + this.$router.push({ + name: 'chat', + params: { recipient_id: this.user.id } + }) } + }, + computed: { + ...mapState({ + pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable + }) } } diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue index 029e7096..987e94b7 100644 --- a/src/components/account_actions/account_actions.vue +++ b/src/components/account_actions/account_actions.vue @@ -50,6 +50,13 @@ > {{ $t('user_card.report') }} </button> + <button + v-if="pleromaChatMessagesAvailable" + class="btn btn-default btn-block dropdown-item" + @click="openChat" + > + {{ $t('user_card.message') }} + </button> </div> </div> <div diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js index b832e10f..cb31020d 100644 --- a/src/components/attachment/attachment.js +++ b/src/components/attachment/attachment.js @@ -8,7 +8,6 @@ const Attachment = { props: [ 'attachment', 'nsfw', - 'statusId', 'size', 'allowPlay', 'setMedia', @@ -30,9 +29,21 @@ const Attachment = { VideoAttachment }, computed: { - usePlaceHolder () { + usePlaceholder () { 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 () { return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer' }, @@ -49,7 +60,15 @@ const Attachment = { return this.size === 'small' }, 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']) }, @@ -60,12 +79,7 @@ const Attachment = { } }, openModal (event) { - const modalTypes = this.mergedConfig.playVideosInModal - ? ['image', 'video'] - : ['image'] - if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) || - this.usePlaceHolder - ) { + if (this.useModal) { event.stopPropagation() event.preventDefault() this.setMedia() diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index a7e217c1..be7377e9 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -1,6 +1,7 @@ <template> <div - v-if="usePlaceHolder" + v-if="usePlaceholder" + :class="{ 'fullwidth': fullwidth }" @click="openModal" > <a @@ -8,8 +9,11 @@ class="placeholder" target="_blank" :href="attachment.url" + :alt="attachment.description" + :title="attachment.description" > - [{{ nsfw ? "NSFW/" : "" }}{{ type.toUpperCase() }}] + <span :class="placeholderIconClass" /> + <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }} </a> </div> <div @@ -22,6 +26,8 @@ v-if="hidden" class="image-attachment" :href="attachment.url" + :alt="attachment.description" + :title="attachment.description" @click.prevent="toggleHidden" > <img @@ -51,7 +57,6 @@ :class="{'hidden': hidden && preloadImage }" :href="attachment.url" target="_blank" - :title="attachment.description" @click="openModal" > <StillImage @@ -59,6 +64,7 @@ :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url" :image-load-handler="onImageLoad" + :alt="attachment.description" /> </a> @@ -83,6 +89,8 @@ <audio v-if="type === 'audio'" :src="attachment.url" + :alt="attachment.description" + :title="attachment.description" controls /> @@ -116,22 +124,19 @@ display: flex; flex-wrap: wrap; - .attachment.media-upload-container { - flex: 0 0 auto; - max-height: 200px; + .non-gallery { max-width: 100%; - display: flex; - align-items: center; - video { - max-width: 100%; - } } .placeholder { - margin-right: 8px; - margin-bottom: 4px; + display: inline-block; + padding: 0.3em 1em 0.3em 0; color: $fallback--link; color: var(--postLink, $fallback--link); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 100%; } .nsfw-placeholder { diff --git a/src/components/bookmark_timeline/bookmark_timeline.js b/src/components/bookmark_timeline/bookmark_timeline.js new file mode 100644 index 00000000..64b69e5d --- /dev/null +++ b/src/components/bookmark_timeline/bookmark_timeline.js @@ -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 diff --git a/src/components/bookmark_timeline/bookmark_timeline.vue b/src/components/bookmark_timeline/bookmark_timeline.vue new file mode 100644 index 00000000..8da6884b --- /dev/null +++ b/src/components/bookmark_timeline/bookmark_timeline.vue @@ -0,0 +1,9 @@ +<template> + <Timeline + :title="$t('nav.bookmarks')" + :timeline="timeline" + :timeline-name="'bookmarks'" + /> +</template> + +<script src="./bookmark_timeline.js"></script> diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js new file mode 100644 index 00000000..9c4e5b05 --- /dev/null +++ b/src/components/chat/chat.js @@ -0,0 +1,333 @@ +import _ from 'lodash' +import { WSConnectionStatus } from '../../services/api/api.service.js' +import { mapGetters, mapState } from 'vuex' +import ChatMessage from '../chat_message/chat_message.vue' +import PostStatusForm from '../post_status_form/post_status_form.vue' +import ChatTitle from '../chat_title/chat_title.vue' +import chatService from '../../services/chat_service/chat_service.js' +import { 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 = { + components: { + ChatMessage, + ChatTitle, + PostStatusForm + }, + data () { + return { + jumpToBottomButtonVisible: false, + hoveredMessageChainId: undefined, + lastScrollPosition: {}, + scrollableContainerHeight: '100%', + errorLoadingChat: false + } + }, + created () { + this.startFetching() + window.addEventListener('resize', this.handleLayoutChange) + }, + mounted () { + window.addEventListener('scroll', this.handleScroll) + if (typeof document.hidden !== 'undefined') { + document.addEventListener('visibilitychange', this.handleVisibilityChange, false) + } + + this.$nextTick(() => { + this.updateScrollableContainerHeight() + this.handleResize() + }) + this.setChatLayout() + }, + destroyed () { + window.removeEventListener('scroll', this.handleScroll) + window.removeEventListener('resize', this.handleLayoutChange) + this.unsetChatLayout() + if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false) + this.$store.dispatch('clearCurrentChat') + }, + computed: { + recipient () { + return this.currentChat && this.currentChat.account + }, + recipientId () { + return this.$route.params.recipient_id + }, + formPlaceholder () { + if (this.recipient) { + return this.$t('chats.message_user', { nickname: this.recipient.screen_name }) + } else { + return '' + } + }, + chatViewItems () { + return chatService.getView(this.currentChatMessageService) + }, + newMessageCount () { + return this.currentChatMessageService && this.currentChatMessageService.newMessageCount + }, + streamingEnabled () { + return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED + }, + ...mapGetters([ + 'currentChat', + 'currentChatMessageService', + 'findOpenedChatByRecipientId', + 'mergedConfig' + ]), + ...mapState({ + backendInteractor: state => state.api.backendInteractor, + mastoUserSocketStatus: state => state.api.mastoUserSocketStatus, + mobileLayout: state => state.interface.mobileLayout, + layoutHeight: state => state.interface.layoutHeight, + currentUser: state => state.users.currentUser + }) + }, + watch: { + chatViewItems () { + // We don't want to scroll to the bottom on a new message when the user is viewing older messages. + // Therefore we need to know whether the scroll position was at the bottom before the DOM update. + const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET) + this.$nextTick(() => { + if (bottomedOutBeforeUpdate) { + this.scrollDown({ forceRead: !document.hidden }) + } + }) + }, + '$route': function () { + this.startFetching() + }, + layoutHeight () { + this.handleResize({ expand: true }) + }, + mastoUserSocketStatus (newValue) { + if (newValue === WSConnectionStatus.JOINED) { + this.fetchChat({ isFirstFetch: true }) + } + } + }, + methods: { + // Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered + onMessageHover ({ isHovered, messageChainId }) { + this.hoveredMessageChainId = isHovered ? messageChainId : undefined + }, + onFilesDropped () { + this.$nextTick(() => { + this.handleResize() + this.updateScrollableContainerHeight() + }) + }, + handleVisibilityChange () { + this.$nextTick(() => { + if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) { + this.scrollDown({ forceRead: true }) + } + }) + }, + setChatLayout () { + // This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app). + // This layout prevents empty spaces from being visible at the bottom + // of the chat on iOS Safari (`safe-area-inset`) when + // - the on-screen keyboard appears and the user starts typing + // - the user selects the text inside the input area + // - the user selects and deletes the text that is multiple lines long + // TODO: unify the chat layout with the global layout. + let html = document.querySelector('html') + if (html) { + html.classList.add('chat-layout') + } + + 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 = {}) { + const { behavior = 'auto', forceRead = false } = options + const scrollable = this.$refs.scrollable + if (!scrollable) { return } + this.$nextTick(() => { + scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior }) + }) + if (forceRead || this.newMessageCount > 0) { + this.readChat() + } + }, + readChat () { + if (!(this.currentChatMessageService && this.currentChatMessageService.lastMessage)) { return } + if (document.hidden) { return } + const lastReadId = this.currentChatMessageService.lastMessage.id + this.$store.dispatch('readChat', { id: this.currentChat.id, lastReadId }) + }, + bottomedOut (offset) { + return isBottomedOut(this.$refs.scrollable, offset) + }, + reachedTop () { + const scrollable = this.$refs.scrollable + return scrollable && scrollable.scrollTop <= 0 + }, + handleScroll: _.throttle(function () { + if (!this.currentChat) { return } + + if (this.reachedTop()) { + this.fetchChat({ maxId: this.currentChatMessageService.minId }) + } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { + this.jumpToBottomButtonVisible = false + if (this.newMessageCount > 0) { + this.readChat() + } + } else { + this.jumpToBottomButtonVisible = true + } + }, 100), + handleScrollUp (positionBeforeLoading) { + const positionAfterLoading = getScrollPosition(this.$refs.scrollable) + this.$refs.scrollable.scrollTo({ + top: getNewTopPosition(positionBeforeLoading, positionAfterLoading), + left: 0 + }) + }, + fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) { + const chatMessageService = this.currentChatMessageService + if (!chatMessageService) { return } + if (fetchLatest && this.streamingEnabled) { return } + + const chatId = chatMessageService.chatId + const fetchOlderMessages = !!maxId + const sinceId = fetchLatest && chatMessageService.lastMessage && chatMessageService.lastMessage.id + + this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId }) + .then((messages) => { + // Clear the current chat in case we're recovering from a ws connection loss. + if (isFirstFetch) { + chatService.clear(chatMessageService) + } + + const positionBeforeUpdate = getScrollPosition(this.$refs.scrollable) + this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => { + this.$nextTick(() => { + if (fetchOlderMessages) { + this.handleScrollUp(positionBeforeUpdate) + } + + if (isFirstFetch) { + this.updateScrollableContainerHeight() + } + }) + }) + }) + }, + async startFetching () { + let chat = this.findOpenedChatByRecipientId(this.recipientId) + if (!chat) { + try { + chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId }) + } catch (e) { + console.error('Error creating or getting a chat', e) + this.errorLoadingChat = true + } + } + if (chat) { + this.$nextTick(() => { + this.scrollDown({ forceRead: true }) + }) + this.$store.dispatch('addOpenedChat', { chat }) + this.doStartFetching() + } + }, + doStartFetching () { + this.$store.dispatch('startFetchingCurrentChat', { + fetcher: () => setInterval(() => this.fetchChat({ fetchLatest: true }), 5000) + }) + this.fetchChat({ isFirstFetch: true }) + }, + sendMessage ({ status, media }) { + const params = { + id: this.currentChat.id, + content: status + } + + if (media[0]) { + params.mediaId = media[0].id + } + + 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 } }) + } + } +} + +export default Chat diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss new file mode 100644 index 00000000..012a1b1d --- /dev/null +++ b/src/components/chat/chat.scss @@ -0,0 +1,162 @@ +.chat-view { + display: flex; + height: calc(100vh - 60px); + width: 100%; + + .chat-title { + // prevents chat header jumping on when the user avatar loads + height: 28px; + } + + .chat-view-inner { + height: auto; + width: 100%; + overflow: visible; + display: flex; + margin: 0.5em 0.5em 0 0.5em; + } + + .chat-view-body { + 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; + 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%; + display: flex; + align-items: flex-end; + height: 100%; + + .error { + width: 100%; + } + } + } + + @media all and (max-width: 800px) { + height: 100%; + overflow: hidden; + + .chat-view-inner { + overflow: hidden; + height: 100%; + margin-top: 0; + margin-left: 0; + margin-right: 0; + } + + .chat-view-body { + display: flex; + min-height: auto; + overflow: hidden; + height: 100%; + margin: 0; + border-radius: 0; + } + + .chat-view-heading { + position: static; + z-index: 9999; + top: 0; + margin-top: 0; + border-radius: 0; + } + + .scrollable-message-list { + display: unset; + overflow-y: scroll; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + } + + .footer { + position: sticky; + bottom: auto; + } + } +} diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue new file mode 100644 index 00000000..2e4538c8 --- /dev/null +++ b/src/components/chat/chat.vue @@ -0,0 +1,100 @@ +<template> + <div class="chat-view"> + <div class="chat-view-inner"> + <div + id="nav" + ref="inner" + class="panel-default panel chat-view-body" + > + <div + ref="header" + class="panel-heading chat-view-heading mobile-hidden" + > + <a + class="go-back-button" + @click="goBack" + > + <i class="button-icon icon-left-open" /> + </a> + <div class="title text-center"> + <ChatTitle + :user="recipient" + :with-avatar="true" + /> + </div> + </div> + <template> + <div + ref="scrollable" + class="scrollable-message-list" + :style="{ height: scrollableContainerHeight }" + @scroll="handleScroll" + > + <template v-if="!errorLoadingChat"> + <ChatMessage + v-for="chatViewItem in chatViewItems" + :key="chatViewItem.id" + :author="recipient" + :chat-view-item="chatViewItem" + :hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId" + @hover="onMessageHover" + /> + </template> + <div + v-else + class="chat-loading-error" + > + <div class="alert error"> + {{ $t('chats.error_loading_chat') }} + </div> + </div> + </div> + <div + ref="footer" + class="panel-body footer" + > + <div + class="jump-to-bottom-button" + :class="{ 'visible': jumpToBottomButtonVisible }" + @click="scrollDown({ behavior: 'smooth' })" + > + <i class="icon-down-open"> + <div + v-if="newMessageCount" + class="badge badge-notification unread-chat-count unread-message-count" + > + {{ newMessageCount }} + </div> + </i> + </div> + <PostStatusForm + :disable-subject="true" + :disable-scope-selector="true" + :disable-notice="true" + :disable-lock-warning="true" + :disable-polls="true" + :disable-sensitivity-checkbox="true" + :disable-submit="errorLoadingChat || !currentChat" + :disable-preview="true" + :post-handler="sendMessage" + :submit-on-enter="!mobileLayout" + :preserve-focus="!mobileLayout" + :auto-focus="!mobileLayout" + :placeholder="formPlaceholder" + :file-limit="1" + max-height="160" + emoji-picker-placement="top" + @resize="handleResize" + /> + </div> + </template> + </div> + </div> + </div> +</template> + +<script src="./chat.js"></script> +<style lang="scss"> +@import '../../_variables.scss'; +@import './chat.scss'; +</style> diff --git a/src/components/chat/chat_layout_utils.js b/src/components/chat/chat_layout_utils.js new file mode 100644 index 00000000..609dc0c9 --- /dev/null +++ b/src/components/chat/chat_layout_utils.js @@ -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 +} diff --git a/src/components/chat_list/chat_list.js b/src/components/chat_list/chat_list.js new file mode 100644 index 00000000..95708d1d --- /dev/null +++ b/src/components/chat_list/chat_list.js @@ -0,0 +1,37 @@ +import { mapState, mapGetters } from 'vuex' +import ChatListItem from '../chat_list_item/chat_list_item.vue' +import ChatNew from '../chat_new/chat_new.vue' +import List from '../list/list.vue' + +const ChatList = { + components: { + ChatListItem, + List, + ChatNew + }, + computed: { + ...mapState({ + currentUser: state => state.users.currentUser + }), + ...mapGetters(['sortedChatList']) + }, + data () { + return { + isNew: false + } + }, + created () { + this.$store.dispatch('fetchChats', { latest: true }) + }, + methods: { + cancelNewChat () { + this.isNew = false + this.$store.dispatch('fetchChats', { latest: true }) + }, + newChat () { + this.isNew = true + } + } +} + +export default ChatList diff --git a/src/components/chat_list/chat_list.vue b/src/components/chat_list/chat_list.vue new file mode 100644 index 00000000..17e2f795 --- /dev/null +++ b/src/components/chat_list/chat_list.vue @@ -0,0 +1,64 @@ +<template> + <div v-if="isNew"> + <ChatNew @cancel="cancelNewChat" /> + </div> + <div + v-else + class="chat-list panel panel-default" + > + <div class="panel-heading"> + <span class="title"> + {{ $t("chats.chats") }} + </span> + <button @click="newChat"> + {{ $t("chats.new") }} + </button> + </div> + <div class="panel-body"> + <div + v-if="sortedChatList.length > 0" + class="timeline" + > + <List :items="sortedChatList"> + <template + slot="item" + slot-scope="{item}" + > + <ChatListItem + :key="item.id" + :compact="false" + :chat="item" + /> + </template> + </List> + </div> + <div + v-else + class="emtpy-chat-list-alert" + > + <span>{{ $t('chats.empty_chat_list_placeholder') }}</span> + </div> + </div> + </div> +</template> + +<script src="./chat_list.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.chat-list { + min-height: 25em; + margin-bottom: 0; +} + +.emtpy-chat-list-alert { + padding: 3em; + font-size: 1.2em; + display: flex; + justify-content: center; + color: $fallback--text; + color: var(--faint, $fallback--text); +} + +</style> diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js new file mode 100644 index 00000000..bee1ad53 --- /dev/null +++ b/src/components/chat_list_item/chat_list_item.js @@ -0,0 +1,67 @@ +import { mapState } from 'vuex' +import StatusContent from '../status_content/status_content.vue' +import fileType from 'src/services/file_type/file_type.service' +import UserAvatar from '../user_avatar/user_avatar.vue' +import AvatarList from '../avatar_list/avatar_list.vue' +import Timeago from '../timeago/timeago.vue' +import ChatTitle from '../chat_title/chat_title.vue' + +const ChatListItem = { + name: 'ChatListItem', + props: [ + 'chat' + ], + components: { + UserAvatar, + AvatarList, + Timeago, + ChatTitle, + StatusContent + }, + computed: { + ...mapState({ + currentUser: state => state.users.currentUser + }), + attachmentInfo () { + if (this.chat.lastMessage.attachments.length === 0) { return } + + const types = this.chat.lastMessage.attachments.map(file => fileType.fileType(file.mimetype)) + if (types.includes('video')) { + return this.$t('file_type.video') + } else if (types.includes('audio')) { + return this.$t('file_type.audio') + } else if (types.includes('image')) { + return this.$t('file_type.image') + } else { + return this.$t('file_type.file') + } + }, + messageForStatusContent () { + const message = this.chat.lastMessage + const isYou = message && message.account_id === this.currentUser.id + const content = message ? (this.attachmentInfo || message.content) : '' + const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content + return { + summary: '', + statusnet_html: messagePreview, + text: messagePreview, + attachments: [] + } + } + }, + methods: { + openChat (_e) { + if (this.chat.id) { + this.$router.push({ + name: 'chat', + params: { + username: this.currentUser.screen_name, + recipient_id: this.chat.account.id + } + }) + } + } + } +} + +export default ChatListItem diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss new file mode 100644 index 00000000..617054ec --- /dev/null +++ b/src/components/chat_list_item/chat_list_item.scss @@ -0,0 +1,94 @@ +.chat-list-item { + display: flex; + flex-direction: row; + padding: 0.75em; + height: 5em; + overflow: hidden; + box-sizing: border-box; + cursor: pointer; + + :focus { + outline: none; + } + + &:hover { + background-color: var(--selectedPost, $fallback--lightBg); + box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1); + } + + .chat-list-item-left { + margin-right: 1em; + } + + .chat-list-item-center { + width: 100%; + box-sizing: border-box; + overflow: hidden; + word-wrap: break-word; + } + + .heading { + width: 100%; + display: inline-flex; + justify-content: space-between; + line-height: 1em; + } + + .heading-right { + white-space: nowrap; + } + + .name-and-account-name { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + flex-shrink: 1; + line-height: 1.4em; + } + + .chat-preview { + display: inline-flex; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 0.35em 0; + color: $fallback--text; + color: var(--faint, $fallback--text); + width: 100%; + } + + a { + color: var(--faintLink, $fallback--link); + text-decoration: none; + pointer-events: none; + } + + &:hover .animated.avatar { + canvas { + display: none; + } + img { + visibility: visible; + } + } + + .avatar.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; + } +} diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue new file mode 100644 index 00000000..1f8ecdf6 --- /dev/null +++ b/src/components/chat_list_item/chat_list_item.vue @@ -0,0 +1,52 @@ +<template> + <div + class="chat-list-item" + @click.capture.prevent="openChat" + > + <div class="chat-list-item-left"> + <UserAvatar + :user="chat.account" + height="48px" + width="48px" + /> + </div> + <div class="chat-list-item-center"> + <div class="heading"> + <span + v-if="chat.account" + class="name-and-account-name" + > + <ChatTitle + :user="chat.account" + /> + </span> + <span class="heading-right" /> + </div> + <div class="chat-preview"> + <StatusContent + :status="messageForStatusContent" + :single-line="true" + /> + <div + v-if="chat.unread > 0" + class="badge badge-notification unread-chat-count" + > + {{ chat.unread }} + </div> + </div> + </div> + <div class="time-wrapper"> + <Timeago + :time="chat.updated_at" + :auto-update="60" + /> + </div> + </div> +</template> + +<script src="./chat_list_item.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; +@import './chat_list_item.scss'; +</style> diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js new file mode 100644 index 00000000..be4a7c89 --- /dev/null +++ b/src/components/chat_message/chat_message.js @@ -0,0 +1,96 @@ +import { mapState, mapGetters } from 'vuex' +import Popover from '../popover/popover.vue' +import Attachment from '../attachment/attachment.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' +import Gallery from '../gallery/gallery.vue' +import LinkPreview from '../link-preview/link-preview.vue' +import StatusContent from '../status_content/status_content.vue' +import ChatMessageDate from '../chat_message_date/chat_message_date.vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' + +const ChatMessage = { + name: 'ChatMessage', + props: [ + 'author', + 'edited', + 'noHeading', + 'chatViewItem', + 'hoveredMessageChain' + ], + components: { + Popover, + Attachment, + StatusContent, + UserAvatar, + Gallery, + LinkPreview, + ChatMessageDate + }, + computed: { + // Returns HH:MM (hours and minutes) in local time. + createdAt () { + const time = this.chatViewItem.data.created_at + return time.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }) + }, + isCurrentUser () { + return this.message.account_id === this.currentUser.id + }, + message () { + return this.chatViewItem.data + }, + userProfileLink () { + return generateProfileLink(this.author.id, this.author.screen_name, this.$store.state.instance.restrictedNicknames) + }, + isMessage () { + return this.chatViewItem.type === 'message' + }, + messageForStatusContent () { + return { + summary: '', + statusnet_html: this.message.content, + text: this.message.content, + attachments: this.message.attachments + } + }, + hasAttachment () { + return this.message.attachments.length > 0 + }, + ...mapState({ + betterShadow: state => state.interface.browserSupport.cssFilter, + currentUser: state => state.users.currentUser, + restrictedNicknames: state => state.instance.restrictedNicknames + }), + popoverMarginStyle () { + if (this.isCurrentUser) { + return {} + } else { + return { left: 50 } + } + }, + ...mapGetters(['mergedConfig', 'findUser']) + }, + data () { + return { + hovered: false, + menuOpened: false + } + }, + methods: { + onHover (bool) { + this.$emit('hover', { isHovered: bool, messageChainId: this.chatViewItem.messageChainId }) + }, + async deleteMessage () { + const confirmed = window.confirm(this.$t('chats.delete_confirm')) + if (confirmed) { + await this.$store.dispatch('deleteChatMessage', { + messageId: this.chatViewItem.data.id, + chatId: this.chatViewItem.data.chat_id + }) + } + this.hovered = false + this.menuOpened = false + } + } +} + +export default ChatMessage diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss new file mode 100644 index 00000000..240beea4 --- /dev/null +++ b/src/components/chat_message/chat_message.scss @@ -0,0 +1,164 @@ +@import '../../_variables.scss'; + +.chat-message-wrapper { + &.hovered-message-chain { + .animated.avatar { + canvas { + display: none; + } + img { + visibility: visible; + } + } + } + + .chat-message-menu { + transition: opacity 0.1s; + opacity: 0; + position: absolute; + top: -0.8em; + + button { + padding-top: 0.2em; + padding-bottom: 0.2em; + } + } + + .icon-ellipsis { + cursor: pointer; + + &: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 { + border-radius: $fallback--chatMessageRadius; + border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius); + display: flex; + padding: 0.75em; + } + + .created-at { + position: relative; + float: right; + font-size: 0.8em; + margin: -1em 0 -0.5em 0; + font-style: italic; + opacity: 0.8; + } + + .without-attachment { + .status-content { + &::after { + margin-right: 5.4em; + content: " "; + display: inline-block; + } + } + } + + .incoming { + a { + color: var(--chatMessageIncomingLink, $fallback--link); + } + + .status { + color: var(--chatMessageIncomingText, $fallback--text); + background-color: var(--chatMessageIncomingBg, $fallback--bg); + border: 1px solid var(--chatMessageIncomingBorder, --border); + } + + .created-at { + a { + color: var(--chatMessageIncomingText, $fallback--text); + } + } + + .chat-message-menu { + left: 0.4rem; + } + } + + .outgoing { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-content: end; + justify-content: flex-end; + + a { + color: var(--chatMessageOutgoingLink, $fallback--link); + } + + .status { + color: var(--chatMessageOutgoingText, $fallback--text); + background-color: var(--chatMessageOutgoingBg, $fallback--lightBg); + border: 1px solid var(--chatMessageOutgoingBorder, --lightBg); + } + + .chat-message-inner { + align-items: flex-end; + } + + .chat-message-menu { + right: 0.4rem; + } + } + + .visible { + opacity: 1; + } +} + +.chat-message-date-separator { + text-align: center; + margin: 1.4em 0; + font-size: 0.9em; + user-select: none; + color: $fallback--text; + color: var(--faintedText, $fallback--text); +} diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue new file mode 100644 index 00000000..e923d694 --- /dev/null +++ b/src/components/chat_message/chat_message.vue @@ -0,0 +1,99 @@ +<template> + <div + v-if="isMessage" + class="chat-message-wrapper" + :class="{ 'hovered-message-chain': hoveredMessageChain }" + @mouseover="onHover(true)" + @mouseleave="onHover(false)" + > + <div + class="chat-message" + :class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]" + > + <div + v-if="!isCurrentUser" + class="avatar-wrapper" + > + <router-link + v-if="chatViewItem.isHead" + :to="userProfileLink" + > + <UserAvatar + :compact="true" + :better-shadow="betterShadow" + :user="author" + /> + </router-link> + </div> + <div class="chat-message-inner"> + <div + class="status-body" + :style="{ 'min-width': message.attachment ? '80%' : '' }" + > + <div + class="media status" + :class="{ 'without-attachment': !hasAttachment }" + style="position: relative" + @mouseenter="hovered = true" + @mouseleave="hovered = false" + > + <div + class="chat-message-menu" + :class="{ 'visible': hovered || menuOpened }" + > + <Popover + trigger="click" + placement="top" + :bound-to-selector="isCurrentUser ? '' : '.scrollable-message-list'" + :bound-to="{ x: 'container' }" + :margin="popoverMarginStyle" + @show="menuOpened = true" + @close="menuOpened = false" + > + <div slot="content"> + <div class="dropdown-menu"> + <button + class="dropdown-item dropdown-item-icon" + @click="deleteMessage" + > + <i class="icon-cancel" /> {{ $t("chats.delete") }} + </button> + </div> + </div> + <button + slot="trigger" + :title="$t('chats.more')" + > + <i class="icon-ellipsis" /> + </button> + </Popover> + </div> + <StatusContent + :status="messageForStatusContent" + :full-content="true" + > + <span + slot="footer" + class="created-at" + > + {{ createdAt }} + </span> + </StatusContent> + </div> + </div> + </div> + </div> + </div> + <div + v-else + class="chat-message-date-separator" + > + <ChatMessageDate :date="chatViewItem.date" /> + </div> +</template> + +<script src="./chat_message.js" ></script> +<style lang="scss"> +@import './chat_message.scss'; + +</style> diff --git a/src/components/chat_message_date/chat_message_date.vue b/src/components/chat_message_date/chat_message_date.vue new file mode 100644 index 00000000..79c346b6 --- /dev/null +++ b/src/components/chat_message_date/chat_message_date.vue @@ -0,0 +1,24 @@ +<template> + <time> + {{ displayDate }} + </time> +</template> + +<script> +export default { + name: 'Timeago', + props: ['date'], + computed: { + displayDate () { + const today = new Date() + today.setHours(0, 0, 0, 0) + + if (this.date.getTime() === today.getTime()) { + return this.$t('display_date.today') + } else { + return this.date.toLocaleDateString('en', { day: 'numeric', month: 'long' }) + } + } + } +} +</script> diff --git a/src/components/chat_new/chat_new.js b/src/components/chat_new/chat_new.js new file mode 100644 index 00000000..d023efc0 --- /dev/null +++ b/src/components/chat_new/chat_new.js @@ -0,0 +1,73 @@ +import { mapState, mapGetters } from 'vuex' +import BasicUserCard from '../basic_user_card/basic_user_card.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' + +const chatNew = { + components: { + BasicUserCard, + UserAvatar + }, + data () { + return { + suggestions: [], + userIds: [], + loading: false, + query: '' + } + }, + async created () { + const { chats } = await this.backendInteractor.chats() + chats.forEach(chat => this.suggestions.push(chat.account)) + }, + computed: { + users () { + return this.userIds.map(userId => this.findUser(userId)) + }, + availableUsers () { + if (this.query.length !== 0) { + return this.users + } else { + return this.suggestions + } + }, + ...mapState({ + currentUser: state => state.users.currentUser, + backendInteractor: state => state.api.backendInteractor + }), + ...mapGetters(['findUser']) + }, + methods: { + goBack () { + this.$emit('cancel') + }, + goToChat (user) { + this.$router.push({ name: 'chat', params: { recipient_id: user.id } }) + }, + onInput () { + this.search(this.query) + }, + addUser (user) { + this.selectedUserIds.push(user.id) + this.query = '' + }, + removeUser (userId) { + this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId) + }, + search (query) { + if (!query) { + this.loading = false + return + } + + this.loading = true + this.userIds = [] + this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts' }) + .then(data => { + this.loading = false + this.userIds = data.accounts.map(a => a.id) + }) + } + } +} + +export default chatNew diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss new file mode 100644 index 00000000..11305444 --- /dev/null +++ b/src/components/chat_new/chat_new.scss @@ -0,0 +1,29 @@ +.chat-new { + .input-wrap { + display: flex; + margin: 0.7em 0.5em 0.7em 0.5em; + + input { + 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; + } +} diff --git a/src/components/chat_new/chat_new.vue b/src/components/chat_new/chat_new.vue new file mode 100644 index 00000000..3333dbf9 --- /dev/null +++ b/src/components/chat_new/chat_new.vue @@ -0,0 +1,46 @@ +<template> + <div + id="nav" + class="panel-default panel chat-new" + > + <div + ref="header" + class="panel-heading" + > + <a + class="go-back-button" + @click="goBack" + > + <i class="button-icon icon-left-open" /> + </a> + </div> + <div class="input-wrap"> + <div class="input-search"> + <i class="button-icon icon-search" /> + </div> + <input + ref="search" + v-model="query" + placeholder="Search people" + @input="onInput" + > + </div> + <div class="member-list"> + <div + v-for="user in availableUsers" + :key="user.id" + class="member" + > + <div @click.capture.prevent="goToChat(user)"> + <BasicUserCard :user="user" /> + </div> + </div> + </div> + </div> +</template> + +<script src="./chat_new.js"></script> +<style lang="scss"> +@import '../../_variables.scss'; +@import './chat_new.scss'; +</style> diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/chat_panel/chat_panel.vue index 3677722f..12968cfb 100644 --- a/src/components/chat_panel/chat_panel.vue +++ b/src/components/chat_panel/chat_panel.vue @@ -84,54 +84,56 @@ max-width: 25em; } -.chat-heading { - cursor: pointer; - .icon-comment-empty { - color: $fallback--text; - color: var(--text, $fallback--text); +.chat-panel { + .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-window { + overflow-y: auto; + overflow-x: hidden; + max-height: 20em; + } -.chat-message { - display: flex; - padding: 0.2em 0.5em -} + .chat-window-container { + height: 100%; + } -.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-message { + display: flex; + padding: 0.2em 0.5em } -} -.chat-input { - display: flex; - textarea { - flex: 1; - margin: 0.6em; - min-height: 3.5em; - resize: none; + .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-panel { - .title { + .chat-input { display: flex; - justify-content: space-between; + textarea { + flex: 1; + margin: 0.6em; + min-height: 3.5em; + resize: none; + } + } + + .chat-panel { + .title { + display: flex; + justify-content: space-between; + } } } </style> diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js new file mode 100644 index 00000000..e424bb1f --- /dev/null +++ b/src/components/chat_title/chat_title.js @@ -0,0 +1,26 @@ +import Vue from 'vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' +import UserAvatar from '../user_avatar/user_avatar.vue' + +export default Vue.component('chat-title', { + name: 'ChatTitle', + components: { + UserAvatar + }, + props: [ + 'user', 'withAvatar' + ], + computed: { + title () { + return this.user ? this.user.screen_name : '' + }, + htmlTitle () { + return this.user ? this.user.name_html : '' + } + }, + methods: { + getUserProfileLink (user) { + return generateProfileLink(user.id, user.screen_name) + } + } +}) diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue new file mode 100644 index 00000000..c375b10b --- /dev/null +++ b/src/components/chat_title/chat_title.vue @@ -0,0 +1,67 @@ +<template> + <!-- eslint-disable vue/no-v-html --> + <div + class="chat-title" + :title="title" + > + <router-link + v-if="withAvatar && user" + :to="getUserProfileLink(user)" + > + <UserAvatar + :user="user" + width="23px" + height="23px" + /> + </router-link> + <span + class="username" + v-html="htmlTitle" + /> + </div> + <!-- eslint-enable vue/no-v-html --> +</template> + +<script src="./chat_title.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.chat-title { + display: flex; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + align-items: center; + + .username { + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; + display: inline; + word-wrap: break-word; + overflow: hidden; + text-overflow: ellipsis; + + .emoji { + width: 14px; + height: 14px; + vertical-align: middle; + 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> diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue index 03375b2f..d28c2cfd 100644 --- a/src/components/checkbox/checkbox.vue +++ b/src/components/checkbox/checkbox.vue @@ -52,7 +52,7 @@ export default { right: 0; top: 0; display: block; - content: '✔'; + content: '✓'; transition: color 200ms; width: 1.1em; height: 1.1em; diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index f4c3479c..f0123447 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -79,6 +79,20 @@ const EmojiInput = { required: false, type: Boolean, 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 () { @@ -162,6 +176,11 @@ const EmojiInput = { input.elm.removeEventListener('input', this.onInput) } }, + watch: { + showSuggestions: function (newValue) { + this.$emit('shown', newValue) + } + }, methods: { triggerShowPicker () { this.showPicker = true @@ -190,7 +209,7 @@ const EmojiInput = { this.$emit('input', newValue) this.caret = 0 }, - insert ({ insertion, keepOpen }) { + insert ({ insertion, keepOpen, surroundingSpace = true }) { const before = this.value.substring(0, this.caret) || '' const after = this.value.substring(this.caret) || '' @@ -209,8 +228,8 @@ const EmojiInput = { * them, masto seem to be rendering :emoji::emoji: correctly now so why not */ const isSpaceRegex = /\s/ - const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0 ? ' ' : '' - const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : '' + const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : '' + const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : '' const newValue = [ before, @@ -367,6 +386,18 @@ const EmojiInput = { }, onKeyDown (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 if (!this.temporarilyHideSuggestions) { if (key === 'Tab') { @@ -425,14 +456,29 @@ const EmojiInput = { this.caret = selectionStart }, resize () { - const { panel, picker } = this.$refs + const panel = this.$refs.panel if (!panel) return + const picker = this.$refs.picker.$el + const panelBody = this.$refs['panel-body'] const { offsetHeight, offsetTop } = this.input.elm const offsetBottom = offsetTop + offsetHeight - panel.style.top = offsetBottom + 'px' - picker.$el.style.top = offsetBottom + 'px' - picker.$el.style.bottom = 'auto' + this.setPlacement(panelBody, panel, offsetBottom) + this.setPlacement(picker, picker, offsetBottom) + }, + 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 } } } diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue index e9ac09c3..b9a74572 100644 --- a/src/components/emoji_input/emoji_input.vue +++ b/src/components/emoji_input/emoji_input.vue @@ -29,7 +29,10 @@ class="autocomplete-panel" :class="{ hide: !showSuggestions }" > - <div class="autocomplete-panel-body"> + <div + ref="panel-body" + class="autocomplete-panel-body" + > <div v-for="(suggestion, index) in suggestions" :key="index" diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js index e4b19d01..5e0c36bb 100644 --- a/src/components/extra_buttons/extra_buttons.js +++ b/src/components/extra_buttons/extra_buttons.js @@ -34,6 +34,16 @@ const ExtraButtons = { navigator.clipboard.writeText(this.statusLink) .then(() => this.$emit('onSuccess')) .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: { diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue index 68db6fd8..7a4e8642 100644 --- a/src/components/extra_buttons/extra_buttons.vue +++ b/src/components/extra_buttons/extra_buttons.vue @@ -41,6 +41,22 @@ <i class="icon-pin" /><span>{{ $t("status.unpin") }}</span> </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 v-if="canDelete" class="dropdown-item dropdown-item-icon" @click.prevent="deleteStatus" diff --git a/src/components/features_panel/features_panel.js b/src/components/features_panel/features_panel.js index 5f80a079..620a85ea 100644 --- a/src/components/features_panel/features_panel.js +++ b/src/components/features_panel/features_panel.js @@ -1,6 +1,7 @@ const FeaturesPanel = { computed: { chat: function () { return this.$store.state.instance.chatAvailable }, + pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable }, gopher: function () { return this.$store.state.instance.gopherAvailable }, whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled }, mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable }, diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue index 3e5939a6..608b11c8 100644 --- a/src/components/features_panel/features_panel.vue +++ b/src/components/features_panel/features_panel.vue @@ -11,6 +11,9 @@ <li v-if="chat"> {{ $t('features_panel.chat') }} </li> + <li v-if="pleromaChatMessages"> + {{ $t('features_panel.pleroma_chat_messages') }} + </li> <li v-if="gopher"> {{ $t('features_panel.gopher') }} </li> diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue index 1ffa7b3c..ca91c9c1 100644 --- a/src/components/gallery/gallery.vue +++ b/src/components/gallery/gallery.vue @@ -50,9 +50,7 @@ align-content: stretch; } - // FIXME: specificity problem with this and .attachments.attachment - // we shouldn't have the need for .image here - .attachment.image { + .gallery-row-inner .attachment { margin: 0 0.5em 0 0; flex-grow: 1; height: 100%; diff --git a/src/components/global_notice_list/global_notice_list.js b/src/components/global_notice_list/global_notice_list.js new file mode 100644 index 00000000..3af29c23 --- /dev/null +++ b/src/components/global_notice_list/global_notice_list.js @@ -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 diff --git a/src/components/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue new file mode 100644 index 00000000..0e4285cc --- /dev/null +++ b/src/components/global_notice_list/global_notice_list.vue @@ -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> diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue index 80d2a8b9..46931667 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -8,6 +8,8 @@ v-if="type === 'image'" class="modal-image" :src="currentMedia.url" + :alt="currentMedia.description" + :title="currentMedia.description" @touchstart.stop="mediaTouchStart" @touchmove.stop="mediaTouchMove" @click="hide" @@ -18,6 +20,14 @@ :attachment="currentMedia" :controls="true" /> + <audio + v-if="type === 'audio'" + class="modal-image" + :src="currentMedia.url" + :alt="currentMedia.description" + :title="currentMedia.description" + controls + /> <button v-if="canNavigate" :title="$t('media_modal.previous')" diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js index fbb2d03d..7b8a76cc 100644 --- a/src/components/media_upload/media_upload.js +++ b/src/components/media_upload/media_upload.js @@ -61,7 +61,8 @@ const mediaUpload = { } }, props: [ - 'dropFiles' + 'dropFiles', + 'disabled' ], watch: { 'dropFiles': function (fileInfos) { diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index 5e31730b..c8865d77 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -1,5 +1,8 @@ <template> - <div class="media-upload"> + <div + class="media-upload" + :class="{ disabled: disabled }" + > <label class="label" :title="$t('tool_tip.media_upload')" @@ -14,6 +17,7 @@ /> <input v-if="uploadReady" + :disabled="disabled" type="file" style="position: fixed; top: -100em" multiple="true" @@ -26,6 +30,8 @@ <script src="./media_upload.js" ></script> <style lang="scss"> +@import '../../_variables.scss'; + .media-upload { .label { display: inline-block; diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js index c1166a0c..4613d090 100644 --- a/src/components/mobile_nav/mobile_nav.js +++ b/src/components/mobile_nav/mobile_nav.js @@ -2,6 +2,7 @@ import SideDrawer from '../side_drawer/side_drawer.vue' import Notifications from '../notifications/notifications.vue' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' import GestureService from '../../services/gesture_service/gesture_service' +import { mapGetters } from 'vuex' const MobileNav = { components: { @@ -30,7 +31,11 @@ const MobileNav = { return this.unseenNotifications.length }, hideSitename () { return this.$store.state.instance.hideSitename }, - sitename () { return this.$store.state.instance.name } + sitename () { return this.$store.state.instance.name }, + isChat () { + return this.$route.name === 'chat' + }, + ...mapGetters(['unreadChatCount']) }, methods: { toggleMobileSidebar () { diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue index 51f1d636..abd95f09 100644 --- a/src/components/mobile_nav/mobile_nav.vue +++ b/src/components/mobile_nav/mobile_nav.vue @@ -3,6 +3,7 @@ <nav id="nav" class="nav-bar container" + :class="{ 'mobile-hidden': isChat }" > <div class="mobile-inner-nav" @@ -15,6 +16,10 @@ @click.stop.prevent="toggleMobileSidebar()" > <i class="button-icon icon-menu" /> + <div + v-if="unreadChatCount" + class="alert-dot" + /> </a> <router-link v-if="!hideSitename" diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.js b/src/components/mobile_post_status_button/mobile_post_status_button.js index 0ad12bb1..6348277b 100644 --- a/src/components/mobile_post_status_button/mobile_post_status_button.js +++ b/src/components/mobile_post_status_button/mobile_post_status_button.js @@ -1,5 +1,10 @@ import { debounce } from 'lodash' +const HIDDEN_FOR_PAGES = new Set([ + 'chats', + 'chat' +]) + const MobilePostStatusButton = { data () { return { @@ -27,6 +32,8 @@ const MobilePostStatusButton = { return !!this.$store.state.users.currentUser }, isHidden () { + if (HIDDEN_FOR_PAGES.has(this.$route.name)) { return true } + return this.autohideFloatingPostButton && (this.hidden || this.inputActive) }, autohideFloatingPostButton () { diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index 8f7edb7f..b142ffe0 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -1,4 +1,4 @@ -import { mapState } from 'vuex' +import { mapState, mapGetters } from 'vuex' const NavPanel = { created () { @@ -6,13 +6,17 @@ const NavPanel = { this.$store.dispatch('startFetchingFollowRequests') } }, - computed: mapState({ - currentUser: state => state.users.currentUser, - chat: state => state.chat.channel, - followRequestCount: state => state.api.followRequests.length, - privateMode: state => state.instance.private, - federating: state => state.instance.federating - }) + computed: { + ...mapState({ + currentUser: state => state.users.currentUser, + chat: state => state.chat.channel, + followRequestCount: state => state.api.followRequests.length, + privateMode: state => state.instance.private, + federating: state => state.instance.federating, + pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable + }), + ...mapGetters(['unreadChatCount']) + } } export default NavPanel diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index 8cd04dc7..8a213d7e 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -17,6 +17,22 @@ <i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }} </router-link> </li> + <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 } }"> + <div + v-if="unreadChatCount" + class="badge badge-notification unread-chat-count" + > + {{ unreadChatCount }} + </div> + <i class="button-icon icon-chat" /> {{ $t("nav.chats") }} + </router-link> + </li> <li v-if="currentUser && currentUser.locked"> <router-link :to="{ name: 'friend-requests' }"> <i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }} diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index 5aa40e98..bb906b50 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -1,4 +1,5 @@ import StatusContent from '../status_content/status_content.vue' +import { mapState } from 'vuex' import Status from '../status/status.vue' import UserAvatar from '../user_avatar/user_avatar.vue' import UserCard from '../user_card/user_card.vue' @@ -81,7 +82,10 @@ const Notification = { }, isStatusNotification () { return isStatusNotification(this.notification.type) - } + }, + ...mapState({ + currentUser: state => state.users.currentUser + }) } } diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index 26ffbab6..d951e2a8 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -1,3 +1,4 @@ +import { mapGetters } from 'vuex' import Notification from '../notification/notification.vue' import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js' import { @@ -27,6 +28,11 @@ const Notifications = { seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT } }, + created () { + const store = this.$store + const credentials = store.state.users.currentUser.credentials + notificationsFetcher.fetchAndUpdate({ store, credentials }) + }, computed: { mainClass () { return this.minimalMode ? '' : 'panel panel-default' @@ -46,23 +52,22 @@ const Notifications = { unseenCount () { return this.unseenNotifications.length }, + unseenCountTitle () { + return this.unseenCount + (this.unreadChatCount) + }, loading () { return this.$store.state.statuses.notifications.loading }, notificationsToDisplay () { return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount) - } + }, + ...mapGetters(['unreadChatCount']) }, components: { Notification }, - created () { - const { dispatch } = this.$store - - dispatch('fetchAndUpdateNotifications') - }, watch: { - unseenCount (count) { + unseenCountTitle (count) { if (count > 0) { this.$store.dispatch('setPageTitle', `(${count})`) } else { diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index 20797cf9..b1a3ad70 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -118,6 +118,11 @@ flex: 1; padding-left: 0.8em; min-width: 0; + + .timeago { + min-width: 3em; + text-align: right; + } } .emoji-reaction-emoji { diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 9027566f..1c0accac 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -3,11 +3,13 @@ import MediaUpload from '../media_upload/media_upload.vue' import ScopeSelector from '../scope_selector/scope_selector.vue' import EmojiInput from '../emoji_input/emoji_input.vue' import PollForm from '../poll/poll_form.vue' +import Attachment from '../attachment/attachment.vue' +import StatusContent from '../status_content/status_content.vue' import fileTypeService from '../../services/file_type/file_type.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js' -import { reject, map, uniqBy } from 'lodash' +import { reject, map, uniqBy, debounce } from 'lodash' import suggestor from '../emoji_input/suggestor.js' -import { mapGetters } from 'vuex' +import { mapGetters, mapState } from 'vuex' import Checkbox from '../checkbox/checkbox.vue' const buildMentionsString = ({ user, attentions = [] }, currentUser) => { @@ -31,21 +33,42 @@ const PostStatusForm = { 'repliedUser', 'attentions', 'copyMessageScope', - 'subject' + 'subject', + 'disableSubject', + 'disableScopeSelector', + 'disableNotice', + 'disableLockWarning', + 'disablePolls', + 'disableSensitivityCheckbox', + 'disableSubmit', + 'disablePreview', + 'placeholder', + 'maxHeight', + 'postHandler', + 'preserveFocus', + 'autoFocus', + 'fileLimit', + 'submitOnEnter', + 'emojiPickerPlacement' ], components: { MediaUpload, EmojiInput, PollForm, ScopeSelector, - Checkbox + Checkbox, + Attachment, + StatusContent }, mounted () { 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) { this.$refs.textarea.focus() } }, @@ -68,7 +91,7 @@ const PostStatusForm = { return { dropFiles: [], - submitDisabled: false, + uploadingFiles: false, error: null, posting: false, highlighted: 0, @@ -78,13 +101,17 @@ const PostStatusForm = { nsfw: false, files: [], poll: {}, + mediaDescriptions: {}, visibility: scope, contentType }, caret: 0, pollFormVisible: false, showDropIcon: 'hide', - dropStopTimeout: null + dropStopTimeout: null, + preview: null, + previewLoading: false, + emojiInputShown: false } }, computed: { @@ -153,28 +180,52 @@ const PostStatusForm = { }, pollsAvailable () { return this.$store.state.instance.pollsAvailable && - this.$store.state.instance.pollLimits.max_options >= 2 + this.$store.state.instance.pollLimits.max_options >= 2 && + this.disablePolls !== true }, hideScopeNotice () { - return this.$store.getters.mergedConfig.hideScopeNotice + return this.disableNotice || this.$store.getters.mergedConfig.hideScopeNotice }, pollContentError () { return this.pollFormVisible && this.newStatus.poll && 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: { - postStatus (newStatus) { + async postStatus (event, newStatus, opts = {}) { if (this.posting) { return } - if (this.submitDisabled) { return } + if (this.disableSubmit) { return } + if (this.emojiInputShown) { return } + if (this.submitOnEnter) { + event.stopPropagation() + event.preventDefault() + } - if (this.newStatus.status === '') { - if (this.newStatus.files.length === 0) { - this.error = 'Cannot post an empty status with no files' - return - } + if (this.emptyStatus) { + this.error = this.$t('post_status.empty_status_error') + return } const poll = this.pollFormVisible ? this.newStatus.poll : {} @@ -184,7 +235,16 @@ const PostStatusForm = { } this.posting = true - statusPoster.postStatus({ + + try { + await this.setAllMediaDescriptions() + } catch (e) { + this.error = this.$t('post_status.media_description_error') + this.posting = false + return + } + + const postingOptions = { status: newStatus.status, spoilerText: newStatus.spoilerText || null, visibility: newStatus.visibility, @@ -194,7 +254,11 @@ const PostStatusForm = { inReplyToStatusId: this.replyTo, contentType: newStatus.contentType, poll - }).then((data) => { + } + + const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus + + postHandler(postingOptions).then((data) => { if (!data.error) { this.newStatus = { status: '', @@ -202,43 +266,105 @@ const PostStatusForm = { files: [], visibility: newStatus.visibility, contentType: newStatus.contentType, - poll: {} + poll: {}, + mediaDescriptions: {} } this.pollFormVisible = false - this.$refs.mediaUpload.clearFile() + this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile() this.clearPollForm() - this.$emit('posted') + this.$emit('posted', data) + if (this.preserveFocus) { + this.$nextTick(() => { + this.$refs.textarea.focus() + }) + } let el = this.$el.querySelector('textarea') el.style.height = 'auto' el.style.height = undefined this.error = null + if (this.preview) this.previewStatus() } else { this.error = data.error } 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) { this.newStatus.files.push(fileInfo) + this.$emit('resize', { delayed: true }) }, removeMediaFile (fileInfo) { let index = this.newStatus.files.indexOf(fileInfo) this.newStatus.files.splice(index, 1) + this.$emit('resize') }, uploadFailed (errString, templateArgs) { templateArgs = templateArgs || {} this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs) }, - disableSubmit () { - this.submitDisabled = true + startedUploadingFiles () { + this.uploadingFiles = true }, - enableSubmit () { - this.submitDisabled = false + finishedUploadingFiles () { + this.$emit('resize') + this.uploadingFiles = false }, type (fileInfo) { return fileTypeService.fileType(fileInfo.mimetype) }, paste (e) { + this.autoPreview() this.resize(e) if (e.clipboardData.files.length > 0) { // prevent pasting of file as text @@ -266,13 +392,14 @@ const PostStatusForm = { this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500) }, 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) { + this.autoPreview() this.$nextTick(() => { this.resize(this.$refs['textarea']) }) @@ -284,6 +411,7 @@ const PostStatusForm = { // Reset to default height for empty form, nothing else to do here. if (target.value === '') { target.style.height = null + this.$emit('resize') this.$refs['emoji-input'].resize() return } @@ -336,8 +464,10 @@ const PostStatusForm = { // BEGIN content size update target.style.height = 'auto' - const newHeight = target.scrollHeight - vertPadding + const heightWithoutPadding = target.scrollHeight - vertPadding + const newHeight = this.maxHeight ? Math.min(heightWithoutPadding, this.maxHeight) : heightWithoutPadding target.style.height = `${newHeight}px` + this.$emit('resize', newHeight) // END content size update // We check where the bottom border of form-bottom element is, this uses findOffset @@ -388,6 +518,18 @@ const PostStatusForm = { }, dismissScopeNotice () { 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) { + this.emojiInputShown = value } } } diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index e3d8d087..3dcf1f79 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -5,19 +5,20 @@ > <form 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 icon-upload" + class="drop-indicator" + :class="[uploadFileLimitReached ? 'icon-block' : 'icon-upload']" @dragleave="fileDragStop" @drop.stop="fileDrop" /> <div class="form-group"> <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" tag="p" class="visibility-notice" @@ -69,15 +70,55 @@ <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span> <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> </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 - v-if="newStatus.spoilerText || alwaysShowSubject" + v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)" v-model="newStatus.spoilerText" enable-emoji-picker :suggest="emojiSuggestor" class="form-control" > <input - v-model="newStatus.spoilerText" type="text" :placeholder="$t('post_status.content_warning')" @@ -89,23 +130,28 @@ ref="emoji-input" v-model="newStatus.status" :suggest="emojiUserSuggestor" + :placement="emojiPickerPlacement" class="form-control main-input" enable-emoji-picker hide-emoji-button + :newline-on-ctrl-enter="submitOnEnter" enable-sticker-picker @input="onEmojiInputInput" @sticker-uploaded="addMediaFile" @sticker-upload-failed="uploadFailed" + @shown="handleEmojiInputShow" > <textarea ref="textarea" v-model="newStatus.status" - :placeholder="$t('post_status.default')" + :placeholder="placeholder || $t('post_status.default')" rows="1" :disabled="posting" class="form-post-body" - @keydown.meta.enter="postStatus(newStatus)" - @keydown.ctrl.enter="postStatus(newStatus)" + :class="{ 'scrollable-form': !!maxHeight }" + @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)" + @keydown.meta.enter="postStatus($event, newStatus)" + @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)" @input="resize" @compositionupdate="resize" @paste="paste" @@ -118,7 +164,10 @@ {{ charactersLeft }} </p> </EmojiInput> - <div class="visibility-tray"> + <div + v-if="!disableScopeSelector" + class="visibility-tray" + > <scope-selector :show-all="showAllScopes" :user-default="userDefaultScope" @@ -176,10 +225,11 @@ ref="mediaUpload" class="media-upload-icon" :drop-files="dropFiles" - @uploading="disableSubmit" + :disabled="uploadFileLimitReached" + @uploading="startedUploadingFiles" @uploaded="addMediaFile" @upload-failed="uploadFailed" - @all-uploaded="enableSubmit" + @all-uploaded="finishedUploadingFiles" /> <div class="emoji-icon" @@ -216,11 +266,13 @@ > {{ $t('general.submit') }} </button> + <!-- touchstart is used to keep the OSK at the same position after a message send --> <button v-else - :disabled="submitDisabled" - type="submit" + :disabled="uploadingFiles || disableSubmit" class="btn btn-default" + @touchstart.stop.prevent="postStatus($event, newStatus)" + @click.stop.prevent="postStatus($event, newStatus)" > {{ $t('general.submit') }} </button> @@ -245,31 +297,22 @@ class="fa button-icon icon-cancel" @click="removeMediaFile(file)" /> - <div class="media-upload-container attachment"> - <img - v-if="type(file) === 'image'" - class="thumbnail media-upload" - :src="file.url" - > - <video - v-if="type(file) === 'video'" - :src="file.url" - controls - /> - <audio - v-if="type(file) === 'audio'" - :src="file.url" - controls - /> - <a - v-if="type(file) === 'unknown'" - :href="file.url" - >{{ file.url }}</a> - </div> + <attachment + :attachment="file" + :set-media="() => $store.dispatch('setMedia', newStatus.files)" + size="small" + allow-play="false" + /> + <input + v-model="newStatus.mediaDescriptions[file.id]" + type="text" + :placeholder="$t('post_status.media_description')" + @keydown.enter.prevent="" + > </div> </div> <div - v-if="newStatus.files.length > 0" + v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox" class="upload_settings" > <Checkbox v-model="newStatus.nsfw"> @@ -303,14 +346,8 @@ } .post-status-form { - .visibility-tray { - display: flex; - justify-content: space-between; - padding-top: 5px; - } -} + position: relative; -.post-status-form { .form-bottom { display: flex; justify-content: space-between; @@ -336,6 +373,48 @@ 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 { .only-format { color: $fallback--faint; @@ -343,6 +422,12 @@ } } + .visibility-tray { + display: flex; + justify-content: space-between; + padding-top: 5px; + } + .media-upload-icon, .poll-icon, .emoji-icon { font-size: 26px; flex: 1; @@ -354,6 +439,19 @@ 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 @@ -381,11 +479,9 @@ } .media-upload-wrapper { - flex: 0 0 auto; - max-width: 100%; - min-width: 50px; margin-right: .2em; margin-bottom: .5em; + width: 18em; .icon-cancel { display: inline-block; @@ -399,6 +495,20 @@ border-bottom-left-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 { @@ -408,28 +518,13 @@ flex-direction: column; } - .attachments { + .media-upload-wrapper .attachments { padding: 0 0.5em; .attachment { margin: 0; + padding: 0; 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 { @@ -482,6 +577,10 @@ padding-bottom: 1.75em; min-height: 1px; box-sizing: content-box; + + &.scrollable-form { + overflow-y: auto; + } } .main-input { @@ -544,4 +643,11 @@ border: 2px dashed var(--text, $fallback--text); } } + +// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before) +img.media-upload, .media-upload-container > video { + line-height: 0; + max-height: 200px; + max-width: 100%; +} </style> diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss index 833ff89a..0da4d9a8 100644 --- a/src/components/settings_modal/settings_modal.scss +++ b/src/components/settings_modal/settings_modal.scss @@ -30,7 +30,7 @@ height: 100vh; } - .panel-body { + >.panel-body { height: 100%; overflow-y: hidden; diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js index 224a7f47..3b2df556 100644 --- a/src/components/settings_modal/tabs/filtering_tab.js +++ b/src/components/settings_modal/tabs/filtering_tab.js @@ -37,6 +37,9 @@ const FilteringTab = { }) }, deep: true + }, + replyVisibility () { + this.$store.dispatch('queueFlushAll') } } } diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js index 56e956cd..bd6bef6a 100644 --- a/src/components/settings_modal/tabs/profile_tab.js +++ b/src/components/settings_modal/tabs/profile_tab.js @@ -1,4 +1,5 @@ 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' @@ -16,6 +17,7 @@ const ProfileTab = { 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, @@ -63,6 +65,45 @@ const ProfileTab = { ...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: { @@ -75,6 +116,7 @@ const ProfileTab = { // 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, @@ -87,6 +129,8 @@ const ProfileTab = { 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) }) @@ -94,6 +138,16 @@ const ProfileTab = { 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 } @@ -123,11 +177,29 @@ const ProfileTab = { } 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.updateAvatar({ avatar }) + that.$store.state.api.backendInteractor.updateProfileImages({ avatar }) .then((user) => { that.$store.commit('addNewUsers', [user]) that.$store.commit('setCurrentUser', user) @@ -145,11 +217,11 @@ const ProfileTab = { } }) }, - submitBanner () { - if (!this.bannerPreview) { return } + submitBanner (banner) { + if (!this.bannerPreview && banner !== '') { return } this.bannerUploading = true - this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner }) + this.$store.state.api.backendInteractor.updateProfileImages({ banner }) .then((user) => { this.$store.commit('addNewUsers', [user]) this.$store.commit('setCurrentUser', user) @@ -160,11 +232,11 @@ const ProfileTab = { }) .then(() => { this.bannerUploading = false }) }, - submitBg () { - if (!this.backgroundPreview) { return } - let background = this.background + submitBackground (background) { + if (!this.backgroundPreview && background !== '') { return } + this.backgroundUploading = true - this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => { + this.$store.state.api.backendInteractor.updateProfileImages({ background }).then((data) => { if (!data.error) { this.$store.commit('addNewUsers', [data]) this.$store.commit('setCurrentUser', data) diff --git a/src/components/settings_modal/tabs/profile_tab.scss b/src/components/settings_modal/tabs/profile_tab.scss index 4aab81eb..e14cf054 100644 --- a/src/components/settings_modal/tabs/profile_tab.scss +++ b/src/components/settings_modal/tabs/profile_tab.scss @@ -13,8 +13,14 @@ height: auto; } - .banner { + .banner-background-preview { max-width: 100%; + width: 300px; + position: relative; + + img { + width: 100%; + } } .uploading { @@ -26,18 +32,40 @@ width: 100%; } - .bg { - max-width: 100%; + .current-avatar-container { + position: relative; + width: 150px; + height: 150px; } .current-avatar { display: block; - width: 150px; - height: 150px; + 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%; @@ -79,4 +107,22 @@ .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; + } + } + } } diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue index decdb389..cf88c4e4 100644 --- a/src/components/settings_modal/tabs/profile_tab.vue +++ b/src/components/settings_modal/tabs/profile_tab.vue @@ -95,6 +95,54 @@ {{ $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') }} @@ -113,11 +161,19 @@ <p class="visibility-notice"> {{ $t('settings.avatar_size_instruction') }} </p> - <p>{{ $t('settings.current_avatar') }}</p> - <img - :src="user.profile_image_url_original" - class="current-avatar" - > + <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" @@ -136,15 +192,20 @@ </div> <div class="setting-item"> <h2>{{ $t('settings.profile_banner') }}</h2> - <p>{{ $t('settings.current_profile_banner') }}</p> - <img - :src="user.cover_photo" - class="banner" - > + <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" + class="banner-background-preview" :src="bannerPreview" > <div> @@ -160,7 +221,7 @@ <button v-else-if="bannerPreview" class="btn btn-default" - @click="submitBanner" + @click="submitBanner(banner)" > {{ $t('general.submit') }} </button> @@ -177,10 +238,20 @@ </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="bg" + class="banner-background-preview" :src="backgroundPreview" > <div> @@ -196,7 +267,7 @@ <button v-else-if="backgroundPreview" class="btn btn-default" - @click="submitBg" + @click="submitBackground(background)" > {{ $t('general.submit') }} </button> diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js index 9d61b0c4..e3c5e80a 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js @@ -99,7 +99,8 @@ export default { avatarRadiusLocal: '', avatarAltRadiusLocal: '', attachmentRadiusLocal: '', - tooltipRadiusLocal: '' + tooltipRadiusLocal: '', + chatMessageRadiusLocal: '' } }, created () { @@ -214,7 +215,8 @@ export default { avatar: this.avatarRadiusLocal, avatarAlt: this.avatarAltRadiusLocal, tooltip: this.tooltipRadiusLocal, - attachment: this.attachmentRadiusLocal + attachment: this.attachmentRadiusLocal, + chatMessage: this.chatMessageRadiusLocal } }, preview () { diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue index d14f854c..d57894de 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue @@ -735,6 +735,65 @@ /> <ContrastRatio :contrast="previewContrast.selectedMenuLink" /> </div> + <div class="color-item"> + <h4>{{ $t('chats.chats') }}</h4> + <ColorInput + v-model="chatBgColorLocal" + name="chatBgColor" + :fallback="previewTheme.colors.bg || 1" + :label="$t('settings.background')" + /> + <h5>{{ $t('settings.style.advanced_colors.chat.incoming') }}</h5> + <ColorInput + v-model="chatMessageIncomingBgColorLocal" + name="chatMessageIncomingBgColor" + :fallback="previewTheme.colors.bg || 1" + :label="$t('settings.background')" + /> + <ColorInput + v-model="chatMessageIncomingTextColorLocal" + name="chatMessageIncomingTextColor" + :fallback="previewTheme.colors.text || 1" + :label="$t('settings.text')" + /> + <ColorInput + v-model="chatMessageIncomingLinkColorLocal" + name="chatMessageIncomingLinkColor" + :fallback="previewTheme.colors.link || 1" + :label="$t('settings.links')" + /> + <ColorInput + v-model="chatMessageIncomingBorderColorLocal" + name="chatMessageIncomingBorderLinkColor" + :fallback="previewTheme.colors.fg || 1" + :label="$t('settings.style.advanced_colors.chat.border')" + /> + <h5>{{ $t('settings.style.advanced_colors.chat.outgoing') }}</h5> + <ColorInput + v-model="chatMessageOutgoingBgColorLocal" + name="chatMessageOutgoingBgColor" + :fallback="previewTheme.colors.bg || 1" + :label="$t('settings.background')" + /> + <ColorInput + v-model="chatMessageOutgoingTextColorLocal" + name="chatMessageOutgoingTextColor" + :fallback="previewTheme.colors.text || 1" + :label="$t('settings.text')" + /> + <ColorInput + v-model="chatMessageOutgoingLinkColorLocal" + name="chatMessageOutgoingLinkColor" + :fallback="previewTheme.colors.link || 1" + :label="$t('settings.links')" + /> + <ColorInput + v-model="chatMessageOutgoingBorderColorLocal" + name="chatMessageOutgoingBorderLinkColor" + :fallback="previewTheme.colors.bg || 1" + :label="$t('settings.style.advanced_colors.chat.border')" + /> + </div> </div> <div @@ -814,6 +873,14 @@ max="50" hard-min="0" /> + <RangeInput + v-model="chatMessageRadiusLocal" + name="chatMessageRadius" + :label="$t('settings.chatMessageRadius')" + :fallback="previewTheme.radii.chatMessage || 2" + max="50" + hard-min="0" + /> </div> <div diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index d1f044f6..3a9e9e8f 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -1,3 +1,4 @@ +import { mapState, mapGetters } from 'vuex' import UserCard from '../user_card/user_card.vue' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' import GestureService from '../../services/gesture_service/gesture_service' @@ -47,7 +48,11 @@ const SideDrawer = { }, federating () { return this.$store.state.instance.federating - } + }, + ...mapState({ + pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable + }), + ...mapGetters(['unreadChatCount']) }, methods: { toggleDrawer () { diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index f253742d..4fdb3d13 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -40,12 +40,24 @@ </router-link> </li> <li - v-if="currentUser" + v-if="currentUser && pleromaChatMessagesAvailable" @click="toggleDrawer" > <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> <i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }} </router-link> + <router-link + :to="{ name: 'chats', params: { username: currentUser.screen_name } }" + style="position: relative" + > + <i class="button-icon icon-chat" /> {{ $t("nav.chats") }} + <span + v-if="unreadChatCount" + class="badge badge-notification unread-chat-count" + > + {{ unreadChatCount }} + </span> + </router-link> </li> <li v-if="currentUser" @@ -66,6 +78,14 @@ </router-link> </li> <li + v-if="currentUser" + @click="toggleDrawer" + > + <router-link :to="{ name: 'bookmarks'}"> + <i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }} + </router-link> + </li> + <li v-if="currentUser && currentUser.locked" @click="toggleDrawer" > @@ -95,14 +115,6 @@ <i class="button-icon icon-globe" /> {{ $t("nav.twkn") }} </router-link> </li> - <li - v-if="currentUser && chat" - @click="toggleDrawer" - > - <router-link :to="{ name: 'chat' }"> - <i class="button-icon icon-chat" /> {{ $t("nav.chat") }} - </router-link> - </li> </ul> <ul> <li diff --git a/src/components/staff_panel/staff_panel.js b/src/components/staff_panel/staff_panel.js index 4f98fff6..8665648a 100644 --- a/src/components/staff_panel/staff_panel.js +++ b/src/components/staff_panel/staff_panel.js @@ -2,6 +2,10 @@ import map from 'lodash/map' import BasicUserCard from '../basic_user_card/basic_user_card.vue' const StaffPanel = { + created () { + const nicknames = this.$store.state.instance.staffAccounts + nicknames.forEach(nickname => this.$store.dispatch('fetchUserIfMissing', nickname)) + }, components: { BasicUserCard }, diff --git a/src/components/status/status.js b/src/components/status/status.js index 73382521..ad0b72a9 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -141,7 +141,7 @@ const Status = { return this.mergedConfig.hideFilteredStatuses }, hideStatus () { - return (this.hideReply || this.deleted) || (this.muted && this.hideFilteredStatuses) + return this.deleted || (this.muted && this.hideFilteredStatuses) }, isFocused () { // retweet or root of an expanded conversation @@ -164,37 +164,6 @@ const Status = { return user && user.screen_name } }, - hideReply () { - if (this.mergedConfig.replyVisibility === 'all') { - return false - } - if (this.inConversation || !this.isReply) { - return false - } - if (this.status.user.id === this.currentUser.id) { - return false - } - if (this.status.type === 'retweet') { - return false - } - const checkFollowing = this.mergedConfig.replyVisibility === 'following' - for (var i = 0; i < this.status.attentions.length; ++i) { - if (this.status.user.id === this.status.attentions[i].id) { - continue - } - // There's zero guarantee of this working. If we happen to have that user and their - // relationship in store then it will work, but there's kinda little chance of having - // them for people you're not following. - const relationship = this.$store.state.users.relationships[this.status.attentions[i].id] - if (checkFollowing && relationship && relationship.following) { - return false - } - if (this.status.attentions[i].id === this.currentUser.id) { - return false - } - } - return this.status.attentions.length > 0 - }, replySubject () { if (!this.status.summary) return '' const decodedSummary = unescape(this.status.summary) diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 7ec29b28..f6b5dd6f 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -197,7 +197,7 @@ > <StatusPopover v-if="!isPreview" - :status-id="status.in_reply_to_status_id" + :status-id="status.parent_visible && status.in_reply_to_status_id" class="reply-to-popover" style="min-width: 0" > @@ -208,7 +208,12 @@ @click.prevent="gotoOriginal(status.in_reply_to_status_id)" > <i class="button-icon icon-reply" /> - <span class="faint-link reply-to-text">{{ $t('status.reply_to') }}</span> + <span + class="faint-link reply-to-text" + :class="{ 'strikethrough': !status.parent_visible }" + > + {{ $t('status.reply_to') }} + </span> </a> </StatusPopover> <span @@ -372,9 +377,6 @@ $status-margin: 0.75em; } .status-el { - overflow-wrap: break-word; - word-wrap: break-word; - word-break: break-word; border-left-width: 0px; min-width: 0; border-color: $fallback--border; @@ -526,6 +528,10 @@ $status-margin: 0.75em; margin: 0 0.4em 0 0.2em; } + .strikethrough { + text-decoration: line-through; + } + .replies-separator { margin-left: 0.4em; } diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js index c0a71e8f..df095de3 100644 --- a/src/components/status_content/status_content.js +++ b/src/components/status_content/status_content.js @@ -14,11 +14,12 @@ const StatusContent = { 'status', 'focused', 'noHeading', - 'fullContent' + 'fullContent', + 'singleLine' ], data () { return { - showingTall: this.inConversation && this.focused, + showingTall: this.fullContent || (this.inConversation && this.focused), showingLongSubject: false, // not as computed because it sets the initial state which will be changed later expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject @@ -44,14 +45,14 @@ const StatusContent = { return lengthScore > 20 }, longSubject () { - return this.status.summary.length > 900 + return this.status.summary.length > 240 }, // When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status. mightHideBecauseSubject () { - return this.status.summary && (!this.tallStatus || this.localCollapseSubjectDefault) + return !!this.status.summary && this.localCollapseSubjectDefault }, mightHideBecauseTall () { - return this.tallStatus && (!this.status.summary || !this.localCollapseSubjectDefault) + return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault) }, hideSubjectStatus () { return this.mightHideBecauseSubject && !this.expandingSubject @@ -99,15 +100,8 @@ const StatusContent = { file => !fileType.fileMatchesSomeType(this.galleryTypes, file) ) }, - hasImageAttachments () { - return this.status.attachments.some( - file => fileType.fileType(file.mimetype) === 'image' - ) - }, - hasVideoAttachments () { - return this.status.attachments.some( - file => fileType.fileType(file.mimetype) === 'video' - ) + attachmentTypes () { + return this.status.attachments.map(file => fileType.fileType(file.mimetype)) }, maxThumbnails () { return this.mergedConfig.maxThumbnails @@ -142,12 +136,6 @@ const StatusContent = { return html } }, - contentHtml () { - if (!this.status.summary_html) { - return this.postBodyHtml - } - return this.status.summary_html + '<br />' + this.postBodyHtml - }, ...mapGetters(['mergedConfig']), ...mapState({ betterShadow: state => state.interface.browserSupport.cssFilter, diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue index efc2485e..bf8d376e 100644 --- a/src/components/status_content/status_content.vue +++ b/src/components/status_content/status_content.vue @@ -3,45 +3,32 @@ <div class="status-body"> <slot name="header" /> <div - v-if="longSubject" - class="status-content-wrapper" - :class="{ 'tall-status': !showingLongSubject }" + v-if="status.summary_html" + class="summary-wrapper" + :class="{ 'tall-subject': (longSubject && !showingLongSubject) }" > - <a - v-if="!showingLongSubject" - class="tall-status-hider" - :class="{ 'tall-status-hider_focused': focused }" - href="#" - @click.prevent="showingLongSubject=true" - > - {{ $t("general.show_more") }} - <span - v-if="hasImageAttachments" - class="icon-picture" - /> - <span - v-if="hasVideoAttachments" - class="icon-video" - /> - <span - v-if="status.card" - class="icon-link" - /> - </a> <div - class="status-content media-body" + class="media-body summary" @click.prevent="linkClicked" - v-html="contentHtml" + v-html="status.summary_html" /> <a - v-if="showingLongSubject" + v-if="longSubject && showingLongSubject" href="#" - class="status-unhider" + class="tall-subject-hider" @click.prevent="showingLongSubject=false" - >{{ $t("general.show_less") }}</a> + >{{ $t("status.hide_full_subject") }}</a> + <a + v-else-if="longSubject" + class="tall-subject-hider" + :class="{ 'tall-subject-hider_focused': focused }" + href="#" + @click.prevent="showingLongSubject=true" + > + {{ $t("status.show_full_subject") }} + </a> </div> <div - v-else :class="{'tall-status': hideTallStatus}" class="status-content-wrapper" > @@ -51,31 +38,52 @@ :class="{ 'tall-status-hider_focused': focused }" href="#" @click.prevent="toggleShowMore" - >{{ $t("general.show_more") }}</a> + > + {{ $t("general.show_more") }} + </a> <div v-if="!hideSubjectStatus" + :class="{ 'single-line': singleLine }" class="status-content media-body" @click.prevent="linkClicked" - v-html="contentHtml" - /> - <div - v-else - class="status-content media-body" - @click.prevent="linkClicked" - v-html="status.summary_html" + v-html="postBodyHtml" /> <a v-if="hideSubjectStatus" href="#" class="cw-status-hider" @click.prevent="toggleShowMore" - >{{ $t("general.show_more") }}</a> + > + {{ $t("status.show_content") }} + <span + v-if="attachmentTypes.includes('image')" + class="icon-picture" + /> + <span + v-if="attachmentTypes.includes('video')" + class="icon-video" + /> + <span + v-if="attachmentTypes.includes('audio')" + class="icon-music" + /> + <span + v-if="attachmentTypes.includes('unknown')" + class="icon-doc" + /> + <span + v-if="status.card" + class="icon-link" + /> + </a> <a - v-if="showingMore" + v-if="showingMore && !fullContent" href="#" class="status-unhider" @click.prevent="toggleShowMore" - >{{ $t("general.show_less") }}</a> + > + {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }} + </a> </div> <div v-if="status.poll && status.poll.options"> @@ -129,6 +137,12 @@ $status-margin: 0.75em; flex: 1; min-width: 0; + .status-content-wrapper { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + } + .tall-status { position: relative; height: 220px; @@ -136,7 +150,7 @@ $status-margin: 0.75em; overflow-y: hidden; z-index: 1; .status-content { - height: 100%; + min-height: 0; mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat, linear-gradient(to top, white, white); /* Autoprefixed seem to ignore this one, and also syntax is different */ @@ -176,10 +190,45 @@ $status-margin: 0.75em; } } + .summary-wrapper { + margin-bottom: 0.5em; + border-style: solid; + border-width: 0 0 1px 0; + border-color: var(--border, $fallback--border); + flex-grow: 0; + } + + .summary { + font-style: italic; + padding-bottom: 0.5em; + } + + .tall-subject { + position: relative; + .summary { + max-height: 2em; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + .tall-subject-hider { + display: inline-block; + word-break: break-all; + // position: absolute; + width: 100%; + text-align: center; + padding-bottom: 0.5em; + } + .status-content { font-family: var(--postFont, sans-serif); line-height: 1.4em; white-space: pre-wrap; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; blockquote { margin: 0.2em 0 0.2em 2em; @@ -221,6 +270,12 @@ $status-margin: 0.75em; h4 { margin: 1.1em 0; } + + &.single-line { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } } } diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js index 159132a9..51e7680c 100644 --- a/src/components/status_popover/status_popover.js +++ b/src/components/status_popover/status_popover.js @@ -22,6 +22,10 @@ const StatusPopover = { methods: { enter () { if (!this.status) { + if (!this.statusId) { + this.error = true + return + } this.$store.dispatch('fetchStatus', this.statusId) .then(data => (this.error = false)) .catch(e => (this.error = true)) diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js index e48fef47..ab40bbd7 100644 --- a/src/components/still-image/still-image.js +++ b/src/components/still-image/still-image.js @@ -4,7 +4,8 @@ const StillImage = { 'referrerpolicy', 'mimetype', 'imageLoadError', - 'imageLoadHandler' + 'imageLoadHandler', + 'alt' ], data () { return { diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index f2ddeb7b..2ebf33ba 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -11,6 +11,8 @@ <img ref="src" :key="src" + :alt="alt" + :title="alt" :src="src" :referrerpolicy="referrerpolicy" @load="onLoad" diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 9a53acd6..bac73022 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -45,11 +45,15 @@ const Timeline = { newStatusCount () { return this.timeline.newStatusCount }, - newStatusCountStr () { + showLoadButton () { + if (this.timelineError || this.errorData) return false + return this.timeline.newStatusCount > 0 || this.timeline.flushMarker !== 0 + }, + loadButtonString () { if (this.timeline.flushMarker !== 0) { - return '' + return this.$t('timeline.reload') } else { - return ` (${this.newStatusCount})` + return `${this.$t('timeline.show_new')} (${this.newStatusCount})` } }, classes () { @@ -112,8 +116,6 @@ const Timeline = { if (e.key === '.') this.showNewStatuses() }, showNewStatuses () { - if (this.newStatusCount === 0) return - if (this.timeline.flushMarker !== 0) { this.$store.commit('clearTimeline', { timeline: this.timelineName, excludeUserId: true }) this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 }) @@ -135,7 +137,7 @@ const Timeline = { showImmediately: true, userId: this.userId, tag: this.tag - }).then(statuses => { + }).then(({ statuses }) => { store.commit('setLoading', { timeline: this.timelineName, value: false }) if (statuses && statuses.length === 0) { this.bottomedOut = true diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index 9777bd0c..111c0976 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -19,14 +19,14 @@ {{ errorData.statusText }} </div> <button - v-if="timeline.newStatusCount > 0 && !timelineError && !errorData" + v-else-if="showLoadButton" class="loadmore-button" @click.prevent="showNewStatuses" > - {{ $t('timeline.show_new') }}{{ newStatusCountStr }} + {{ loadButtonString }} </button> <div - v-if="!timeline.newStatusCount > 0 && !timelineError && !errorData" + v-else class="loadmore-text faint" @click.prevent > diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js index 4adf8211..94653004 100644 --- a/src/components/user_avatar/user_avatar.js +++ b/src/components/user_avatar/user_avatar.js @@ -8,26 +8,20 @@ const UserAvatar = { ], data () { return { - showPlaceholder: false + showPlaceholder: false, + defaultAvatar: `${this.$store.state.instance.server + this.$store.state.instance.defaultAvatar}` } }, components: { StillImage }, - computed: { - imgSrc () { - return this.showPlaceholder ? '/images/avi.png' : this.user.profile_image_url_original - } - }, methods: { + imgSrc (src) { + return (!src || this.showPlaceholder) ? this.defaultAvatar : src + }, imageLoadError () { this.showPlaceholder = true } - }, - watch: { - src () { - this.showPlaceholder = false - } } } diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue index 9ffb28d8..3545b801 100644 --- a/src/components/user_avatar/user_avatar.vue +++ b/src/components/user_avatar/user_avatar.vue @@ -3,7 +3,7 @@ class="avatar" :alt="user.screen_name" :title="user.screen_name" - :src="imgSrc" + :src="imgSrc(user.profile_image_url_original)" :class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }" :image-load-error="imageLoadError" /> diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue index 1db4f648..5685916a 100644 --- a/src/components/user_panel/user_panel.vue +++ b/src/components/user_panel/user_panel.vue @@ -10,9 +10,7 @@ :hide-bio="true" rounded="top" /> - <div class="panel-footer"> - <PostStatusForm /> - </div> + <PostStatusForm /> </div> <auth-form v-else diff --git a/src/components/video_attachment/video_attachment.vue b/src/components/video_attachment/video_attachment.vue index 97ddf1cd..1ffed4e0 100644 --- a/src/components/video_attachment/video_attachment.vue +++ b/src/components/video_attachment/video_attachment.vue @@ -4,6 +4,8 @@ :src="attachment.url" :loop="loopVideo" :controls="controls" + :alt="attachment.description" + :title="attachment.description" playsinline @loadeddata="onVideoDataLoad" /> diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.js b/src/components/who_to_follow_panel/who_to_follow_panel.js index dcb56106..818e8bd5 100644 --- a/src/components/who_to_follow_panel/who_to_follow_panel.js +++ b/src/components/who_to_follow_panel/who_to_follow_panel.js @@ -7,7 +7,7 @@ function showWhoToFollow (panel, reply) { panel.usersToFollow.forEach((toFollow, index) => { let user = shuffled[index] - let img = user.avatar || '/images/avi.png' + let img = user.avatar || this.$store.state.instance.defaultAvatar let name = user.acct toFollow.img = img @@ -38,13 +38,7 @@ function getWhoToFollow (panel) { const WhoToFollowPanel = { data: () => ({ - usersToFollow: new Array(3).fill().map(x => ( - { - img: '/images/avi.png', - name: '', - id: 0 - } - )) + usersToFollow: [] }), computed: { user: function () { @@ -68,6 +62,13 @@ const WhoToFollowPanel = { }, mounted: function () { + this.usersToFollow = new Array(3).fill().map(x => ( + { + img: this.$store.state.instance.defaultAvatar, + name: '', + id: 0 + } + )) if (this.suggestionsEnabled) { getWhoToFollow(this) } diff --git a/src/hocs/with_load_more/with_load_more.scss b/src/hocs/with_load_more/with_load_more.scss index 4cefe2be..1a26eb8d 100644 --- a/src/hocs/with_load_more/with_load_more.scss +++ b/src/hocs/with_load_more/with_load_more.scss @@ -12,5 +12,9 @@ .error { font-size: 14px; } + + a { + cursor: pointer; + } } } diff --git a/src/i18n/en.json b/src/i18n/en.json index 4a6c94cc..de81c92e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -44,6 +44,7 @@ }, "features_panel": { "chat": "Chat", + "pleroma_chat_messages": "Pleroma Chat", "gopher": "Gopher", "media_proxy": "Media proxy", "scope_options": "Scope options", @@ -120,10 +121,12 @@ "public_tl": "Public Timeline", "timeline": "Timeline", "twkn": "The Whole Known Network", + "bookmarks": "Bookmarks", "user_search": "User Search", "search": "Search", "who_to_follow": "Who to follow", - "preferences": "Preferences" + "preferences": "Preferences", + "chats": "Chats" }, "notifications": { "broken_favorite": "Unknown status, searching for it…", @@ -163,6 +166,9 @@ "load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.", "load_all": "Loading all {emojiAmount} emoji" }, + "errors": { + "storage_unavailable": "Pleroma could not access browser storage. Your login or your local settings won't be saved and you might encounter unexpected issues. Try enabling cookies." + }, "interactions": { "favs_repeats": "Repeats and Favorites", "follows": "New follows", @@ -174,6 +180,7 @@ "account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.", "account_not_locked_warning_link": "locked", "attachments_sensitive": "Mark attachments as sensitive", + "media_description": "Media description", "content_type": { "text/plain": "Plain text", "text/html": "HTML", @@ -185,6 +192,10 @@ "direct_warning_to_all": "This post will be visible to all the mentioned users.", "direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.", "posting": "Posting", + "preview": "Preview", + "preview_empty": "Empty", + "empty_status_error": "Can't post an empty status with no files", + "media_description_error": "Failed to update media, try again", "scope_notice": { "public": "This post will be visible to everyone", "private": "This post will be visible to your followers only", @@ -278,12 +289,11 @@ "change_password": "Change Password", "change_password_error": "There was an issue changing your password.", "changed_password": "Password changed successfully!", + "chatMessageRadius": "Chat message", "collapse_subject": "Collapse posts with subjects", "composing": "Composing", "confirm_new_password": "Confirm new password", - "current_avatar": "Your current avatar", "current_password": "Current password", - "current_profile_banner": "Your current profile banner", "mutes_and_blocks": "Mutes and Blocks", "data_import_export_tab": "Data Import / Export", "default_vis": "Default visibility scope", @@ -334,6 +344,12 @@ "loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")", "mutes_tab": "Mutes", "play_videos_in_modal": "Play videos in a popup frame", + "profile_fields": { + "label": "Profile metadata", + "add_field": "Add Field", + "name": "Label", + "value": "Content" + }, "use_contain_fit": "Don't crop the attachment in thumbnails", "name": "Name", "name_bio": "Name & Bio", @@ -384,6 +400,12 @@ "set_new_avatar": "Set new avatar", "set_new_profile_background": "Set new profile background", "set_new_profile_banner": "Set new profile banner", + "reset_avatar": "Reset avatar", + "reset_profile_background": "Reset profile background", + "reset_profile_banner": "Reset profile banner", + "reset_avatar_confirm": "Do you really want to reset the avatar?", + "reset_banner_confirm": "Do you really want to reset the banner?", + "reset_background_confirm": "Do you really want to reset the background?", "settings": "Settings", "subject_input_always_show": "Always show subject field", "subject_line_behavior": "Copy subject when replying", @@ -496,7 +518,12 @@ "selectedMenu": "Selected menu item", "disabled": "Disabled", "toggled": "Toggled", - "tabs": "Tabs" + "tabs": "Tabs", + "chat": { + "incoming": "Incoming", + "outgoing": "Outgoing", + "border": "Border" + } }, "radii": { "_tab_label": "Roundness" @@ -608,6 +635,7 @@ "no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated", "repeated": "repeated", "show_new": "Show new", + "reload": "Reload", "up_to_date": "Up-to-date", "no_more_statuses": "No more statuses", "no_statuses": "No statuses" @@ -619,6 +647,8 @@ "pin": "Pin on profile", "unpin": "Unpin from profile", "pinned": "Pinned", + "bookmark": "Bookmark", + "unbookmark": "Unbookmark", "delete_confirm": "Do you really want to delete this status?", "reply_to": "Reply to", "replies_list": "Replies:", @@ -627,7 +657,11 @@ "status_unavailable": "Status unavailable", "copy_link": "Copy link to status", "thread_muted": "Thread muted", - "thread_muted_and_words": ", has words:" + "thread_muted_and_words": ", has words:", + "show_full_subject": "Show full subject", + "hide_full_subject": "Hide full subject", + "show_content": "Show content", + "hide_content": "Hide content" }, "user_card": { "approve": "Approve", @@ -648,6 +682,7 @@ "its_you": "It's you!", "media": "Media", "mention": "Mention", + "message": "Message", "mute": "Mute", "muted": "Muted", "per_day": "per day", @@ -710,7 +745,8 @@ "add_reaction": "Add Reaction", "user_settings": "User Settings", "accept_follow_request": "Accept follow request", - "reject_follow_request": "Reject follow request" + "reject_follow_request": "Reject follow request", + "bookmark": "Bookmark" }, "upload": { "error": { @@ -745,5 +781,27 @@ "password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.", "password_reset_required": "You must reset your password to log in.", "password_reset_required_but_mailer_is_disabled": "You must reset your password, but password reset is disabled. Please contact your instance administrator." + }, + "chats": { + "you": "You:", + "message_user": "Message {nickname}", + "delete": "Delete", + "chats": "Chats", + "new": "New Chat", + "empty_message_error": "Cannot post empty message", + "more": "More", + "delete_confirm": "Do you really want to delete this message?", + "error_loading_chat": "Something went wrong when loading the chat.", + "error_sending_message": "Something went wrong when sending the message.", + "empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!" + }, + "file_type": { + "audio": "Audio", + "video": "Video", + "image": "Image", + "file": "File" + }, + "display_date": { + "today": "Today" } } diff --git a/src/i18n/fi.json b/src/i18n/fi.json index 99a1b53a..3e63377f 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -28,7 +28,12 @@ "disable": "Poista käytöstä", "confirm": "Hyväksy", "verify": "Varmenna", - "enable": "Ota käyttöön" + "enable": "Ota käyttöön", + "loading": "Ladataan…", + "error_retry": "Yritä uudelleen", + "retry": "Yritä uudelleen", + "close": "Sulje", + "peek": "Kurkkaa" }, "login": { "login": "Kirjaudu sisään", @@ -63,10 +68,11 @@ "who_to_follow": "Seurausehdotukset", "preferences": "Asetukset", "administration": "Ylläpito", - "search": "Haku" + "search": "Haku", + "bookmarks": "Kirjanmerkit" }, "notifications": { - "broken_favorite": "Viestiä ei löydetty...", + "broken_favorite": "Viestiä ei löydetty…", "favorited_you": "tykkäsi viestistäsi", "followed_you": "seuraa sinua", "load_older": "Lataa vanhempia ilmoituksia", @@ -101,7 +107,7 @@ }, "post_status": { "new_status": "Uusi viesti", - "account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nähdäksesi 'vain-seuraajille' -viestisi", + "account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nähdäksesi 'vain-seuraajille' -viestisi.", "account_not_locked_warning_link": "lukittu", "attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi", "content_type": { @@ -126,7 +132,12 @@ "public": "Tämä viesti näkyy kaikille", "private": "Tämä viesti näkyy vain sinun seuraajillesi", "unlisted": "Tämä viesti ei näy Julkisella Aikajanalla tai Koko Tunnettu Verkosto -aikajanalla" - } + }, + "preview": "Esikatselu", + "preview_empty": "Tyhjä", + "empty_status_error": "Tyhjää viestiä ilman tiedostoja ei voi lähettää", + "media_description": "Tiedoston kuvaus", + "media_description_error": "Tiedostojen päivitys epäonnistui, yritä uudelleen" }, "registration": { "bio": "Kuvaus", @@ -175,7 +186,7 @@ "data_import_export_tab": "Tietojen tuonti / vienti", "default_vis": "Oletusnäkyvyysrajaus", "delete_account": "Poista tili", - "delete_account_description": "Poista tilisi ja viestisi pysyvästi.", + "delete_account_description": "Poista tietosi ja lukitse tili pysyvästi.", "delete_account_error": "Virhe poistaessa tiliäsi. Jos virhe jatkuu, ota yhteyttä palvelimesi ylläpitoon.", "delete_account_instructions": "Syötä salasanasi vahvistaaksesi tilin poiston.", "emoji_reactions_on_timeline": "Näytä emojireaktiot aikajanalla", @@ -288,7 +299,7 @@ "authentication_methods": "Todennus", "warning_of_generate_new_codes": "Luodessasi uudet palautuskoodit, vanhat koodisi lakkaavat toimimasta.", "recovery_codes": "Palautuskoodit.", - "waiting_a_recovery_codes": "Odotetaan palautuskoodeja...", + "waiting_a_recovery_codes": "Odotetaan palautuskoodeja…", "recovery_codes_warning": "Kirjoita koodit ylös tai tallenna ne turvallisesti, muuten et näe niitä uudestaan. Jos et voi käyttää monivaihetodennusta ja sinulla ei ole palautuskoodeja, et voi enää kirjautua sisään tilillesi.", "scan": { "title": "Skannaa", @@ -329,7 +340,7 @@ "post_status_content_type": "Uuden viestin sisällön muoto", "user_mutes": "Käyttäjät", "useStreamingApiWarning": "(Kokeellinen)", - "type_domains_to_mute": "Syötä mykistettäviä sivustoja", + "type_domains_to_mute": "Etsi mykistettäviä sivustoja", "upload_a_photo": "Lataa kuva", "fun": "Hupi", "greentext": "Meeminuolet", @@ -490,7 +501,21 @@ "title": "Versio", "backend_version": "Palvelimen versio", "frontend_version": "Käyttöliittymän versio" - } + }, + "reset_profile_background": "Nollaa taustakuva", + "reset_background_confirm": "Haluatko todella nollata taustakuvan?", + "mutes_and_blocks": "Mykistykset ja Estot", + "bot": "Tämä on bottitili", + "profile_fields": { + "label": "Profiilin metatiedot", + "add_field": "Lisää kenttä", + "name": "Nimi", + "value": "Sisältö" + }, + "reset_avatar": "Nollaa profiilikuva", + "reset_profile_banner": "Nollaa profiilin tausta", + "reset_avatar_confirm": "Haluatko todella nollata profiilikuvan?", + "reset_banner_confirm": "Haluatko todella nollata profiilin taustan?" }, "time": { "day": "{0} päivä", @@ -536,7 +561,8 @@ "show_new": "Näytä uudet", "up_to_date": "Ajantasalla", "no_more_statuses": "Ei enempää viestejä", - "no_statuses": "Ei viestejä" + "no_statuses": "Ei viestejä", + "reload": "Päivitä" }, "status": { "favorites": "Tykkäykset", @@ -551,7 +577,15 @@ "mute_conversation": "Mykistä keskustelu", "unmute_conversation": "Poista mykistys", "status_unavailable": "Viesti ei saatavissa", - "copy_link": "Kopioi linkki" + "copy_link": "Kopioi linkki", + "bookmark": "Lisää kirjanmerkkeihin", + "unbookmark": "Poista kirjanmerkeistä", + "thread_muted": "Keskustelu mykistetty", + "thread_muted_and_words": ", sisältää sanat:", + "show_full_subject": "Näytä koko otsikko", + "hide_full_subject": "Piilota koko otsikko", + "show_content": "Näytä sisältö", + "hide_content": "Piilota sisältö" }, "user_card": { "approve": "Hyväksy", @@ -561,7 +595,7 @@ "follow": "Seuraa", "follow_sent": "Pyyntö lähetetty!", "follow_progress": "Pyydetään…", - "follow_again": "Lähetä pyyntö uudestaan", + "follow_again": "Lähetä pyyntö uudestaan?", "follow_unfollow": "Älä seuraa", "followees": "Seuraa", "followers": "Seuraajat", @@ -575,7 +609,7 @@ "statuses": "Viestit", "hidden": "Piilotettu", "media": "Media", - "block_progress": "Estetään...", + "block_progress": "Estetään…", "admin_menu": { "grant_admin": "Anna Ylläpitöoikeudet", "force_nsfw": "Merkitse kaikki viestit NSFW:nä", @@ -601,10 +635,10 @@ "subscribe": "Tilaa", "unsubscribe": "Poista tilaus", "unblock": "Poista esto", - "unblock_progress": "Postetaan estoa...", + "unblock_progress": "Poistetaan estoa…", "unmute": "Poista mykistys", - "unmute_progress": "Poistetaan mykistystä...", - "mute_progress": "Mykistetään...", + "unmute_progress": "Poistetaan mykistystä…", + "mute_progress": "Mykistetään…", "hide_repeats": "Piilota toistot", "show_repeats": "Näytä toistot" }, @@ -625,7 +659,8 @@ "user_settings": "Käyttäjäasetukset", "add_reaction": "Lisää Reaktio", "accept_follow_request": "Hyväksy seurauspyyntö", - "reject_follow_request": "Hylkää seurauspyyntö" + "reject_follow_request": "Hylkää seurauspyyntö", + "bookmark": "Kirjanmerkki" }, "upload": { "error": { @@ -674,8 +709,8 @@ "domain_mute_card": { "mute": "Mykistä", "unmute": "Poista mykistys", - "mute_progress": "Mykistetään...", - "unmute_progress": "Poistetaan mykistyst..." + "mute_progress": "Mykistetään…", + "unmute_progress": "Poistetaan mykistystä…" }, "exporter": { "export": "Vie", @@ -743,5 +778,8 @@ "people_talking": "{0} käyttäjää puhuvat", "person_talking": "{0} käyttäjä puhuu", "no_results": "Ei tuloksia" + }, + "errors": { + "storage_unavailable": "Pleroma ei voinut käyttää selaimen muistia. Kirjautumisesi ja paikalliset asetukset eivät tallennu ja saatat kohdata odottamattomia ongelmia. Yritä sallia evästeet." } } diff --git a/src/i18n/it.json b/src/i18n/it.json index 6c8be351..24520c09 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -34,7 +34,9 @@ "user_search": "Ricerca utenti", "search": "Ricerca", "who_to_follow": "Chi seguire", - "preferences": "Preferenze" + "preferences": "Preferenze", + "bookmarks": "Segnalibri", + "chats": "Conversazioni" }, "notifications": { "followed_you": "ti segue", @@ -84,7 +86,7 @@ "change_password": "Cambia password", "change_password_error": "C'è stato un problema durante il cambiamento della password.", "changed_password": "Password cambiata correttamente!", - "collapse_subject": "Ripiega messaggi con Oggetto", + "collapse_subject": "Ripiega messaggi con oggetto", "confirm_new_password": "Conferma la nuova password", "current_password": "La tua password attuale", "data_import_export_tab": "Importa o esporta dati", @@ -255,7 +257,13 @@ "top_bar": "Barra superiore", "panel_header": "Titolo pannello", "badge_notification": "Notifica", - "popover": "Suggerimenti, menù, sbalzi" + "popover": "Suggerimenti, menù, sbalzi", + "toggled": "Scambiato", + "chat": { + "border": "Bordo", + "outgoing": "Inviati", + "incoming": "Ricevuti" + } }, "common_colors": { "rgbo": "Icone, accenti, medaglie", @@ -270,10 +278,59 @@ "shadow_id": "Ombra numero {value}", "override": "Sostituisci", "component": "Componente", - "_tab_label": "Luci ed ombre" + "_tab_label": "Luci ed ombre", + "components": { + "avatarStatus": "Icona utente (vista messaggio)", + "avatar": "Icona utente (vista profilo)", + "topBar": "Barra superiore", + "panelHeader": "Intestazione pannello", + "panel": "Pannello", + "input": "Campo d'immissione", + "buttonPressedHover": "Pulsante (puntato e premuto)", + "buttonPressed": "Pulsante (premuto)", + "buttonHover": "Pulsante (puntato)", + "button": "Pulsante", + "popup": "Sbalzi e suggerimenti" + }, + "filter_hint": { + "inset_classic": "Le ombre incluse usano {0}", + "spread_zero": "Lo spandimento maggiore di zero si azzera sulle ombre", + "avatar_inset": "Tieni presente che combinare ombre (sia incluse che non) sulle icone utente potrebbe dare risultati strani con quelle trasparenti.", + "drop_shadow_syntax": "{0} non supporta il parametro {1} né la keyword {2}.", + "always_drop_shadow": "Attenzione: quest'ombra usa sempre {0} se il tuo browser lo supporta." + }, + "hintV3": "Per le ombre puoi anche usare la sintassi {0} per sfruttare il secondo colore." }, "radii": { "_tab_label": "Raggio" + }, + "fonts": { + "_tab_label": "Font", + "custom": "Personalizzato", + "weight": "Peso (grassettatura)", + "size": "Dimensione (in pixel)", + "family": "Nome font", + "components": { + "postCode": "Font a spaziatura fissa incluso in un messaggio", + "post": "Testo del messaggio", + "input": "Campi d'immissione", + "interface": "Interfaccia" + }, + "help": "Seleziona il font da usare per gli elementi dell'interfaccia. Se scegli \"personalizzato\" devi inserire il suo nome di sistema." + }, + "preview": { + "link": "un bel collegamentino", + "checkbox": "Ho dato uno sguardo a termini e condizioni", + "header_faint": "Tutto bene", + "fine_print": "Leggi il nostro {0} per imparare un bel niente!", + "faint_link": "utilissimo manuale", + "input": "Sono appena atterrato a Fiumicino.", + "mono": "contenuto", + "text": "Altro {0} e {1}", + "content": "Contenuto", + "button": "Pulsante", + "error": "Errore d'esempio", + "header": "Anteprima" } }, "enable_web_push_notifications": "Abilita notifiche web push", @@ -335,7 +392,26 @@ "emoji_reactions_on_timeline": "Mostra emoji di reazione sulle sequenze", "pad_emoji": "Affianca spazi agli emoji inseriti tramite selettore", "notification_blocks": "Bloccando un utente non riceverai più le sue notifiche né lo seguirai più.", - "mutes_and_blocks": "Zittiti e bloccati" + "mutes_and_blocks": "Zittiti e bloccati", + "profile_fields": { + "value": "Contenuto", + "name": "Etichetta", + "add_field": "Aggiungi campo", + "label": "Metadati profilo" + }, + "bot": "Questo profilo è di un robot", + "version": { + "frontend_version": "Versione interfaccia", + "backend_version": "Versione backend", + "title": "Versione" + }, + "reset_avatar": "Azzera icona", + "reset_profile_background": "Azzera sfondo profilo", + "reset_profile_banner": "Azzera stendardo profilo", + "reset_avatar_confirm": "Vuoi veramente azzerare l'icona?", + "reset_banner_confirm": "Vuoi veramente azzerare lo stendardo?", + "reset_background_confirm": "Vuoi veramente azzerare lo sfondo?", + "chatMessageRadius": "Messaggi istantanei" }, "timeline": { "error_fetching": "Errore nell'aggiornamento", @@ -345,7 +421,10 @@ "collapse": "Riduci", "conversation": "Conversazione", "no_retweet_hint": "Il messaggio è diretto o solo per seguaci e non può essere condiviso", - "repeated": "condiviso" + "repeated": "condiviso", + "no_statuses": "Nessun messaggio", + "no_more_statuses": "Fine dei messaggi", + "reload": "Ricarica" }, "user_card": { "follow": "Segui", @@ -361,7 +440,47 @@ "block": "Blocca", "blocked": "Bloccato!", "deny": "Nega", - "remote_follow": "Segui da remoto" + "remote_follow": "Segui da remoto", + "admin_menu": { + "delete_user_confirmation": "Ne sei completamente sicuro? Quest'azione non può essere annullata.", + "delete_user": "Elimina utente", + "quarantine": "I messaggi non arriveranno alle altre stanze", + "disable_any_subscription": "Rendi utente non seguibile", + "disable_remote_subscription": "Blocca i tentativi di seguirlo da altre stanze", + "sandbox": "Rendi tutti i messaggi solo per seguaci", + "force_unlisted": "Rendi tutti i messaggi invisibili", + "strip_media": "Rimuovi ogni allegato ai messaggi", + "force_nsfw": "Oscura tutti i messaggi", + "delete_account": "Elimina profilo", + "deactivate_account": "Disattiva profilo", + "activate_account": "Attiva profilo", + "revoke_moderator": "Divesti Moderatore", + "grant_moderator": "Crea Moderatore", + "revoke_admin": "Divesti Amministratore", + "grant_admin": "Crea Amministratore", + "moderation": "Moderazione" + }, + "show_repeats": "Mostra condivisioni", + "hide_repeats": "Nascondi condivisioni", + "mute_progress": "Zittisco…", + "unmute_progress": "Riabilito…", + "unmute": "Riabilita", + "block_progress": "Blocco…", + "unblock_progress": "Sblocco…", + "unblock": "Sblocca", + "unsubscribe": "Disdici", + "subscribe": "Abbònati", + "report": "Segnala", + "mention": "Menzioni", + "media": "Media", + "its_you": "Sei tu!", + "hidden": "Nascosto", + "follow_unfollow": "Disconosci", + "follow_again": "Reinvio richiesta?", + "follow_progress": "Richiedo…", + "follow_sent": "Richiesta inviata!", + "favorites": "Preferiti", + "message": "Contatta" }, "chat": { "title": "Chat" @@ -373,7 +492,8 @@ "scope_options": "Opzioni visibilità", "text_limit": "Lunghezza massima", "title": "Caratteristiche", - "who_to_follow": "Chi seguire" + "who_to_follow": "Chi seguire", + "pleroma_chat_messages": "Chiacchiere" }, "finder": { "error_fetching_user": "Errore nel recupero dell'utente", @@ -424,7 +544,12 @@ }, "direct_warning_to_first_only": "Questo messaggio sarà visibile solo agli utenti menzionati all'inizio.", "direct_warning_to_all": "Questo messaggio sarà visibile a tutti i menzionati.", - "new_status": "Nuovo messaggio" + "new_status": "Nuovo messaggio", + "empty_status_error": "Non puoi pubblicare messaggi vuoti senza allegati", + "preview_empty": "Vuoto", + "preview": "Anteprima", + "media_description_error": "Allegati non caricati, riprova", + "media_description": "Descrizione allegati" }, "registration": { "bio": "Introduzione", @@ -448,7 +573,9 @@ "captcha": "CAPTCHA" }, "user_profile": { - "timeline_title": "Sequenza dell'Utente" + "timeline_title": "Sequenza dell'Utente", + "profile_loading_error": "Spiacente, c'è stato un errore nel caricamento del profilo.", + "profile_does_not_exist": "Spiacente, questo profilo non esiste." }, "who_to_follow": { "more": "Altro", @@ -547,5 +674,140 @@ "error": "Non trovato.", "searching_for": "Cerco", "remote_user_resolver": "Cerca utenti remoti" + }, + "errors": { + "storage_unavailable": "Pleroma non ha potuto accedere ai dati del tuo browser. Le tue credenziali o le tue impostazioni locali non potranno essere salvate e potresti incontrare strani errori. Prova ad abilitare i cookie." + }, + "status": { + "pinned": "Intestato", + "unpin": "De-intesta", + "pin": "Intesta al profilo", + "delete": "Elimina messaggio", + "repeats": "Condivisi", + "favorites": "Preferiti", + "hide_content": "Nascondi contenuti", + "show_content": "Mostra contenuti", + "hide_full_subject": "Nascondi intero oggetto", + "show_full_subject": "Mostra intero oggetto", + "thread_muted_and_words": ", contiene:", + "thread_muted": "Discussione zittita", + "copy_link": "Copia collegamento", + "status_unavailable": "Messaggio non disponibile", + "unmute_conversation": "Riabilita conversazione", + "mute_conversation": "Zittisci conversazione", + "replies_list": "Risposte:", + "reply_to": "Rispondi a", + "delete_confirm": "Vuoi veramente eliminare questo messaggio?", + "unbookmark": "Rimuovi segnalibro", + "bookmark": "Aggiungi segnalibro" + }, + "time": { + "years_short": "{0}a", + "year_short": "{0}a", + "years": "{0} anni", + "year": "{0} anno", + "weeks_short": "{0}set", + "week_short": "{0}set", + "seconds_short": "{0}sec", + "second_short": "{0}sec", + "weeks": "{0} settimane", + "week": "{0} settimana", + "seconds": "{0} secondi", + "second": "{0} secondo", + "now_short": "ora", + "now": "adesso", + "months_short": "{0}me", + "month_short": "{0}me", + "months": "{0} mesi", + "month": "{0} mese", + "minutes_short": "{0}min", + "minute_short": "{0}min", + "minutes": "{0} minuti", + "minute": "{0} minuto", + "in_past": "{0} fa", + "in_future": "fra {0}", + "hours_short": "{0}h", + "days_short": "{0}g", + "hour_short": "{0}h", + "hours": "{0} ore", + "hour": "{0} ora", + "day_short": "{0}g", + "days": "{0} giorni", + "day": "{0} giorno" + }, + "user_reporting": { + "title": "Segnalo {0}", + "additional_comments": "Osservazioni accessorie", + "generic_error": "C'è stato un errore nell'elaborazione della tua richiesta.", + "submit": "Invia", + "forward_to": "Inoltra a {0}", + "forward_description": "Il profilo appartiene ad un'altra stanza. Inviare la segnalazione anche a quella?", + "add_comment_description": "La segnalazione sarà inviata ai moderatori della tua stanza. Puoi motivarla qui sotto:" + }, + "password_reset": { + "password_reset_required_but_mailer_is_disabled": "Devi reimpostare la tua password, ma non puoi farlo. Contatta il tuo amministratore.", + "password_reset_required": "Devi reimpostare la tua password per poter continuare.", + "password_reset_disabled": "Non puoi azzerare la tua password. Contatta il tuo amministratore.", + "too_many_requests": "Hai raggiunto il numero massimo di tentativi, riprova più tardi.", + "not_found": "Non ho trovato questa email o nome utente.", + "return_home": "Torna alla pagina principale", + "check_email": "Controlla la tua posta elettronica.", + "placeholder": "La tua email o nome utente", + "instruction": "Inserisci il tuo indirizzo email o il tuo nome utente. Ti invieremo un collegamento per reimpostare la tua password.", + "password_reset": "Azzera password", + "forgot_password": "Password dimenticata?" + }, + "search": { + "no_results": "Nessun risultato", + "people_talking": "{count} partecipanti", + "person_talking": "{count} partecipante", + "hashtags": "Etichette", + "people": "Utenti" + }, + "upload": { + "file_size_units": { + "TiB": "TiB", + "GiB": "GiB", + "MiB": "MiB", + "KiB": "KiB", + "B": "B" + }, + "error": { + "default": "Riprova in seguito", + "file_too_big": "File troppo pesante [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "base": "Caricamento fallito." + } + }, + "tool_tip": { + "bookmark": "Aggiungi segnalibro", + "reject_follow_request": "Rifiuta seguace", + "accept_follow_request": "Accetta seguace", + "user_settings": "Impostazioni utente", + "add_reaction": "Reagisci", + "favorite": "Gradisci", + "reply": "Rispondi", + "repeat": "Ripeti", + "media_upload": "Carica allegati" + }, + "display_date": { + "today": "Oggi" + }, + "file_type": { + "file": "File", + "image": "Immagine", + "video": "Video", + "audio": "Audio" + }, + "chats": { + "empty_chat_list_placeholder": "Non hai conversazioni. Contatta qualcuno!", + "error_sending_message": "Errore. Il messaggio non è stato inviato.", + "error_loading_chat": "Errore. La conversazione non è stata caricata.", + "delete_confirm": "Vuoi veramente eliminare questo messaggio?", + "more": "Altro", + "empty_message_error": "Non puoi inviare messaggi vuoti", + "new": "Nuova conversazione", + "chats": "Conversazioni", + "delete": "Elimina", + "message_user": "Contatta {nickname}" } } diff --git a/src/i18n/nl.json b/src/i18n/nl.json index af728b6e..bf270f87 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -28,7 +28,12 @@ "enable": "Inschakelen", "confirm": "Bevestigen", "verify": "Verifiëren", - "generic_error": "Er is een fout opgetreden" + "generic_error": "Er is een fout opgetreden", + "peek": "Spiek", + "close": "Sluiten", + "retry": "Opnieuw proberen", + "error_retry": "Probeer het opnieuw", + "loading": "Laden…" }, "login": { "login": "Log in", @@ -90,7 +95,7 @@ "text/bbcode": "BBCode" }, "content_warning": "Onderwerp (optioneel)", - "default": "Zojuist geland in L.A.", + "default": "Tijd voor anime!", "direct_warning": "Deze post zal enkel zichtbaar zijn voor de personen die genoemd zijn.", "posting": "Plaatsen", "scope": { @@ -377,7 +382,7 @@ "button": "Knop", "text": "Nog een boel andere {0} en {1}", "mono": "inhoud", - "input": "Zojuist geland in L.A.", + "input": "Tijd voor anime!", "faint_link": "handige gebruikershandleiding", "fine_print": "Lees onze {0} om niets nuttig te leren!", "header_faint": "Alles komt goed", @@ -451,7 +456,7 @@ "user_mutes": "Gebruikers", "useStreamingApi": "Berichten en meldingen in real-time ontvangen", "useStreamingApiWarning": "(Afgeraden, experimenteel, kan berichten overslaan)", - "type_domains_to_mute": "Voer domeinen in om te negeren", + "type_domains_to_mute": "Zoek domeinen om te negeren", "upload_a_photo": "Upload een foto", "fun": "Plezier", "greentext": "Meme pijlen", @@ -470,7 +475,15 @@ "frontend_version": "Frontend Versie", "backend_version": "Backend Versie", "title": "Versie" - } + }, + "mutes_and_blocks": "Negeringen en Blokkades", + "profile_fields": { + "value": "Inhoud", + "name": "Label", + "add_field": "Veld Toevoegen", + "label": "Profiel metadata" + }, + "bot": "Dit is een bot account" }, "timeline": { "collapse": "Inklappen", @@ -708,7 +721,9 @@ "unpin": "Van profiel losmaken", "delete": "Status verwijderen", "repeats": "Herhalingen", - "favorites": "Favorieten" + "favorites": "Favorieten", + "thread_muted_and_words": ", heeft woorden:", + "thread_muted": "Thread genegeerd" }, "time": { "years_short": "{0}j", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index aa78db26..08f05d18 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -45,7 +45,8 @@ "timeline": "Лента", "twkn": "Федеративная лента", "search": "Поиск", - "friend_requests": "Запросы на чтение" + "friend_requests": "Запросы на чтение", + "bookmarks": "Закладки" }, "notifications": { "broken_favorite": "Неизвестный статус, ищем...", @@ -366,6 +367,10 @@ "show_new": "Показать новые", "up_to_date": "Обновлено" }, + "status": { + "bookmark": "В закладки", + "unbookmark": "Удалить из закладок" + }, "user_card": { "block": "Заблокировать", "blocked": "Заблокирован", diff --git a/src/i18n/zh.json b/src/i18n/zh.json index f95dc498..7e620bdf 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -85,7 +85,7 @@ "administration": "管理员" }, "notifications": { - "broken_favorite": "未知的状态,正在搜索中...", + "broken_favorite": "未知的状态,正在搜索中…", "favorited_you": "收藏了你的状态", "followed_you": "关注了你", "load_older": "加载更早的通知", @@ -185,7 +185,7 @@ "generate_new_recovery_codes": "生成新的恢复码", "warning_of_generate_new_codes": "当你生成新的恢复码时,你的旧恢复码就失效了。", "recovery_codes": "恢复码。", - "waiting_a_recovery_codes": "正在接收备份码……", + "waiting_a_recovery_codes": "正在接收备份码…", "recovery_codes_warning": "抄写这些号码,或者保存在安全的地方。这些号码不会再次显示。如果你无法访问你的 2FA app,也丢失了你的恢复码,你的账号就再也无法登录了。", "authentication_methods": "身份验证方法", "scan": { @@ -564,11 +564,11 @@ "subscribe": "订阅", "unsubscribe": "退订", "unblock": "取消拉黑", - "unblock_progress": "取消拉黑中...", - "block_progress": "拉黑中...", + "unblock_progress": "取消拉黑中…", + "block_progress": "拉黑中…", "unmute": "取消隐藏", - "unmute_progress": "取消隐藏中...", - "mute_progress": "隐藏中...", + "unmute_progress": "取消隐藏中…", + "mute_progress": "隐藏中…", "admin_menu": { "moderation": "权限", "grant_admin": "赋予管理权限", @@ -690,9 +690,9 @@ } }, "domain_mute_card": { - "unmute_progress": "正在取消隐藏……", + "unmute_progress": "正在取消隐藏…", "unmute": "取消隐藏", - "mute_progress": "隐藏中……", + "mute_progress": "隐藏中…", "mute": "隐藏" } } diff --git a/src/main.js b/src/main.js index 9a201e4f..0a898022 100644 --- a/src/main.js +++ b/src/main.js @@ -19,6 +19,7 @@ import oauthTokensModule from './modules/oauth_tokens.js' import reportsModule from './modules/reports.js' import pollsModule from './modules/polls.js' import postStatusModule from './modules/postStatus.js' +import chatsModule from './modules/chats.js' import VueI18n from 'vue-i18n' @@ -62,7 +63,15 @@ const persistedStateOptions = { }; (async () => { - const persistedState = await createPersistedState(persistedStateOptions) + let storageError = false + const plugins = [pushNotifications] + try { + const persistedState = await createPersistedState(persistedStateOptions) + plugins.push(persistedState) + } catch (e) { + console.error(e) + storageError = true + } const store = new Vuex.Store({ modules: { i18n: { @@ -83,13 +92,16 @@ const persistedStateOptions = { oauthTokens: oauthTokensModule, reports: reportsModule, polls: pollsModule, - postStatus: postStatusModule + postStatus: postStatusModule, + chats: chatsModule }, - plugins: [persistedState, pushNotifications], + plugins, strict: false // Socket modifies itself, let's ignore this for now. // strict: process.env.NODE_ENV !== 'production' }) - + if (storageError) { + store.dispatch('pushGlobalNotice', { messageKey: 'errors.storage_unavailable', level: 'error' }) + } afterStoreSetup({ store, i18n }) })() diff --git a/src/modules/api.js b/src/modules/api.js index 748570e5..68402602 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -1,4 +1,5 @@ import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' +import { WSConnectionStatus } from '../services/api/api.service.js' import { Socket } from 'phoenix' const api = { @@ -7,6 +8,7 @@ const api = { fetchers: {}, socket: null, mastoUserSocket: null, + mastoUserSocketStatus: null, followRequests: [] }, mutations: { @@ -28,6 +30,9 @@ const api = { }, setFollowRequests (state, value) { state.followRequests = value + }, + setMastoUserSocketStatus (state, value) { + state.mastoUserSocketStatus = value } }, actions: { @@ -47,7 +52,7 @@ const api = { startMastoUserSocket (store) { return new Promise((resolve, reject) => { try { - const { state, dispatch, rootState } = store + const { state, commit, dispatch, rootState } = store const timelineData = rootState.statuses.timelines.friends state.mastoUserSocket = state.backendInteractor.startUserSocket({ store }) state.mastoUserSocket.addEventListener( @@ -66,11 +71,22 @@ const api = { showImmediately: timelineData.visibleStatuses.length === 0, timeline: 'friends' }) + } else if (message.event === 'pleroma:chat_update') { + dispatch('addChatMessages', { + chatId: message.chatUpdate.id, + messages: [message.chatUpdate.lastMessage] + }) + dispatch('updateChat', { chat: message.chatUpdate }) } } ) + state.mastoUserSocket.addEventListener('open', () => { + commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED) + }) state.mastoUserSocket.addEventListener('error', ({ detail: error }) => { console.error('Error in MastoAPI websocket:', error) + commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR) + dispatch('clearOpenedChats') }) state.mastoUserSocket.addEventListener('close', ({ detail: closeEvent }) => { const ignoreCodes = new Set([ @@ -84,8 +100,11 @@ const api = { console.warn(`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`) dispatch('startFetchingTimeline', { timeline: 'friends' }) dispatch('startFetchingNotifications') + dispatch('startFetchingChats') dispatch('restartMastoUserSocket') } + commit('setMastoUserSocketStatus', WSConnectionStatus.CLOSED) + dispatch('clearOpenedChats') }) resolve() } catch (e) { @@ -99,12 +118,13 @@ const api = { return dispatch('startMastoUserSocket').then(() => { dispatch('stopFetchingTimeline', { timeline: 'friends' }) dispatch('stopFetchingNotifications') + dispatch('stopFetchingChats') }) }, stopMastoUserSocket ({ state, dispatch }) { dispatch('startFetchingTimeline', { timeline: 'friends' }) dispatch('startFetchingNotifications') - console.log(state.mastoUserSocket) + dispatch('startFetchingChats') state.mastoUserSocket.close() }, @@ -138,9 +158,6 @@ const api = { if (!fetcher) return store.commit('removeFetcher', { fetcherName: 'notifications', fetcher }) }, - fetchAndUpdateNotifications (store) { - store.state.backendInteractor.fetchAndUpdateNotifications({ store }) - }, // Follow requests startFetchingFollowRequests (store) { diff --git a/src/modules/chats.js b/src/modules/chats.js new file mode 100644 index 00000000..228d6256 --- /dev/null +++ b/src/modules/chats.js @@ -0,0 +1,225 @@ +import Vue from 'vue' +import { find, omitBy, orderBy, sumBy } from 'lodash' +import chatService from '../services/chat_service/chat_service.js' +import { parseChat, parseChatMessage } from '../services/entity_normalizer/entity_normalizer.service.js' + +const emptyChatList = () => ({ + data: [], + idStore: {} +}) + +const defaultState = { + chatList: emptyChatList(), + chatListFetcher: null, + openedChats: {}, + openedChatMessageServices: {}, + fetcher: undefined, + currentChatId: null +} + +const getChatById = (state, id) => { + return find(state.chatList.data, { id }) +} + +const sortedChatList = (state) => { + return orderBy(state.chatList.data, ['updated_at'], ['desc']) +} + +const unreadChatCount = (state) => { + return sumBy(state.chatList.data, 'unread') +} + +const chats = { + state: { ...defaultState }, + getters: { + currentChat: state => state.openedChats[state.currentChatId], + currentChatMessageService: state => state.openedChatMessageServices[state.currentChatId], + findOpenedChatByRecipientId: state => recipientId => find(state.openedChats, c => c.account.id === recipientId), + sortedChatList, + unreadChatCount + }, + actions: { + // Chat list + startFetchingChats ({ dispatch, commit }) { + const fetcher = () => { + dispatch('fetchChats', { latest: true }) + } + fetcher() + commit('setChatListFetcher', { + fetcher: () => setInterval(() => { fetcher() }, 5000) + }) + }, + stopFetchingChats ({ commit }) { + commit('setChatListFetcher', { fetcher: undefined }) + }, + fetchChats ({ dispatch, rootState, commit }, params = {}) { + return rootState.api.backendInteractor.chats() + .then(({ chats }) => { + dispatch('addNewChats', { chats }) + return chats + }) + }, + addNewChats ({ rootState, commit, dispatch, rootGetters }, { chats }) { + commit('addNewChats', { dispatch, chats, rootGetters }) + }, + updateChat ({ commit }, { chat }) { + commit('updateChat', { chat }) + }, + + // Opened Chats + startFetchingCurrentChat ({ commit, dispatch }, { fetcher }) { + dispatch('setCurrentChatFetcher', { fetcher }) + }, + setCurrentChatFetcher ({ rootState, commit }, { fetcher }) { + commit('setCurrentChatFetcher', { fetcher }) + }, + addOpenedChat ({ rootState, commit, dispatch }, { chat }) { + commit('addOpenedChat', { dispatch, chat: parseChat(chat) }) + dispatch('addNewUsers', [chat.account]) + }, + addChatMessages ({ commit }, value) { + commit('addChatMessages', { commit, ...value }) + }, + resetChatNewMessageCount ({ commit }, value) { + commit('resetChatNewMessageCount', value) + }, + clearCurrentChat ({ rootState, commit, dispatch }, value) { + commit('setCurrentChatId', { chatId: undefined }) + commit('setCurrentChatFetcher', { fetcher: undefined }) + }, + readChat ({ rootState, commit, dispatch }, { id, lastReadId }) { + dispatch('resetChatNewMessageCount') + commit('readChat', { id }) + rootState.api.backendInteractor.readChat({ id, lastReadId }) + }, + deleteChatMessage ({ rootState, commit }, value) { + rootState.api.backendInteractor.deleteChatMessage(value) + commit('deleteChatMessage', { commit, ...value }) + }, + resetChats ({ commit, dispatch }) { + dispatch('clearCurrentChat') + commit('resetChats', { commit }) + }, + clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) { + commit('clearOpenedChats', { commit }) + } + }, + mutations: { + setChatListFetcher (state, { commit, fetcher }) { + const prevFetcher = state.chatListFetcher + if (prevFetcher) { + clearInterval(prevFetcher) + } + state.chatListFetcher = fetcher && fetcher() + }, + setCurrentChatFetcher (state, { fetcher }) { + const prevFetcher = state.fetcher + if (prevFetcher) { + clearInterval(prevFetcher) + } + state.fetcher = fetcher && fetcher() + }, + addOpenedChat (state, { _dispatch, chat }) { + state.currentChatId = chat.id + Vue.set(state.openedChats, chat.id, chat) + + if (!state.openedChatMessageServices[chat.id]) { + Vue.set(state.openedChatMessageServices, chat.id, chatService.empty(chat.id)) + } + }, + setCurrentChatId (state, { chatId }) { + state.currentChatId = chatId + }, + addNewChats (state, { _dispatch, chats, _rootGetters }) { + chats.forEach((updatedChat) => { + const chat = getChatById(state, updatedChat.id) + + if (chat) { + chat.lastMessage = updatedChat.lastMessage + chat.unread = updatedChat.unread + } else { + state.chatList.data.push(updatedChat) + Vue.set(state.chatList.idStore, updatedChat.id, updatedChat) + } + }) + }, + updateChat (state, { _dispatch, chat: updatedChat, _rootGetters }) { + const chat = getChatById(state, updatedChat.id) + if (chat) { + chat.lastMessage = updatedChat.lastMessage + chat.unread = updatedChat.unread + chat.updated_at = updatedChat.updated_at + } + if (!chat) { state.chatList.data.unshift(updatedChat) } + Vue.set(state.chatList.idStore, updatedChat.id, updatedChat) + }, + deleteChat (state, { _dispatch, id, _rootGetters }) { + state.chats.data = state.chats.data.filter(conversation => + conversation.last_status.id !== id + ) + state.chats.idStore = omitBy(state.chats.idStore, conversation => conversation.last_status.id === id) + }, + resetChats (state, { commit }) { + state.chatList = emptyChatList() + state.currentChatId = null + commit('setChatListFetcher', { fetcher: undefined }) + for (const chatId in state.openedChats) { + chatService.clear(state.openedChatMessageServices[chatId]) + Vue.delete(state.openedChats, chatId) + Vue.delete(state.openedChatMessageServices, chatId) + } + }, + setChatsLoading (state, { value }) { + state.chats.loading = value + }, + addChatMessages (state, { commit, chatId, messages }) { + const chatMessageService = state.openedChatMessageServices[chatId] + if (chatMessageService) { + chatService.add(chatMessageService, { messages: messages.map(parseChatMessage) }) + commit('refreshLastMessage', { chatId }) + } + }, + refreshLastMessage (state, { chatId }) { + const chatMessageService = state.openedChatMessageServices[chatId] + if (chatMessageService) { + const chat = getChatById(state, chatId) + if (chat) { + chat.lastMessage = chatMessageService.lastMessage + if (chatMessageService.lastMessage) { + chat.updated_at = chatMessageService.lastMessage.created_at + } + } + } + }, + deleteChatMessage (state, { commit, chatId, messageId }) { + const chatMessageService = state.openedChatMessageServices[chatId] + if (chatMessageService) { + chatService.deleteMessage(chatMessageService, messageId) + commit('refreshLastMessage', { chatId }) + } + }, + resetChatNewMessageCount (state, _value) { + const chatMessageService = state.openedChatMessageServices[state.currentChatId] + chatService.resetNewMessageCount(chatMessageService) + }, + // Used when a connection loss occurs + clearOpenedChats (state) { + const currentChatId = state.currentChatId + for (const chatId in state.openedChats) { + if (currentChatId !== chatId) { + chatService.clear(state.openedChatMessageServices[chatId]) + Vue.delete(state.openedChats, chatId) + Vue.delete(state.openedChatMessageServices, chatId) + } + } + }, + readChat (state, { id }) { + const chat = getChatById(state, id) + if (chat) { + chat.unread = 0 + } + } + } +} + +export default chats diff --git a/src/modules/config.js b/src/modules/config.js index 47b24d77..e0fe72df 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -46,7 +46,8 @@ export const defaultState = { repeats: true, moves: true, emojiReactions: false, - followRequest: true + followRequest: true, + chatMention: true }, webPushNotifications: false, muteWords: [], diff --git a/src/modules/instance.js b/src/modules/instance.js index ec5f4e54..3fe3bbf3 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -15,6 +15,8 @@ const defaultState = { // Stuff from static/config.json alwaysShowSubjectInput: true, + defaultAvatar: '/images/avi.png', + defaultBanner: '/images/banner.png', background: '/static/aurora_borealis.jpg', collapseMessageWithSubject: false, disableChat: false, @@ -53,6 +55,7 @@ const defaultState = { // Feature-set, apparently, not everything here is reported... chatAvailable: false, + pleromaChatMessagesAvailable: false, gopherAvailable: false, mediaProxyAvailable: false, suggestionsEnabled: false, diff --git a/src/modules/interface.js b/src/modules/interface.js index eeebd65e..ec08ac0a 100644 --- a/src/modules/interface.js +++ b/src/modules/interface.js @@ -14,7 +14,9 @@ const defaultState = { window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)') ) }, - mobileLayout: false + mobileLayout: false, + globalNotices: [], + layoutHeight: 0 } const interfaceMod = { @@ -58,6 +60,15 @@ const interfaceMod = { if (!state.settingsModalLoaded) { state.settingsModalLoaded = true } + }, + pushGlobalNotice (state, notice) { + state.globalNotices.push(notice) + }, + removeGlobalNotice (state, notice) { + state.globalNotices = state.globalNotices.filter(n => n !== notice) + }, + setLayoutHeight (state, value) { + state.layoutHeight = value } }, actions: { @@ -81,6 +92,31 @@ const interfaceMod = { }, togglePeekSettingsModal ({ commit }) { commit('togglePeekSettingsModal') + }, + pushGlobalNotice ( + { commit, dispatch }, + { + messageKey, + messageArgs = {}, + level = 'error', + timeout = 0 + }) { + const notice = { + messageKey, + messageArgs, + level + } + if (timeout) { + setTimeout(() => dispatch('removeGlobalNotice', notice), timeout) + } + commit('pushGlobalNotice', notice) + return notice + }, + removeGlobalNotice ({ commit }, notice) { + commit('removeGlobalNotice', notice) + }, + setLayoutHeight ({ commit }, value) { + commit('setLayoutHeight', value) } } } diff --git a/src/modules/media_viewer.js b/src/modules/media_viewer.js index a24b408d..721c25e6 100644 --- a/src/modules/media_viewer.js +++ b/src/modules/media_viewer.js @@ -22,7 +22,7 @@ const mediaViewer = { setMedia ({ commit }, attachments) { const media = attachments.filter(attachment => { const type = fileTypeService.fileType(attachment.mimetype) - return type === 'image' || type === 'video' + return type === 'image' || type === 'video' || type === 'audio' }) commit('setMedia', media) }, diff --git a/src/modules/statuses.js b/src/modules/statuses.js index 073b15f1..64f5b587 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -62,7 +62,8 @@ export const defaultState = () => ({ publicAndExternal: emptyTl(), friends: emptyTl(), tag: emptyTl(), - dms: emptyTl() + dms: emptyTl(), + bookmarks: emptyTl() } }) @@ -163,8 +164,7 @@ const removeStatusFromGlobalStorage = (state, status) => { } } -const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, - noIdUpdate = false, userId }) => { +const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false, userId, pagination = {} }) => { // Sanity check if (!isArray(statuses)) { return false @@ -173,8 +173,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us const allStatuses = state.allStatuses const timelineObject = state.timelines[timeline] - const maxNew = statuses.length > 0 ? maxBy(statuses, 'id').id : 0 - const minNew = statuses.length > 0 ? minBy(statuses, 'id').id : 0 + // Mismatch between API pagination and our internal minId/maxId tracking systems: + // pagination.maxId is the oldest of the returned statuses when fetching older, + // and pagination.minId is the newest when fetching newer. The names come directly + // from the arguments they're supposed to be passed as for the next fetch. + const minNew = pagination.maxId || (statuses.length > 0 ? minBy(statuses, 'id').id : 0) + const maxNew = pagination.minId || (statuses.length > 0 ? maxBy(statuses, 'id').id : 0) + const newer = timeline && (maxNew > timelineObject.maxId || timelineObject.maxId === 0) && statuses.length > 0 const older = timeline && (minNew < timelineObject.minId || timelineObject.minId === 0) && statuses.length > 0 @@ -315,7 +320,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us }) // Keep the visible statuses sorted - if (timeline) { + if (timeline && !(timeline === 'bookmarks')) { sortTimeline(timelineObject) } } @@ -463,9 +468,17 @@ export const mutations = { newStatus.rebloggedBy.push(user) } }, + setBookmarked (state, { status, value }) { + const newStatus = state.allStatusesObject[status.id] + newStatus.bookmarked = value + }, + setBookmarkedConfirm (state, { status }) { + const newStatus = state.allStatusesObject[status.id] + newStatus.bookmarked = status.bookmarked + }, setDeleted (state, { status }) { const newStatus = state.allStatusesObject[status.id] - newStatus.deleted = true + if (newStatus) newStatus.deleted = true }, setManyDeleted (state, condition) { Object.values(state.allStatusesObject).forEach(status => { @@ -508,6 +521,9 @@ export const mutations = { dismissNotification (state, { id }) { state.notifications.data = state.notifications.data.filter(n => n.id !== id) }, + dismissNotifications (state, { finder }) { + state.notifications.data = state.notifications.data.filter(n => finder) + }, updateNotification (state, { id, updater }) { const notification = find(state.notifications.data, n => n.id === id) notification && updater(notification) @@ -515,6 +531,11 @@ export const mutations = { queueFlush (state, { timeline, id }) { state.timelines[timeline].flushMarker = id }, + queueFlushAll (state) { + Object.keys(state.timelines).forEach((timeline) => { + state.timelines[timeline].flushMarker = state.timelines[timeline].maxId + }) + }, addRepeats (state, { id, rebloggedByUsers, currentUser }) { const newStatus = state.allStatusesObject[id] newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _) @@ -585,8 +606,8 @@ export const mutations = { const statuses = { state: defaultState(), actions: { - addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId }) { - commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId }) + addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId, pagination }) { + commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId, pagination }) }, addNewNotifications ({ rootState, commit, dispatch, rootGetters }, { notifications, older }) { commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older, rootGetters }) @@ -661,9 +682,26 @@ const statuses = { rootState.api.backendInteractor.unretweet({ id: status.id }) .then(status => commit('setRetweetedConfirm', { status, user: rootState.users.currentUser })) }, + bookmark ({ rootState, commit }, status) { + commit('setBookmarked', { status, value: true }) + rootState.api.backendInteractor.bookmarkStatus({ id: status.id }) + .then(status => { + commit('setBookmarkedConfirm', { status }) + }) + }, + unbookmark ({ rootState, commit }, status) { + commit('setBookmarked', { status, value: false }) + rootState.api.backendInteractor.unbookmarkStatus({ id: status.id }) + .then(status => { + commit('setBookmarkedConfirm', { status }) + }) + }, queueFlush ({ rootState, commit }, { timeline, id }) { commit('queueFlush', { timeline, id }) }, + queueFlushAll ({ rootState, commit }) { + commit('queueFlushAll') + }, markNotificationsAsSeen ({ rootState, commit }) { commit('markNotificationsAsSeen') apiService.markNotificationsAsSeen({ diff --git a/src/modules/users.js b/src/modules/users.js index 5e32bb49..16c1e566 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -1,6 +1,6 @@ import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import oauthApi from '../services/new_api/oauth.js' -import { compact, map, each, merge, last, concat, uniq } from 'lodash' +import { compact, map, each, mergeWith, last, concat, uniq, isArray } from 'lodash' import { set } from 'vue' import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js' @@ -10,7 +10,7 @@ export const mergeOrAdd = (arr, obj, item) => { const oldItem = obj[item.id] if (oldItem) { // We already have this, so only merge the new info. - merge(oldItem, item) + mergeWith(oldItem, item, mergeArrayLength) return { item: oldItem, new: false } } else { // This is a new item, prepare it @@ -23,6 +23,13 @@ export const mergeOrAdd = (arr, obj, item) => { } } +const mergeArrayLength = (oldValue, newValue) => { + if (isArray(oldValue) && isArray(newValue)) { + oldValue.length = newValue.length + return mergeWith(oldValue, newValue, mergeArrayLength) + } +} + const getNotificationPermission = () => { const Notification = window.Notification @@ -116,7 +123,7 @@ export const mutations = { }, setCurrentUser (state, user) { state.lastLoginName = user.screen_name - state.currentUser = merge(state.currentUser || {}, user) + state.currentUser = mergeWith(state.currentUser || {}, user, mergeArrayLength) }, clearCurrentUser (state) { state.currentUser = false @@ -259,6 +266,11 @@ const users = { mutations, getters, actions: { + fetchUserIfMissing (store, id) { + if (!store.getters.findUser(id)) { + store.dispatch('fetchUser', id) + } + }, fetchUser (store, id) { return store.rootState.api.backendInteractor.fetchUser({ id }) .then((user) => { @@ -486,6 +498,7 @@ const users = { store.dispatch('stopFetchingFollowRequests') store.commit('clearNotifications') store.commit('resetStatuses') + store.dispatch('resetChats') }) }, loginUser (store, accessToken) { @@ -525,6 +538,9 @@ const users = { // Start fetching notifications store.dispatch('startFetchingNotifications') + + // Start fetching chats + store.dispatch('startFetchingChats') } if (store.getters.mergedConfig.useStreamingApi) { @@ -532,6 +548,7 @@ const users = { console.error('Failed initializing MastoAPI Streaming socket', error) startPolling() }).then(() => { + store.dispatch('fetchChats', { latest: true }) setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000) }) } else { diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index dfffc291..40ea5bd9 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -1,5 +1,5 @@ import { each, map, concat, last, get } from 'lodash' -import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js' +import { parseStatus, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js' import { RegistrationError, StatusCodeError } from '../errors/errors' /* eslint-env browser */ @@ -50,6 +50,7 @@ const MASTODON_USER_URL = '/api/v1/accounts' const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships' const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses` const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}` +const MASTODON_BOOKMARK_TIMELINE_URL = '/api/v1/bookmarks' const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/' const MASTODON_USER_MUTES_URL = '/api/v1/mutes/' const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block` @@ -58,6 +59,8 @@ const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute` const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute` const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe` const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe` +const MASTODON_BOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/bookmark` +const MASTODON_UNBOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/unbookmark` const MASTODON_POST_STATUS_URL = '/api/v1/statuses' const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media' const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes` @@ -78,6 +81,11 @@ const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers' const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions` const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` +const PLEROMA_CHATS_URL = `/api/v1/pleroma/chats` +const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}` +const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages` +const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read` +const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}` const oldfetch = window.fetch @@ -138,20 +146,11 @@ const updateNotificationSettings = ({ credentials, settings }) => { }).then((data) => data.json()) } -const updateAvatar = ({ credentials, avatar }) => { +const updateProfileImages = ({ credentials, avatar = null, banner = null, background = null }) => { const form = new FormData() - form.append('avatar', avatar) - return fetch(MASTODON_PROFILE_UPDATE_URL, { - headers: authHeaders(credentials), - method: 'PATCH', - body: form - }).then((data) => data.json()) - .then((data) => parseUser(data)) -} - -const updateBg = ({ credentials, background }) => { - const form = new FormData() - form.append('pleroma_background_image', background) + if (avatar !== null) form.append('avatar', avatar) + if (banner !== null) form.append('header', banner) + if (background !== null) form.append('pleroma_background_image', background) return fetch(MASTODON_PROFILE_UPDATE_URL, { headers: authHeaders(credentials), method: 'PATCH', @@ -161,17 +160,6 @@ const updateBg = ({ credentials, background }) => { .then((data) => parseUser(data)) } -const updateBanner = ({ credentials, banner }) => { - const form = new FormData() - form.append('header', banner) - return fetch(MASTODON_PROFILE_UPDATE_URL, { - headers: authHeaders(credentials), - method: 'PATCH', - body: form - }).then((data) => data.json()) - .then((data) => parseUser(data)) -} - const updateProfile = ({ credentials, params }) => { return promisedRequest({ url: MASTODON_PROFILE_UPDATE_URL, @@ -498,7 +486,8 @@ const fetchTimeline = ({ until = false, userId = false, tag = false, - withMuted = false + withMuted = false, + replyVisibility = 'all' }) => { const timelineUrls = { public: MASTODON_PUBLIC_TIMELINE, @@ -509,7 +498,8 @@ const fetchTimeline = ({ user: MASTODON_USER_TIMELINE_URL, media: MASTODON_USER_TIMELINE_URL, favorites: MASTODON_USER_FAVORITES_TIMELINE_URL, - tag: MASTODON_TAG_TIMELINE_URL + tag: MASTODON_TAG_TIMELINE_URL, + bookmarks: MASTODON_BOOKMARK_TIMELINE_URL } const isNotifications = timeline === 'notifications' const params = [] @@ -538,9 +528,12 @@ const fetchTimeline = ({ if (timeline === 'public' || timeline === 'publicAndExternal') { params.push(['only_media', false]) } - if (timeline !== 'favorites') { + if (timeline !== 'favorites' && timeline !== 'bookmarks') { params.push(['with_muted', withMuted]) } + if (replyVisibility !== 'all') { + params.push(['reply_visibility', replyVisibility]) + } params.push(['limit', 20]) @@ -548,16 +541,20 @@ const fetchTimeline = ({ url += `?${queryString}` let status = '' let statusText = '' + let pagination = {} return fetch(url, { headers: authHeaders(credentials) }) .then((data) => { status = data.status statusText = data.statusText + pagination = parseLinkHeaderPagination(data.headers.get('Link'), { + flakeId: timeline !== 'bookmarks' && timeline !== 'notifications' + }) return data }) .then((data) => data.json()) .then((data) => { if (!data.error) { - return data.map(isNotifications ? parseNotification : parseStatus) + return { data: data.map(isNotifications ? parseNotification : parseStatus), pagination } } else { data.status = status data.statusText = statusText @@ -608,6 +605,22 @@ const unretweet = ({ id, credentials }) => { .then((data) => parseStatus(data)) } +const bookmarkStatus = ({ id, credentials }) => { + return promisedRequest({ + url: MASTODON_BOOKMARK_STATUS_URL(id), + headers: authHeaders(credentials), + method: 'POST' + }) +} + +const unbookmarkStatus = ({ id, credentials }) => { + return promisedRequest({ + url: MASTODON_UNBOOKMARK_STATUS_URL(id), + headers: authHeaders(credentials), + method: 'POST' + }) +} + const postStatus = ({ credentials, status, @@ -617,7 +630,8 @@ const postStatus = ({ poll, mediaIds = [], inReplyToStatusId, - contentType + contentType, + preview }) => { const form = new FormData() const pollOptions = poll.options || [] @@ -647,6 +661,9 @@ const postStatus = ({ if (inReplyToStatusId) { form.append('in_reply_to_id', inReplyToStatusId) } + if (preview) { + form.append('preview', 'true') + } return fetch(MASTODON_POST_STATUS_URL, { body: form, @@ -654,13 +671,7 @@ const postStatus = ({ headers: authHeaders(credentials) }) .then((response) => { - if (response.ok) { - return response.json() - } else { - return { - error: response - } - } + return response.json() }) .then((data) => data.error ? data : parseStatus(data)) } @@ -682,6 +693,17 @@ const uploadMedia = ({ formData, credentials }) => { .then((data) => parseAttachment(data)) } +const setMediaDescription = ({ id, description, credentials }) => { + return promisedRequest({ + url: `${MASTODON_MEDIA_UPLOAD_URL}/${id}`, + method: 'PUT', + headers: authHeaders(credentials), + payload: { + description + } + }).then((data) => parseAttachment(data)) +} + const importBlocks = ({ file, credentials }) => { const formData = new FormData() formData.append('list', file) @@ -1050,6 +1072,10 @@ const MASTODON_STREAMING_EVENTS = new Set([ 'filters_changed' ]) +const PLEROMA_STREAMING_EVENTS = new Set([ + 'pleroma:chat_update' +]) + // A thin wrapper around WebSocket API that allows adding a pre-processor to it // Uses EventTarget and a CustomEvent to proxy events export const ProcessedWS = ({ @@ -1106,7 +1132,7 @@ export const handleMastoWS = (wsEvent) => { if (!data) return const parsedEvent = JSON.parse(data) const { event, payload } = parsedEvent - if (MASTODON_STREAMING_EVENTS.has(event)) { + if (MASTODON_STREAMING_EVENTS.has(event) || PLEROMA_STREAMING_EVENTS.has(event)) { // MastoBE and PleromaBE both send payload for delete as a PLAIN string if (event === 'delete') { return { event, id: payload } @@ -1116,6 +1142,8 @@ export const handleMastoWS = (wsEvent) => { return { event, status: parseStatus(data) } } else if (event === 'notification') { return { event, notification: parseNotification(data) } + } else if (event === 'pleroma:chat_update') { + return { event, chatUpdate: parseChat(data) } } } else { console.warn('Unknown event', wsEvent) @@ -1123,6 +1151,81 @@ export const handleMastoWS = (wsEvent) => { } } +export const WSConnectionStatus = Object.freeze({ + 'JOINED': 1, + 'CLOSED': 2, + 'ERROR': 3 +}) + +const chats = ({ credentials }) => { + return fetch(PLEROMA_CHATS_URL, { headers: authHeaders(credentials) }) + .then((data) => data.json()) + .then((data) => { + return { chats: data.map(parseChat).filter(c => c) } + }) +} + +const getOrCreateChat = ({ accountId, credentials }) => { + return promisedRequest({ + url: PLEROMA_CHAT_URL(accountId), + method: 'POST', + credentials + }) +} + +const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => { + let url = PLEROMA_CHAT_MESSAGES_URL(id) + const args = [ + maxId && `max_id=${maxId}`, + sinceId && `since_id=${sinceId}`, + limit && `limit=${limit}` + ].filter(_ => _).join('&') + + url = url + (args ? '?' + args : '') + + return promisedRequest({ + url, + method: 'GET', + credentials + }) +} + +const sendChatMessage = ({ id, content, mediaId = null, credentials }) => { + const payload = { + 'content': content + } + + if (mediaId) { + payload['media_id'] = mediaId + } + + return promisedRequest({ + url: PLEROMA_CHAT_MESSAGES_URL(id), + method: 'POST', + payload: payload, + credentials + }) +} + +const readChat = ({ id, lastReadId, credentials }) => { + return promisedRequest({ + url: PLEROMA_CHAT_READ_URL(id), + method: 'POST', + payload: { + 'last_read_id': lastReadId + }, + credentials + }) +} + +const deleteChatMessage = ({ chatId, messageId, credentials }) => { + return promisedRequest({ + url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId), + method: 'DELETE', + credentials + }) +} + const apiService = { verifyCredentials, fetchTimeline, @@ -1146,9 +1249,12 @@ const apiService = { unfavorite, retweet, unretweet, + bookmarkStatus, + unbookmarkStatus, postStatus, deleteStatus, uploadMedia, + setMediaDescription, fetchMutes, muteUser, unmuteUser, @@ -1166,10 +1272,8 @@ const apiService = { deactivateUser, register, getCaptcha, - updateAvatar, - updateBg, + updateProfileImages, updateProfile, - updateBanner, importBlocks, importFollows, deleteAccount, @@ -1200,7 +1304,13 @@ const apiService = { fetchKnownDomains, fetchDomainMutes, muteDomain, - unmuteDomain + unmuteDomain, + chats, + getOrCreateChat, + chatMessages, + sendChatMessage, + readChat, + deleteChatMessage } export default apiService diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index e1c32860..45e6bd0e 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -12,10 +12,6 @@ const backendInteractorService = credentials => ({ return notificationsFetcher.startFetching({ store, credentials }) }, - fetchAndUpdateNotifications ({ store }) { - return notificationsFetcher.fetchAndUpdate({ store, credentials }) - }, - startFetchingFollowRequests ({ store }) { return followRequestFetcher.startFetching({ store, credentials }) }, diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js new file mode 100644 index 00000000..b60a889b --- /dev/null +++ b/src/services/chat_service/chat_service.js @@ -0,0 +1,151 @@ +import _ from 'lodash' + +const empty = (chatId) => { + return { + idIndex: {}, + messages: [], + newMessageCount: 0, + lastSeenTimestamp: 0, + chatId: chatId, + minId: undefined, + lastMessage: undefined + } +} + +const clear = (storage) => { + storage.idIndex = {} + storage.messages.splice(0, storage.messages.length) + storage.newMessageCount = 0 + storage.lastSeenTimestamp = 0 + storage.minId = undefined + storage.lastMessage = undefined +} + +const deleteMessage = (storage, messageId) => { + if (!storage) { return } + storage.messages = storage.messages.filter(m => m.id !== messageId) + delete storage.idIndex[messageId] + + if (storage.lastMessage && (storage.lastMessage.id === messageId)) { + storage.lastMessage = _.maxBy(storage.messages, 'id') + } + + if (storage.minId === messageId) { + const firstMessage = _.minBy(storage.messages, 'id') + storage.minId = firstMessage.id + } +} + +const add = (storage, { messages: newMessages }) => { + if (!storage) { return } + for (let i = 0; i < newMessages.length; i++) { + const message = newMessages[i] + + // sanity check + if (message.chat_id !== storage.chatId) { return } + + if (!storage.minId || message.id < storage.minId) { + storage.minId = message.id + } + + if (!storage.lastMessage || message.id > storage.lastMessage.id) { + storage.lastMessage = message + } + + if (!storage.idIndex[message.id]) { + if (storage.lastSeenTimestamp < message.created_at) { + storage.newMessageCount++ + } + storage.messages.push(message) + storage.idIndex[message.id] = message + } + } +} + +const resetNewMessageCount = (storage) => { + if (!storage) { return } + storage.newMessageCount = 0 + storage.lastSeenTimestamp = new Date() +} + +// Inserts date separators and marks the head and tail if it's the chain of messages made by the same user +const getView = (storage) => { + if (!storage) { return [] } + + const result = [] + const messages = _.sortBy(storage.messages, ['id', 'desc']) + const firstMessage = messages[0] + let previousMessage = messages[messages.length - 1] + let currentMessageChainId + + if (firstMessage) { + const date = new Date(firstMessage.created_at) + date.setHours(0, 0, 0, 0) + result.push({ + type: 'date', + date, + id: date.getTime().toString() + }) + } + + let afterDate = false + + for (let i = 0; i < messages.length; i++) { + const message = messages[i] + const nextMessage = messages[i + 1] + + const date = new Date(message.created_at) + date.setHours(0, 0, 0, 0) + + // insert date separator and start a new message chain + if (previousMessage && previousMessage.date < date) { + result.push({ + type: 'date', + date, + id: date.getTime().toString() + }) + + previousMessage['isTail'] = true + currentMessageChainId = undefined + afterDate = true + } + + const object = { + type: 'message', + data: message, + date, + id: message.id, + messageChainId: currentMessageChainId + } + + // end a message chian + if ((nextMessage && nextMessage.account_id) !== message.account_id) { + object['isTail'] = true + currentMessageChainId = undefined + } + + // start a new message chain + if ((previousMessage && previousMessage.data && previousMessage.data.account_id) !== message.account_id || afterDate) { + currentMessageChainId = _.uniqueId() + object['isHead'] = true + object['messageChainId'] = currentMessageChainId + } + + result.push(object) + previousMessage = object + afterDate = false + } + + return result +} + +const ChatService = { + add, + empty, + getView, + deleteMessage, + resetNewMessageCount, + clear +} + +export default ChatService diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index 3bdb92f3..7ea8a16c 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -1,4 +1,5 @@ import escape from 'escape-html' +import parseLinkHeader from 'parse-link-header' import { isStatusNotification } from '../notification_utils/notification_utils.js' const qvitterStatusType = (status) => { @@ -182,6 +183,7 @@ export const parseUser = (data) => { output.deactivated = data.pleroma.deactivated output.notification_settings = data.pleroma.notification_settings + output.unread_chat_count = data.pleroma.unread_chat_count } output.tags = output.tags || [] @@ -232,6 +234,8 @@ export const parseStatus = (data) => { output.repeated = data.reblogged output.repeat_num = data.reblogs_count + output.bookmarked = data.bookmarked + output.type = data.reblog ? 'retweet' : 'status' output.nsfw = data.sensitive @@ -248,6 +252,7 @@ export const parseStatus = (data) => { output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct output.thread_muted = pleroma.thread_muted output.emoji_reactions = pleroma.emoji_reactions + output.parent_visible = pleroma.parent_visible === undefined ? true : pleroma.parent_visible } else { output.text = data.content output.summary = data.spoiler_text @@ -368,7 +373,7 @@ export const parseNotification = (data) => { ? parseStatus(data.notice.favorited_status) : parsedNotice output.action = parsedNotice - output.from_profile = parseUser(data.from_profile) + output.from_profile = output.type === 'pleroma:chat_mention' ? parseUser(data.account) : parseUser(data.from_profile) } output.created_at = new Date(data.created_at) @@ -381,3 +386,47 @@ const isNsfw = (status) => { const nsfwRegex = /#nsfw/i return (status.tags || []).includes('nsfw') || !!(status.text || '').match(nsfwRegex) } + +export const parseLinkHeaderPagination = (linkHeader, opts = {}) => { + const flakeId = opts.flakeId + const parsedLinkHeader = parseLinkHeader(linkHeader) + if (!parsedLinkHeader) return + const maxId = parsedLinkHeader.next.max_id + const minId = parsedLinkHeader.prev.min_id + + return { + maxId: flakeId ? maxId : parseInt(maxId, 10), + minId: flakeId ? minId : parseInt(minId, 10) + } +} + +export const parseChat = (chat) => { + const output = {} + output.id = chat.id + output.account = parseUser(chat.account) + output.unread = chat.unread + output.lastMessage = parseChatMessage(chat.last_message) + output.updated_at = new Date(chat.updated_at) + return output +} + +export const parseChatMessage = (message) => { + if (!message) { return } + if (message.isNormalized) { return message } + const output = message + output.id = message.id + output.created_at = new Date(message.created_at) + output.chat_id = message.chat_id + if (message.content) { + output.content = addEmojis(message.content, message.emojis) + } else { + output.content = '' + } + if (message.attachment) { + output.attachments = [parseAttachment(message.attachment)] + } else { + output.attachments = [] + } + output.isNormalized = true + return output +} diff --git a/src/services/follow_request_fetcher/follow_request_fetcher.service.js b/src/services/follow_request_fetcher/follow_request_fetcher.service.js index 786740b7..93fac9bc 100644 --- a/src/services/follow_request_fetcher/follow_request_fetcher.service.js +++ b/src/services/follow_request_fetcher/follow_request_fetcher.service.js @@ -4,6 +4,7 @@ const fetchAndUpdate = ({ store, credentials }) => { return apiService.fetchFollowRequests({ credentials }) .then((requests) => { store.commit('setFollowRequests', requests) + store.commit('addNewUsers', requests) }, () => {}) .catch(() => {}) } diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js index 64499a1b..d282074a 100644 --- a/src/services/notifications_fetcher/notifications_fetcher.service.js +++ b/src/services/notifications_fetcher/notifications_fetcher.service.js @@ -27,21 +27,25 @@ const fetchAndUpdate = ({ store, credentials, older = false }) => { } const result = fetchNotifications({ store, args, older }) - // load unread notifications repeatedly to provide consistency between browser tabs + // If there's any unread notifications, try fetch notifications since + // the newest read notification to check if any of the unread notifs + // have changed their 'seen' state (marked as read in another session), so + // we can update the state in this session to mark them as read as well. + // The normal maxId-check does not tell if older notifications have changed const notifications = timelineData.data const readNotifsIds = notifications.filter(n => n.seen).map(n => n.id) - if (readNotifsIds.length) { + const numUnseenNotifs = notifications.length - readNotifsIds.length + if (numUnseenNotifs > 0) { args['since'] = Math.max(...readNotifsIds) fetchNotifications({ store, args, older }) } - return result } } const fetchNotifications = ({ store, args, older }) => { return apiService.fetchTimeline(args) - .then((notifications) => { + .then(({ data: notifications }) => { update({ store, notifications, older }) return notifications }, () => store.dispatch('setNotificationsError', { value: true })) diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js index 9e904d3a..ac469175 100644 --- a/src/services/status_poster/status_poster.service.js +++ b/src/services/status_poster/status_poster.service.js @@ -1,7 +1,18 @@ import { map } from 'lodash' import apiService from '../api/api.service.js' -const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, media = [], inReplyToStatusId = undefined, contentType = 'text/plain' }) => { +const postStatus = ({ + store, + status, + spoilerText, + visibility, + sensitive, + poll, + media = [], + inReplyToStatusId = undefined, + contentType = 'text/plain', + preview = false +}) => { const mediaIds = map(media, 'id') return apiService.postStatus({ @@ -13,9 +24,11 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, m mediaIds, inReplyToStatusId, contentType, - poll }) + poll, + preview + }) .then((data) => { - if (!data.error) { + if (!data.error && !preview) { store.dispatch('addNewStatuses', { statuses: [data], timeline: 'friends', @@ -34,13 +47,18 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, m const uploadMedia = ({ store, formData }) => { const credentials = store.state.users.currentUser.credentials - return apiService.uploadMedia({ credentials, formData }) } +const setMediaDescription = ({ store, id, description }) => { + const credentials = store.state.users.currentUser.credentials + return apiService.setMediaDescription({ credentials, id, description }) +} + const statusPosterService = { postStatus, - uploadMedia + uploadMedia, + setMediaDescription } export default statusPosterService diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js index fbdcf562..07425abd 100644 --- a/src/services/style_setter/style_setter.js +++ b/src/services/style_setter/style_setter.js @@ -106,7 +106,8 @@ export const generateRadii = (input) => { avatar: 5, avatarAlt: 50, tooltip: 2, - attachment: 5 + attachment: 5, + chatMessage: inputRadii.panel }) return { diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js index b577cfab..b58ca9be 100644 --- a/src/services/theme_data/pleromafe.js +++ b/src/services/theme_data/pleromafe.js @@ -23,7 +23,9 @@ export const LAYERS = { inputTopBar: 'topBar', alert: 'bg', alertPanel: 'panel', - poll: 'bg' + poll: 'bg', + chatBg: 'underlay', + chatMessage: 'chatBg' } /* By default opacity slots have 1 as default opacity @@ -34,7 +36,8 @@ export const DEFAULT_OPACITY = { alert: 0.5, input: 0.5, faint: 0.5, - underlay: 0.15 + underlay: 0.15, + alertPopup: 0.95 } /** SUBJECT TO CHANGE IN THE FUTURE, this is all beta @@ -627,11 +630,93 @@ export const SLOT_INHERITANCE = { textColor: true }, + alertPopupError: { + depends: ['alertError'], + opacity: 'alertPopup' + }, + alertPopupErrorText: { + depends: ['alertErrorText'], + layer: 'popover', + variant: 'alertPopupError', + textColor: true + }, + + alertPopupWarning: { + depends: ['alertWarning'], + opacity: 'alertPopup' + }, + alertPopupWarningText: { + depends: ['alertWarningText'], + layer: 'popover', + variant: 'alertPopupWarning', + textColor: true + }, + + alertPopupNeutral: { + depends: ['alertNeutral'], + opacity: 'alertPopup' + }, + alertPopupNeutralText: { + depends: ['alertNeutralText'], + layer: 'popover', + variant: 'alertPopupNeutral', + textColor: true + }, + badgeNotification: '--cRed', badgeNotificationText: { depends: ['text', 'badgeNotification'], layer: 'badge', variant: 'badgeNotification', textColor: 'bw' + }, + + chatBg: { + depends: ['bg'] + }, + + chatMessage: { + depends: ['chatBg'] + }, + + chatMessageIncomingBg: { + depends: ['chatMessage'], + layer: 'chatMessage' + }, + + chatMessageIncomingText: { + depends: ['text'], + layer: 'text' + }, + + chatMessageIncomingLink: { + depends: ['link'], + layer: 'link' + }, + + chatMessageIncomingBorder: { + depends: ['border'], + opacity: 'border', + color: (mod, border) => brightness(2 * mod, border).rgb + }, + + chatMessageOutgoingBg: { + depends: ['chatMessage'], + color: (mod, chatMessage) => brightness(5 * mod, chatMessage).rgb + }, + + chatMessageOutgoingText: { + depends: ['text'], + layer: 'text' + }, + + chatMessageOutgoingLink: { + depends: ['link'], + layer: 'link' + }, + + chatMessageOutgoingBorder: { + depends: ['chatMessage'], + opacity: 'chatMessage' } } diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js index c6b28ad5..214294eb 100644 --- a/src/services/timeline_fetcher/timeline_fetcher.service.js +++ b/src/services/timeline_fetcher/timeline_fetcher.service.js @@ -2,7 +2,7 @@ import { camelCase } from 'lodash' import apiService from '../api/api.service.js' -const update = ({ store, statuses, timeline, showImmediately, userId }) => { +const update = ({ store, statuses, timeline, showImmediately, userId, pagination }) => { const ccTimeline = camelCase(timeline) store.dispatch('setError', { value: false }) @@ -12,7 +12,8 @@ const update = ({ store, statuses, timeline, showImmediately, userId }) => { timeline: ccTimeline, userId, statuses, - showImmediately + showImmediately, + pagination }) } @@ -30,7 +31,8 @@ const fetchAndUpdate = ({ const rootState = store.rootState || store.state const { getters } = store const timelineData = rootState.statuses.timelines[camelCase(timeline)] - const hideMutedPosts = getters.mergedConfig.hideMutedPosts + const { hideMutedPosts, replyVisibility } = getters.mergedConfig + const loggedIn = !!rootState.users.currentUser if (older) { args['until'] = until || timelineData.minId @@ -41,20 +43,23 @@ const fetchAndUpdate = ({ args['userId'] = userId args['tag'] = tag args['withMuted'] = !hideMutedPosts + if (loggedIn) args['replyVisibility'] = replyVisibility const numStatusesBeforeFetch = timelineData.statuses.length return apiService.fetchTimeline(args) - .then((statuses) => { - if (statuses.error) { - store.dispatch('setErrorData', { value: statuses }) + .then(response => { + if (response.error) { + store.dispatch('setErrorData', { value: response }) return } + + const { data: statuses, pagination } = response if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) { store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId }) } - update({ store, statuses, timeline, showImmediately, userId }) - return statuses + update({ store, statuses, timeline, showImmediately, userId, pagination }) + return { statuses, pagination } }, () => store.dispatch('setError', { value: true })) } diff --git a/src/services/window_utils/window_utils.js b/src/services/window_utils/window_utils.js index faff6cb9..909088db 100644 --- a/src/services/window_utils/window_utils.js +++ b/src/services/window_utils/window_utils.js @@ -3,3 +3,8 @@ export const windowWidth = () => window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth + +export const windowHeight = () => + window.innerHeight || + document.documentElement.clientHeight || + document.body.clientHeight diff --git a/static/fontello.json b/static/fontello.json index ac3f0a18..706800cd 100755..100644 --- a/static/fontello.json +++ b/static/fontello.json @@ -375,6 +375,36 @@ "css": "download", "code": 59429, "src": "fontawesome" + }, + { + "uid": "f04a5d24e9e659145b966739c4fde82a", + "css": "bookmark", + "code": 59430, + "src": "fontawesome" + }, + { + "uid": "2f5ef6f6b7aaebc56458ab4e865beff5", + "css": "bookmark-empty", + "code": 61591, + "src": "fontawesome" + }, + { + "uid": "9ea0a737ccc45d6c510dcbae56058849", + "css": "music", + "code": 59432, + "src": "fontawesome" + }, + { + "uid": "1b5a5d7b7e3c71437f5a26befdd045ed", + "css": "doc", + "code": 59433, + "src": "fontawesome" + }, + { + "uid": "98d9c83c1ee7c2c25af784b518c522c5", + "css": "block", + "code": 59434, + "src": "fontawesome" } ] }
\ No newline at end of file diff --git a/static/terms-of-service.html b/static/terms-of-service.html index b2c66815..3b6bbb36 100644 --- a/static/terms-of-service.html +++ b/static/terms-of-service.html @@ -2,7 +2,7 @@ <p>This is the default placeholder ToS. You should copy it over to your static folder and edit it to fit the needs of your instance.</p> -<p>To do so, place a file at <code>"/instance/static/terms-of-service.html"</code> in your +<p>To do so, place a file at <code>"/instance/static/static/terms-of-service.html"</code> in your Pleroma install containing the real ToS for your instance.</p> <p>See the <a href='https://docs.pleroma.social/backend/configuration/static_dir/'>Pleroma documentation</a> for more information.</p> <br> diff --git a/test/unit/specs/boot/routes.spec.js b/test/unit/specs/boot/routes.spec.js index a415aeaf..3673256f 100644 --- a/test/unit/specs/boot/routes.spec.js +++ b/test/unit/specs/boot/routes.spec.js @@ -1,14 +1,22 @@ +import Vuex from 'vuex' import routes from 'src/boot/routes' import { createLocalVue } from '@vue/test-utils' import VueRouter from 'vue-router' const localVue = createLocalVue() +localVue.use(Vuex) localVue.use(VueRouter) +const store = new Vuex.Store({ + state: { + instance: {} + } +}) + describe('routes', () => { const router = new VueRouter({ mode: 'abstract', - routes: routes({}) + routes: routes(store) }) it('root path', () => { diff --git a/test/unit/specs/modules/users.spec.js b/test/unit/specs/modules/users.spec.js index 670acfc8..dfa5684d 100644 --- a/test/unit/specs/modules/users.spec.js +++ b/test/unit/specs/modules/users.spec.js @@ -18,6 +18,42 @@ describe('The users module', () => { expect(state.users).to.eql([user]) expect(state.users[0].name).to.eql('Dude') }) + + it('merging array field in new information for old users', () => { + const state = cloneDeep(defaultState) + const user = { + id: '1', + fields: [ + { name: 'Label 1', value: 'Content 1' } + ] + } + const firstModUser = { + id: '1', + fields: [ + { name: 'Label 2', value: 'Content 2' }, + { name: 'Label 3', value: 'Content 3' } + ] + } + const secondModUser = { + id: '1', + fields: [ + { name: 'Label 4', value: 'Content 4' } + ] + } + + mutations.addNewUsers(state, [user]) + expect(state.users[0].fields).to.have.length(1) + expect(state.users[0].fields[0].name).to.eql('Label 1') + + mutations.addNewUsers(state, [firstModUser]) + expect(state.users[0].fields).to.have.length(2) + expect(state.users[0].fields[0].name).to.eql('Label 2') + expect(state.users[0].fields[1].name).to.eql('Label 3') + + mutations.addNewUsers(state, [secondModUser]) + expect(state.users[0].fields).to.have.length(1) + expect(state.users[0].fields[0].name).to.eql('Label 4') + }) }) describe('findUser', () => { diff --git a/test/unit/specs/services/chat_service/chat_service.spec.js b/test/unit/specs/services/chat_service/chat_service.spec.js new file mode 100644 index 00000000..4e8e566b --- /dev/null +++ b/test/unit/specs/services/chat_service/chat_service.spec.js @@ -0,0 +1,89 @@ +import chatService from '../../../../../src/services/chat_service/chat_service.js' + +const message1 = { + id: '9wLkdcmQXD21Oy8lEX', + created_at: (new Date('2020-06-22T18:45:53.000Z')) +} + +const message2 = { + id: '9wLkdp6ihaOVdNj8Wu', + account_id: '9vmRb29zLQReckr5ay', + created_at: (new Date('2020-06-22T18:45:56.000Z')) +} + +const message3 = { + id: '9wLke9zL4Dy4OZR2RM', + account_id: '9vmRb29zLQReckr5ay', + created_at: (new Date('2020-07-22T18:45:59.000Z')) +} + +// TODO: only +describe.only('chatService', () => { + describe('.add', () => { + it("Doesn't add duplicates", () => { + const chat = chatService.empty() + chatService.add(chat, { messages: [ message1 ] }) + chatService.add(chat, { messages: [ message1 ] }) + expect(chat.messages.length).to.eql(1) + + chatService.add(chat, { messages: [ message2 ] }) + expect(chat.messages.length).to.eql(2) + }) + + it('Updates minId and lastMessage and newMessageCount', () => { + const chat = chatService.empty() + + chatService.add(chat, { messages: [ message1 ] }) + expect(chat.lastMessage.id).to.eql(message1.id) + expect(chat.minId).to.eql(message1.id) + expect(chat.newMessageCount).to.eql(1) + + chatService.add(chat, { messages: [ message2 ] }) + expect(chat.lastMessage.id).to.eql(message2.id) + expect(chat.minId).to.eql(message1.id) + expect(chat.newMessageCount).to.eql(2) + + chatService.resetNewMessageCount(chat) + expect(chat.newMessageCount).to.eql(0) + + const createdAt = new Date() + createdAt.setSeconds(createdAt.getSeconds() + 10) + chatService.add(chat, { messages: [ { message3, created_at: createdAt } ] }) + expect(chat.newMessageCount).to.eql(1) + }) + }) + + describe('.delete', () => { + it('Updates minId and lastMessage', () => { + const chat = chatService.empty() + + chatService.add(chat, { messages: [ message1 ] }) + chatService.add(chat, { messages: [ message2 ] }) + chatService.add(chat, { messages: [ message3 ] }) + + expect(chat.lastMessage.id).to.eql(message3.id) + expect(chat.minId).to.eql(message1.id) + + chatService.deleteMessage(chat, message3.id) + expect(chat.lastMessage.id).to.eql(message2.id) + expect(chat.minId).to.eql(message1.id) + + chatService.deleteMessage(chat, message1.id) + expect(chat.lastMessage.id).to.eql(message2.id) + expect(chat.minId).to.eql(message2.id) + }) + }) + + describe('.getView', () => { + it('Inserts date separators', () => { + const chat = chatService.empty() + + chatService.add(chat, { messages: [ message1 ] }) + chatService.add(chat, { messages: [ message2 ] }) + chatService.add(chat, { messages: [ message3 ] }) + + const view = chatService.getView(chat) + expect(view.map(i => i.type)).to.eql(['date', 'message', 'message', 'date', 'message']) + }) + }) +}) diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js index ccb57942..e1f7a958 100644 --- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js +++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js @@ -1,4 +1,4 @@ -import { parseStatus, parseUser, parseNotification, addEmojis } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js' +import { parseStatus, parseUser, parseNotification, addEmojis, parseLinkHeaderPagination } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js' import mastoapidata from '../../../../fixtures/mastoapi.json' import qvitterapidata from '../../../../fixtures/statuses.json' @@ -383,4 +383,24 @@ describe('API Entities normalizer', () => { expect(result).to.include('title=\':[a-z] {|}*:\'') }) }) + + describe('Link header pagination', () => { + it('Parses min and max ids as integers', () => { + const linkHeader = '<https://example.com/api/v1/notifications?max_id=861676>; rel="next", <https://example.com/api/v1/notifications?min_id=861741>; rel="prev"' + const result = parseLinkHeaderPagination(linkHeader) + expect(result).to.eql({ + 'maxId': 861676, + 'minId': 861741 + }) + }) + + it('Parses min and max ids as flakes', () => { + const linkHeader = '<http://example.com/api/v1/timelines/home?max_id=9waQx5IIS48qVue2Ai>; rel="next", <http://example.com/api/v1/timelines/home?min_id=9wi61nIPnfn674xgie>; rel="prev"' + const result = parseLinkHeaderPagination(linkHeader, { flakeId: true }) + expect(result).to.eql({ + 'maxId': '9waQx5IIS48qVue2Ai', + 'minId': '9wi61nIPnfn674xgie' + }) + }) + }) }) @@ -5751,6 +5751,13 @@ parse-json@^4.0.0: error-ex "^1.3.1" json-parse-better-errors "^1.0.1" +parse-link-header@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parse-link-header/-/parse-link-header-1.0.1.tgz#bedfe0d2118aeb84be75e7b025419ec8a61140a7" + integrity sha1-vt/g0hGK64S+deewJUGeyKYRQKc= + dependencies: + xtend "~4.0.1" + parseqs@0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d" |
