diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 7f6d3c924..85d3ee446 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,7 +1,7 @@
# This file is a template, and might need editing before it works on your project.
# Official framework image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/node/tags/
-image: node:8
+image: node:10
stages:
- lint
@@ -14,6 +14,7 @@ lint:
script:
- yarn
- npm run lint
+ - npm run stylelint
test:
stage: test
diff --git a/.stylelintrc.json b/.stylelintrc.json
new file mode 100644
index 000000000..fbf3a245c
--- /dev/null
+++ b/.stylelintrc.json
@@ -0,0 +1,19 @@
+{
+ "extends": [
+ "stylelint-rscss/config",
+ "stylelint-config-recommended",
+ "stylelint-config-standard"
+ ],
+ "rules": {
+ "declaration-no-important": true,
+ "rscss/no-descendant-combinator": false,
+ "rscss/class-format": [
+ true,
+ {
+ "component": "pascal-case",
+ "variant": "^-[a-z]\\w+",
+ "element": "^[a-z]\\w+"
+ }
+ ]
+ }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 49ec550cd..86d1a97f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Greentext now has separate color slot for it
- Removed the use of with_move parameters when fetching notifications
- Push notifications now are the same as normal notfication, and are localized.
+- Updated Notification Settings to match new BE API
### Fixed
- Weird bug related to post being sent seemingly after pasting with keyboard (hopefully)
@@ -23,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- 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
+- Added ability to see all favoriting or repeating users when hovering the number on highlighted statuses
### Changed
- Registration page no longer requires email if the server is configured not to require it
@@ -31,6 +33,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- 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
+- Remove unnecessary options for 'automatic loading when loading older' and 'reply previews'
### Fixed
- Custom Emoji will display in poll options now.
@@ -48,6 +51,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- 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
+- Add status idempotency to prevent accidental double posting when posting returns an error
## [2.0.3] - 2020-05-02
### Fixed
diff --git a/README.md b/README.md
index b66383ade..54529a705 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
> A single column frontend designed for Pleroma.
-
+
# For Translators
diff --git a/package.json b/package.json
index 962311719..75d9ee56c 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"unit:watch": "karma start test/unit/karma.conf.js --single-run=false",
"e2e": "node test/e2e/runner.js",
"test": "npm run unit && npm run e2e",
+ "stylelint": "npx stylelint src/components/status/status.scss",
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs",
"lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
},
@@ -22,8 +23,8 @@
"cropperjs": "^1.4.3",
"diff": "^3.0.1",
"escape-html": "^1.0.3",
- "parse-link-header": "^1.0.1",
"localforage": "^1.5.0",
+ "parse-link-header": "^1.0.1",
"phoenix": "^1.3.0",
"portal-vue": "^2.1.4",
"v-click-outside": "^2.1.1",
@@ -36,7 +37,6 @@
"vuex": "^3.0.1"
},
"devDependencies": {
- "karma-mocha-reporter": "^2.2.1",
"@babel/core": "^7.7.5",
"@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.6",
@@ -80,6 +80,7 @@
"karma-coverage": "^1.1.1",
"karma-firefox-launcher": "^1.1.0",
"karma-mocha": "^1.2.0",
+ "karma-mocha-reporter": "^2.2.1",
"karma-sinon-chai": "^2.0.2",
"karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "0.0.26",
@@ -101,6 +102,9 @@
"shelljs": "^0.7.4",
"sinon": "^2.1.0",
"sinon-chai": "^2.8.0",
+ "stylelint": "^13.6.1",
+ "stylelint-config-standard": "^20.0.0",
+ "stylelint-rscss": "^0.4.0",
"url-loader": "^1.1.2",
"vue-loader": "^14.0.0",
"vue-style-loader": "^4.0.0",
diff --git a/src/App.js b/src/App.js
index 92c4e2f58..ded772fad 100644
--- a/src/App.js
+++ b/src/App.js
@@ -14,7 +14,7 @@ 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 GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
-import { windowWidth } from './services/window_utils/window_utils'
+import { windowWidth, windowHeight } from './services/window_utils/window_utils'
export default {
name: 'app',
@@ -127,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 6597b6f41..e2e2d079c 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 {
@@ -928,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 03b632ecc..0276c6a60 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -77,6 +77,7 @@
+
-
-
-
+
diff --git a/src/_variables.scss b/src/_variables.scss
index 30dc3e42e..9004d551a 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 302b278c5..00ca74a21 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -20,12 +20,20 @@ const parsedInitialResults = () => {
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 requestData = JSON.parse(atob(data[request]))
+ const decoded = decodeUTF8Base64(data[request])
+ const requestData = JSON.parse(decoded)
return {
ok: true,
json: () => requestData,
@@ -230,6 +238,7 @@ const getNodeInfo = async ({ store }) => {
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 })
diff --git a/src/boot/routes.js b/src/boot/routes.js
index f63d8adfa..b5d3c6315 100644
--- a/src/boot/routes.js
+++ b/src/boot/routes.js
@@ -6,6 +6,8 @@ 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'
@@ -28,7 +30,7 @@ export default (store) => {
}
}
- return [
+ let routes = [
{ name: 'root',
path: '/',
redirect: _to => {
@@ -62,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 0826c2758..6d345bc7a 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 029e70968..987e94b74 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') }}
+
+ {{ $t('user_card.message') }}
+
{
+ 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 000000000..012a1b1d7
--- /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 000000000..2e4538c84
--- /dev/null
+++ b/src/components/chat/chat.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
diff --git a/src/components/chat/chat_layout_utils.js b/src/components/chat/chat_layout_utils.js
new file mode 100644
index 000000000..609dc0c9b
--- /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 000000000..95708d1dd
--- /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 000000000..17e2f7950
--- /dev/null
+++ b/src/components/chat_list/chat_list.vue
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+ {{ $t("chats.chats") }}
+
+
+ {{ $t("chats.new") }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('chats.empty_chat_list_placeholder') }}
+
+
+
+
+
+
+
+
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 000000000..bee1ad535
--- /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 ? `${this.$t('chats.you')} ${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 000000000..9e97b28ea
--- /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 {
+ border-radius: $fallback--avatarAltRadius;
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ }
+
+ .StatusContent {
+ 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 000000000..1f8ecdf6a
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ chat.unread }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
new file mode 100644
index 000000000..be4a7c89d
--- /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 000000000..7d4ff60cf
--- /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 000000000..e923d6944
--- /dev/null
+++ b/src/components/chat_message/chat_message.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ createdAt }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 000000000..79c346b61
--- /dev/null
+++ b/src/components/chat_message_date/chat_message_date.vue
@@ -0,0 +1,24 @@
+
+
+ {{ displayDate }}
+
+
+
+
diff --git a/src/components/chat_new/chat_new.js b/src/components/chat_new/chat_new.js
new file mode 100644
index 000000000..d023efc07
--- /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 000000000..113054443
--- /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 000000000..3333dbf9e
--- /dev/null
+++ b/src/components/chat_new/chat_new.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/chat_panel/chat_panel.vue
index 3677722ff..ca529b5ab 100644
--- a/src/components/chat_panel/chat_panel.vue
+++ b/src/components/chat_panel/chat_panel.vue
@@ -10,7 +10,7 @@
@click.stop.prevent="togglePanel"
>
-
{{ $t('chat.title') }}
+
{{ $t('shoutbox.title') }}
- {{ $t('chat.title') }}
+ {{ $t('shoutbox.title') }}
@@ -84,54 +84,56 @@
max-width: 25em;
}
-.chat-heading {
- cursor: pointer;
- .icon-comment-empty {
- color: $fallback--text;
- color: var(--text, $fallback--text);
- }
-}
-
-.chat-window {
- overflow-y: auto;
- overflow-x: hidden;
- max-height: 20em;
-}
-
-.chat-window-container {
- height: 100%;
-}
-
-.chat-message {
- display: flex;
- padding: 0.2em 0.5em
-}
-
-.chat-avatar {
- img {
- height: 24px;
- width: 24px;
- border-radius: $fallback--avatarRadius;
- border-radius: var(--avatarRadius, $fallback--avatarRadius);
- margin-right: 0.5em;
- margin-top: 0.25em;
- }
-}
-
-.chat-input {
- display: flex;
- textarea {
- flex: 1;
- margin: 0.6em;
- min-height: 3.5em;
- resize: none;
- }
-}
-
.chat-panel {
- .title {
+ .chat-heading {
+ cursor: pointer;
+ .icon-comment-empty {
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+ }
+
+ .chat-window {
+ overflow-y: auto;
+ overflow-x: hidden;
+ max-height: 20em;
+ }
+
+ .chat-window-container {
+ height: 100%;
+ }
+
+ .chat-message {
display: flex;
- justify-content: space-between;
+ padding: 0.2em 0.5em
+ }
+
+ .chat-avatar {
+ img {
+ height: 24px;
+ width: 24px;
+ border-radius: $fallback--avatarRadius;
+ border-radius: var(--avatarRadius, $fallback--avatarRadius);
+ margin-right: 0.5em;
+ margin-top: 0.25em;
+ }
+ }
+
+ .chat-input {
+ display: flex;
+ textarea {
+ flex: 1;
+ margin: 0.6em;
+ min-height: 3.5em;
+ resize: none;
+ }
+ }
+
+ .chat-panel {
+ .title {
+ display: flex;
+ justify-content: space-between;
+ }
}
}
diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js
new file mode 100644
index 000000000..e424bb1f0
--- /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 000000000..b16ed39d7
--- /dev/null
+++ b/src/components/chat_title/chat_title.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue
index 03375b2fa..d28c2cfde 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/conversation/conversation.vue b/src/components/conversation/conversation.vue
index d7b54699a..75df263ee 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -2,8 +2,8 @@
@@ -47,14 +47,27 @@
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index d8a327b05..d951e2a87 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 {
@@ -51,18 +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
},
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 b1a3ad704..c6b2a5b56 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -39,7 +39,7 @@
word-wrap: break-word;
word-break: break-word;
- &:hover .animated.avatar {
+ &:hover .animated.Avatar {
canvas {
display: none;
}
@@ -60,16 +60,8 @@
height: 32px;
}
- .status-body {
- color: $fallback--faint;
- color: var(--faint, $fallback--faint);
- a {
- color: var(--faintLink);
- }
- .status-content a {
- color: var(--postFaintLink);
- }
- }
+ --link: var(--faintLink);
+ --text: var(--faint);
}
.follow-request-accept {
@@ -106,7 +98,8 @@
}
}
- .status-el {
+ /* TODO cleanup this */
+ .Status {
flex: 1;
}
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
index adbb05558..1858f3e19 100644
--- a/src/components/poll/poll.vue
+++ b/src/components/poll/poll.vue
@@ -17,7 +17,7 @@
{{ percentageForOption(option.votes_count) }}%
-
+
2) {
this.options.splice(index, 1)
+ this.updatePollToParent()
}
},
convertExpiryToUnit (unit, amount) {
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js
index a40a91952..695f73b9c 100644
--- a/src/components/popover/popover.js
+++ b/src/components/popover/popover.js
@@ -18,7 +18,9 @@ const Popover = {
// Takes a x/y object and tells how many pixels to offset from
// anchor point on either axis
offset: Object,
- // Additional styles you may want for the popover container
+ // Replaces the classes you may want for the popover container.
+ // Use 'popover-default' in addition to get the default popover
+ // styles with your custom class.
popoverClass: String
},
data () {
@@ -106,7 +108,7 @@ const Popover = {
// single translate or translate3d resulted in blurry text.
this.styles = {
opacity: 1,
- transform: `translateX(${Math.floor(translateX)}px) translateY(${Math.floor(translateY)}px)`
+ transform: `translateX(${Math.round(translateX)}px) translateY(${Math.round(translateY)}px)`
}
},
showPopover () {
diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue
index a271cb1b9..5c99c509e 100644
--- a/src/components/popover/popover.vue
+++ b/src/components/popover/popover.vue
@@ -14,7 +14,7 @@
ref="content"
:style="styles"
class="popover"
- :class="popoverClass"
+ :class="popoverClass || 'popover-default'"
>
{
@@ -27,13 +27,34 @@ const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
}
+// Converts a string with px to a number like '2px' -> 2
+const pxStringToNumber = (str) => {
+ return Number(str.substring(0, str.length - 2))
+}
+
const PostStatusForm = {
props: [
'replyTo',
'repliedUser',
'attentions',
'copyMessageScope',
- 'subject'
+ 'subject',
+ 'disableSubject',
+ 'disableScopeSelector',
+ 'disableNotice',
+ 'disableLockWarning',
+ 'disablePolls',
+ 'disableSensitivityCheckbox',
+ 'disableSubmit',
+ 'disablePreview',
+ 'placeholder',
+ 'maxHeight',
+ 'postHandler',
+ 'preserveFocus',
+ 'autoFocus',
+ 'fileLimit',
+ 'submitOnEnter',
+ 'emojiPickerPlacement'
],
components: {
MediaUpload,
@@ -45,11 +66,15 @@ const PostStatusForm = {
StatusContent
},
mounted () {
+ this.updateIdempotencyKey()
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()
}
},
@@ -72,7 +97,7 @@ const PostStatusForm = {
return {
dropFiles: [],
- submitDisabled: false,
+ uploadingFiles: false,
error: null,
posting: false,
highlighted: 0,
@@ -91,7 +116,9 @@ const PostStatusForm = {
showDropIcon: 'hide',
dropStopTimeout: null,
preview: null,
- previewLoading: false
+ previewLoading: false,
+ emojiInputShown: false,
+ idempotencyKey: ''
}
},
computed: {
@@ -160,10 +187,11 @@ 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 &&
@@ -171,25 +199,66 @@ const PostStatusForm = {
this.newStatus.poll.error
},
showPreview () {
- return !!this.preview || this.previewLoading
+ return !this.disablePreview && (!!this.preview || this.previewLoading)
},
emptyStatus () {
return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
},
- ...mapGetters(['mergedConfig'])
+ 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()
+ 'newStatus': {
+ deep: true,
+ handler () {
+ this.statusChanged()
+ }
}
},
methods: {
- async postStatus (newStatus) {
+ statusChanged () {
+ this.autoPreview()
+ this.updateIdempotencyKey()
+ },
+ clearStatus () {
+ const newStatus = this.newStatus
+ this.newStatus = {
+ status: '',
+ spoilerText: '',
+ files: [],
+ visibility: newStatus.visibility,
+ contentType: newStatus.contentType,
+ poll: {},
+ mediaDescriptions: {}
+ }
+ this.pollFormVisible = false
+ this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
+ this.clearPollForm()
+ 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()
+ },
+ 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.emptyStatus) {
this.error = this.$t('post_status.empty_status_error')
return
@@ -211,7 +280,7 @@ const PostStatusForm = {
return
}
- const data = await statusPoster.postStatus({
+ const postingOptions = {
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
@@ -220,33 +289,21 @@ const PostStatusForm = {
store: this.$store,
inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType,
- poll
- })
-
- if (!data.error) {
- this.newStatus = {
- status: '',
- spoilerText: '',
- files: [],
- visibility: newStatus.visibility,
- contentType: newStatus.contentType,
- poll: {},
- mediaDescriptions: {}
- }
- this.pollFormVisible = false
- this.$refs.mediaUpload.clearFile()
- this.clearPollForm()
- this.$emit('posted')
- 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
+ poll,
+ idempotencyKey: this.idempotencyKey
}
- this.posting = false
+ const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus
+
+ postHandler(postingOptions).then((data) => {
+ if (!data.error) {
+ this.clearStatus()
+ this.$emit('posted', data)
+ } else {
+ this.error = data.error
+ }
+ this.posting = false
+ })
},
previewStatus () {
if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
@@ -301,20 +358,23 @@ const PostStatusForm = {
},
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)
@@ -348,14 +408,13 @@ 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'])
})
@@ -367,6 +426,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
}
@@ -378,7 +438,7 @@ const PostStatusForm = {
* scroll is different for `Window` and `Element`s
*/
const bottomBottomPaddingStr = window.getComputedStyle(bottomRef)['padding-bottom']
- const bottomBottomPadding = Number(bottomBottomPaddingStr.substring(0, bottomBottomPaddingStr.length - 2))
+ const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
const scrollerRef = this.$el.closest('.sidebar-scroller') ||
this.$el.closest('.post-form-modal-view') ||
@@ -387,10 +447,12 @@ const PostStatusForm = {
// Getting info about padding we have to account for, removing 'px' part
const topPaddingStr = window.getComputedStyle(target)['padding-top']
const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
- const topPadding = Number(topPaddingStr.substring(0, topPaddingStr.length - 2))
- const bottomPadding = Number(bottomPaddingStr.substring(0, bottomPaddingStr.length - 2))
+ const topPadding = pxStringToNumber(topPaddingStr)
+ const bottomPadding = pxStringToNumber(bottomPaddingStr)
const vertPadding = topPadding + bottomPadding
+ const oldHeight = pxStringToNumber(target.style.height)
+
/* Explanation:
*
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
@@ -419,8 +481,15 @@ const PostStatusForm = {
// BEGIN content size update
target.style.height = 'auto'
- const newHeight = target.scrollHeight - vertPadding
+ const heightWithoutPadding = Math.floor(target.scrollHeight - vertPadding)
+ let newHeight = this.maxHeight ? Math.min(heightWithoutPadding, this.maxHeight) : heightWithoutPadding
+ // This is a bit of a hack to combat target.scrollHeight being different on every other input
+ // on some browsers for whatever reason. Don't change the height if difference is 1px or less.
+ if (Math.abs(newHeight - oldHeight) <= 1) {
+ newHeight = oldHeight
+ }
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
@@ -480,6 +549,12 @@ const PostStatusForm = {
setAllMediaDescriptions () {
const ids = this.newStatus.files.map(file => file.id)
return Promise.all(ids.map(id => this.setMediaDescription(id)))
+ },
+ handleEmojiInputShow (value) {
+ this.emojiInputShown = value
+ },
+ updateIdempotencyKey () {
+ this.idempotencyKey = Date.now().toString()
}
}
}
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 626584ed0..520c03ea1 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 @@
>