summaryrefslogtreecommitdiffstats
path: root/src/components/Composer/Composer.vue
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/Composer/Composer.vue')
-rw-r--r--src/components/Composer/Composer.vue743
1 files changed, 743 insertions, 0 deletions
diff --git a/src/components/Composer/Composer.vue b/src/components/Composer/Composer.vue
new file mode 100644
index 00000000..7d5685b3
--- /dev/null
+++ b/src/components/Composer/Composer.vue
@@ -0,0 +1,743 @@
+<!--
+ - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
+ - @copyright Copyright (c) 2022 Carl Schwan <carl@carlschwan.eu>
+ -
+ - @author Julius Härtl <jus@bitgrid.net>
+ -
+ - @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+
+<template>
+ <div class="new-post" data-id="">
+ <input id="file-upload"
+ ref="fileUploadInput"
+ @change="handleFileChange($event)"
+ multiple
+ type="file"
+ tabindex="-1"
+ aria-hidden="true"
+ class="hidden-visually">
+ <div class="new-post-author">
+ <avatar :user="currentUser.uid" :display-name="currentUser.displayName" :disable-tooltip="true"
+ :size="32" />
+ <div class="post-author">
+ <span class="post-author-name">
+ {{ currentUser.displayName }}
+ </span>
+ <span class="post-author-id">
+ {{ socialId }}
+ </span>
+ </div>
+ </div>
+ <div v-if="replyTo" class="reply-to">
+ <p>
+ <span>{{ t('social', 'In reply to') }}</span>
+ <actor-avatar :actor="replyTo.actor_info" :size="16" />
+ <strong>{{ replyTo.actor_info.account }}</strong>
+ <a class="icon-close" @click="closeReply()" />
+ </p>
+ <div class="reply-to-preview">
+ {{ replyTo.content }}
+ </div>
+ </div>
+ <form class="new-post-form" @submit.prevent="createPost">
+ <vue-tribute :options="tributeOptions">
+ <!-- eslint-disable-next-line vue/valid-v-model -->
+ <div ref="composerInput" v-contenteditable:post.dangerousHTML="canType && !loading" class="message"
+ placeholder="What would you like to share?" :class="{'icon-loading': loading}" @keyup.prevent.enter="keyup"
+ @tribute-replaced="updatePostFromTribute" />
+ </vue-tribute>
+
+ <PreviewGrid :uploading="false" :uploadProgress="0.4" :miniatures="previewUrls" />
+
+ <div class="options">
+ <Button type="tertiary"
+ @click.prevent="clickImportInput"
+ :aria-label="t('social', 'Add attachment')"
+ v-tooltip="t('social', 'Add attachment')">
+ <template #icon>
+ <FileUpload :size="22" decorative title="" />
+ </template>
+ </Button>
+
+ <div class="new-post-form__emoji-picker">
+ <EmojiPicker ref="emojiPicker" :search="search" :close-on-select="false"
+ :container="container"
+ @select="insert">
+ <Button type="tertiary"
+ :aria-haspopup="true"
+ :aria-label="t('social', 'Add emoji')"
+ v-tooltip="t('social', 'Add emoji')">
+ <template #icon>
+ <EmoticonOutline :size="22" decorative title="" />
+ </template>
+ </Button>
+ </EmojiPicker>
+ </div>
+
+ <div v-click-outside="hidePopoverMenu" class="popovermenu-parent">
+ <Button type="tertiary"
+ :class="currentVisibilityIconClass"
+ @click.prevent="togglePopoverMenu"
+ v-tooltip="t('social', 'Visibility')" />
+ <div :class="{open: menuOpened}" class="popovermenu">
+ <popover-menu :menu="visibilityPopover" />
+ </div>
+ </div>
+
+ <div class="emptySpace" />
+ <Button :value="currentVisibilityPostLabel" :disabled="!canPost" type="primary"
+ @click.prevent="createPost">
+ <template #icon>
+ <Send title="" :size="22" decorative />
+ </template>
+ <template>{{ postTo }}</template>
+ </button>
+ </div>
+ </form>
+ </div>
+</template>
+
+<script>
+
+import EmoticonOutline from 'vue-material-design-icons/EmoticonOutline'
+import Send from 'vue-material-design-icons/Send'
+import FileUpload from 'vue-material-design-icons/FileUpload'
+import Avatar from '@nextcloud/vue/dist/Components/Avatar'
+import Button from '@nextcloud/vue/dist/Components/Button'
+import PopoverMenu from '@nextcloud/vue/dist/Components/PopoverMenu'
+import EmojiPicker from '@nextcloud/vue/dist/Components/EmojiPicker'
+import VueTribute from 'vue-tribute'
+import he from 'he'
+import CurrentUserMixin from '../../mixins/currentUserMixin'
+import FocusOnCreate from '../../directives/focusOnCreate'
+import axios from '@nextcloud/axios'
+import ActorAvatar from '../ActorAvatar.vue'
+import { generateUrl } from '@nextcloud/router'
+import PreviewGrid from './PreviewGrid'
+
+export default {
+ name: 'Composer',
+ components: {
+ PopoverMenu,
+ Avatar,
+ FileUpload,
+ ActorAvatar,
+ EmojiPicker,
+ VueTribute,
+ EmoticonOutline,
+ Button,
+ Send,
+ PreviewGrid,
+ },
+ directives: {
+ FocusOnCreate,
+ },
+ mixins: [CurrentUserMixin],
+ props: {},
+ data() {
+ return {
+ type: localStorage.getItem('social.lastPostType') || 'followers',
+ loading: false,
+ post: '',
+ miniatures: [], // miniatures of images stored in postAttachments
+ postAttachments: [], // The toot's attachments
+ previewUrls: [],
+ canType: true,
+ search: '',
+ replyTo: null,
+ tributeOptions: {
+ spaceSelectsMatch: true,
+ collection: [
+ {
+ trigger: '@',
+ lookup: function(item) {
+ return item.key + item.value
+ },
+ menuItemTemplate: function(item) {
+ return '<img src="' + item.original.avatar + '" /><div>'
+ + '<span class="displayName">' + item.original.key + '</span>'
+ + '<span class="account">' + item.original.value + '</span>'
+ + '</div>'
+ },
+ selectTemplate: function(item) {
+ return '<span class="mention" contenteditable="false">'
+ + '<a href="' + item.original.url + '" target="_blank"><img src="' + item.original.avatar + '" />@' + item.original.value + '</a></span>'
+ },
+ values: (text, cb) => {
+ let users = []
+
+ if (text.length < 1) {
+ cb(users)
+ }
+ this.remoteSearchAccounts(text).then((result) => {
+ for (var i in result.data.result.accounts) {
+ let user = result.data.result.accounts[i]
+ users.push({
+ key: user.preferredUsername,
+ value: user.account,
+ url: user.url,
+ avatar: user.local ? generateUrl(`/avatar/${user.preferredUsername}/32`) : generateUrl(`apps/social/api/v1/global/actor/avatar?id=${user.id}`)
+ })
+ }
+ cb(users)
+ })
+ }
+ },
+ {
+ trigger: '#',
+ menuItemTemplate: function(item) {
+ return item.original.value
+ },
+ selectTemplate: function(item) {
+ let tag = ''
+ // item is undefined if selectTemplate is called from a noMatchTemplate menu
+ if (typeof item === 'undefined') {
+ tag = this.currentMentionTextSnapshot
+ } else {
+ tag = item.original.value
+ }
+ return '<span class="hashtag" contenteditable="false">'
+ + '<a href="' + generateUrl('/timeline/tags/' + tag) + '" target="_blank">#' + tag + '</a></span>'
+ },
+ values: (text, cb) => {
+ let tags = []
+
+ if (text.length < 1) {
+ cb(tags)
+ }
+ this.remoteSearchHashtags(text).then((result) => {
+ if (result.data.result.exact) {
+ tags.push({
+ key: result.data.result.exact,
+ value: result.data.result.exact
+ })
+ }
+ for (var i in result.data.result.tags) {
+ let tag = result.data.result.tags[i]
+ tags.push({
+ key: tag.hashtag,
+ value: tag.hashtag
+ })
+ }
+ cb(tags)
+ })
+ }
+ }
+ ],
+ noMatchTemplate() {
+ if (this.current.collection.trigger === '#') {
+ if (this.current.mentionText === '') {
+ return undefined
+ } else {
+ return '<li data-index="0">#' + this.current.mentionText + '</li>'
+ }
+ }
+ }
+ },
+ menuOpened: false
+
+ }
+ },
+ computed: {
+ postTo() {
+ switch (this.type) {
+ case 'public':
+ case 'unlisted':
+ return t('social', 'Post')
+ case 'followers':
+ return t('social', 'Post to followers')
+ case 'direct':
+ return t('social', 'Post to mentioned users')
+ }
+ },
+ currentVisibilityIconClass() {
+ return this.visibilityIconClass(this.type)
+ },
+ visibilityIconClass() {
+ return (type) => {
+ if (typeof type === 'undefined') {
+ type = this.type
+ }
+ switch (type) {
+ case 'public':
+ return 'icon-link'
+ case 'followers':
+ return 'icon-contacts-dark'
+ case 'direct':
+ return 'icon-external'
+ case 'unlisted':
+ return 'icon-password'
+ }
+ }
+ },
+ currentVisibilityPostLabel() {
+ return this.visibilityPostLabel(this.type)
+ },
+ visibilityPostLabel() {
+ return (type) => {
+ if (typeof type === 'undefined') {
+ type = this.type
+ }
+ switch (type) {
+ case 'public':
+ return t('social', 'Post publicly')
+ case 'followers':
+ return t('social', 'Post to followers')
+ case 'direct':
+ return t('social', 'Post to recipients')
+ case 'unlisted':
+ return t('social', 'Post unlisted')
+ }
+ }
+ },
+ activeState() {
+ return (type) => {
+ if (type === this.type) {
+ return true
+ } else {
+ return false
+ }
+ }
+ },
+ visibilityPopover() {
+ return [
+ {
+ action: () => {
+ this.switchType('public')
+ },
+ icon: this.visibilityIconClass('public'),
+ active: this.activeState('public'),
+ text: t('social', 'Public'),
+ longtext: t('social', 'Post to public timelines')
+ },
+ {
+ action: () => {
+ this.switchType('unlisted')
+ },
+ icon: this.visibilityIconClass('unlisted'),
+ active: this.activeState('unlisted'),
+ text: t('social', 'Unlisted'),
+ longtext: t('social', 'Do not post to public timelines')
+ },
+ {
+ action: () => {
+ this.switchType('followers')
+ },
+ icon: this.visibilityIconClass('followers'),
+ active: this.activeState('followers'),
+ text: t('social', 'Followers'),
+ longtext: t('social', 'Post to followers only')
+ },
+ {
+ action: () => {
+ this.switchType('direct')
+ },
+ icon: this.visibilityIconClass('direct'),
+ active: this.activeState('direct'),
+ text: t('social', 'Direct'),
+ longtext: t('social', 'Post to mentioned users only')
+ }
+ ]
+ },
+ container() {
+ return '#content-vue'
+ },
+ containerElement() {
+ return document.querySelector(this.container)
+ },
+ canPost() {
+ if (this.previewUrls.length > 0) {
+ return true;
+ }
+ return this.post.length !== 0 && this.post !== '<br>'
+ }
+ },
+ mounted() {
+ this.$root.$on('composer-reply', (data) => {
+ this.replyTo = data
+ this.type = 'direct'
+ })
+ },
+ methods: {
+ clickImportInput() {
+ this.$refs.fileUploadInput.click()
+ },
+ handleFileChange(event) {
+ const previewUrl = URL.createObjectURL(event.target.files[0])
+ this.previewUrls.push({
+ description: '',
+ url: previewUrl,
+ result: event.target.files[0],
+ })
+ },
+ removeAttachment(idx) {
+ this.previewUrls.splice(idx, 1)
+ },
+ insert(emoji) {
+ if (typeof emoji === 'object') {
+ let category = Object.keys(emoji)[0]
+ let emojis = emoji[category]
+ let firstEmoji = Object.keys(emojis)[0]
+ emoji = emojis[firstEmoji]
+ }
+ this.post += this.$twemoji.parse(emoji) + ' '
+ this.$refs.composerInput.innerHTML += this.$twemoji.parse(emoji) + ' '
+ this.$refs.emojiPicker.hide()
+ },
+ togglePopoverMenu() {
+ this.menuOpened = !this.menuOpened
+ },
+ hidePopoverMenu() {
+ this.menuOpened = false
+ },
+ switchType(type) {
+ this.type = type
+ this.menuOpened = false
+ localStorage.setItem('social.lastPostType', type)
+ },
+ getPostData() {
+ let element = this.$refs.composerInput.cloneNode(true)
+ Array.from(element.getElementsByClassName('emoji')).forEach((emoji) => {
+ var em = document.createTextNode(emoji.getAttribute('alt'))
+ emoji.replaceWith(em)
+ })
+
+ let contentHtml = element.innerHTML
+
+ // Extract mentions from content and create an array out of them
+ let to = []
+ const mentionRegex = /<span class="mention"[^>]+><a[^>]+><img[^>]+>@([\w-_.]+@[\w-.]+)/g
+ let match = null
+ do {
+ match = mentionRegex.exec(contentHtml)
+ if (match) {
+ to.push(match[1])
+ }
+ } while (match)
+
+ // Add author of original post in case of reply
+ if (this.replyTo !== null) {
+ to.push(this.replyTo.actor_info.account)
+ }
+
+ // Extract hashtags from content and create an array ot of them
+ const hashtagRegex = />#([^<]+)</g
+ let hashtags = []
+ match = null
+ do {
+ match = hashtagRegex.exec(contentHtml)
+ if (match) {
+ hashtags.push(match[1])
+ }
+ } while (match)
+
+ // Remove all html tags but </div> (wich we turn in newlines) and decode the remaining html entities
+ let content = contentHtml.replace(/<(?!\/div)[^>]+>/gi, '').replace(/<\/div>/gi, '\n').trim()
+ content = he.decode(content)
+
+ let data = {
+ content: content,
+ to: to,
+ hashtags: hashtags,
+ type: this.type,
+ attachments: this.previewUrls.map(preview => preview.result), // TODO send the summary and other props too
+ }
+
+ if (this.replyTo) {
+ data.replyTo = this.replyTo.id
+ }
+
+ return data
+ },
+ keyup(event) {
+ if (event.shiftKey || event.ctrlKey) {
+ this.createPost(event)
+ }
+ },
+ updatePostFromTribute(event) {
+ // Trick to let vue-contenteditable know that tribute replaced a mention or hashtag
+ this.$refs.composerInput.oninput(event)
+ },
+ createPost: async function(event) {
+
+ let postData = this.getPostData()
+
+ // Trick to validate last mention when the user directly clicks on the "post" button without validating it.
+ let regex = /@([-\w]+)$/
+ let lastMention = postData.content.match(regex)
+ if (lastMention) {
+
+ // Ask the server for matching accounts, and wait for the results
+ let result = await this.remoteSearchAccounts(lastMention[1])
+
+ // Validate the last mention only when it matches a single account
+ if (result.data.result.accounts.length === 1) {
+ postData.content = postData.content.replace(regex, '@' + result.data.result.accounts[0].account)
+ postData.to.push(result.data.result.accounts[0].account)
+ }
+ }
+
+ // Abort if the post is a direct message and no valid mentions were found
+ // if (this.type === 'direct' && postData.to.length === 0) {
+ // OC.Notification.showTemporary(t('social', 'Error while trying to post your message: Could not find any valid recipients.'), { type: 'error' })
+ // return
+ // }
+
+ // Post message
+ this.loading = true
+ this.$store.dispatch('post', postData).then((response) => {
+ this.loading = false
+ this.replyTo = null
+ this.post = ''
+ this.$refs.composerInput.innerText = this.post
+ this.previewUrls = []
+ this.$store.dispatch('refreshTimeline')
+ })
+
+ },
+ closeReply() {
+ this.replyTo = null
+ // View may want to hide the composer
+ this.$store.commit('setComposerDisplayStatus', false)
+ },
+ remoteSearchAccounts(text) {
+ return axios.get(generateUrl('apps/social/api/v1/global/accounts/search?search=' + text))
+ },
+ remoteSearchHashtags(text) {
+ return axios.get(generateUrl('apps/social/api/v1/global/tags/search?search=' + text))
+ }
+ }
+}
+
+</script>
+
+<style scoped lang="scss">
+ .new-post {
+ padding: 10px;
+ background-color: var(--color-main-background);
+ position: sticky;
+ top: 47px;
+ z-index: 100;
+ margin-bottom: 10px;
+
+ &-form {
+ flex-grow: 1;
+ position: relative;
+ top: -10px;
+ margin-left: 39px;
+ &__emoji-picker {
+ z-index: 1;
+ }
+ }
+ }
+
+ .new-post-author {
+ padding: 5px;
+ display: flex;
+ flex-wrap: wrap;
+
+ .post-author {
+ padding: 6px;
+
+ .post-author-name {
+ font-weight: bold;
+ }
+
+ .post-author-id {
+ opacity: .7;
+ }
+ }
+ }
+
+ .reply-to {
+ background-image: url(../../../img/reply.svg);
+ background-position: 5px 5px;
+ background-repeat: no-repeat;
+ margin-left: 39px;
+ margin-bottom: 20px;
+ overflow: hidden;
+ background-color: #fafafa;
+ border-radius: 3px;
+ padding: 5px;
+ padding-left: 30px;
+
+ .icon-close {
+ display: inline-block;
+ float: right;
+ opacity: .7;
+ padding: 3px;
+ }
+ }
+
+ .message {
+ width: 100%;
+ padding-right: 44px;
+ min-height: 70px;
+ min-width: 2px;
+ display: block;
+ }
+
+ [contenteditable=true]:empty:before {
+ content: attr(placeholder);
+ display: block; /* For Firefox */
+ opacity: .5;
+ }
+
+ input[type=submit].inline {
+ width: 44px;
+ height: 44px;
+ margin: 0;
+ padding: 13px;
+ background-color: transparent;
+ border: none;
+ opacity: 0.3;
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ }
+
+ .options {
+ display: flex;
+ align-items: flex-end;
+ width: 100%;
+ margin-top: 0.5rem;
+ margin-bottom: 1rem;
+ }
+
+ .emptySpace {
+ flex-grow:1;
+ }
+
+ .popovermenu-parent {
+ position: relative;
+ }
+ .popovermenu {
+ top: 55px;
+ }
+
+ .attachment-picker-wrapper {
+ position: absolute;
+ right: 0;
+ top: 2;
+ }
+</style>
+<style>
+ /* Tribute-specific styles TODO: properly scope component css */
+ .tribute-container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: auto;
+ max-height: 300px;
+ max-width: 500px;
+ min-width: 200px;
+ overflow: auto;
+ display: block;
+ z-index: 999999;
+ border-radius: 4px;
+ box-shadow: 0 1px 3px var(--color-box-shadow);
+ }
+
+ .tribute-container ul {
+ margin: 0;
+ margin-top: 2px;
+ padding: 0;
+ list-style: none;
+ background: var(--color-main-background);
+ border-radius: 4px;
+ background-clip: padding-box;
+ overflow: hidden;
+ }
+
+ .tribute-container li {
+ color: var(--color-text);
+ padding: 5px 10px;
+ cursor: pointer;
+ font-size: 14px;
+ display: flex;
+ }
+
+ .tribute-container li span {
+ display: block;
+ }
+
+ .tribute-container li.highlight,
+ .tribute-container li:hover {
+ background: var(--color-primary);
+ color: var(--color-primary-text);
+ }
+
+ .tribute-container li img {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ overflow: hidden;
+ margin-right: 10px;
+ margin-left: -3px;
+ margin-top: 3px;
+ }
+
+ .tribute-container li span {
+ font-weight: bold;
+ }
+
+ .tribute-container li.no-match {
+ cursor: default;
+ }
+
+ .tribute-container .menu-highlighted {
+ font-weight: bold;
+ }
+
+ .tribute-container .account,
+ .tribute-container li.highlight .account,
+ .tribute-container li:hover .account {
+ font-weight: normal;
+ color: var(--color-text-light);
+ opacity: 0.5;
+ }
+
+ .tribute-container li.highlight .account,
+ .tribute-container li:hover .account {
+ color: var(--color-primary-text) !important;
+ opacity: .6;
+ }
+
+ .message .mention {
+ color: var(--color-primary-element);
+ background-color: var(--color-background-dark);
+ border-radius: 5px;
+ padding-top: 1px;
+ padding-left: 2px;
+ padding-bottom: 1px;
+ padding-right: 5px;
+ }
+
+ .mention img {
+ width: 16px;
+ border-radius: 50%;
+ overflow: hidden;
+ margin-right: 3px;
+ vertical-align: middle;
+ margin-top: -1px;
+ }
+
+ .hashtag {
+ text-decoration: underline;
+ }
+</style>