social/src/components/Composer.vue

514 wiersze
13 KiB
Vue

<!--
- @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
-
- @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="">
<div class="new-post-author">
<avatar :user="currentUser.uid" :display-name="currentUser.displayName" :size="32" />
</div>
<form class="new-post-form" @submit.prevent="createPost">
<div class="author currentUser">
{{ currentUser.displayName }}
<span class="social-id">{{ socialId }}</span>
</div>
<vue-tribute :options="tributeOptions">
<!-- eslint-disable-next-line vue/valid-v-model -->
<div v-contenteditable:post.dangerousHTML="canType" ref="composerInput" class="message"
placeholder="What would you like to share?" @keyup.enter="keyup" />
</vue-tribute>
<emoji-picker ref="emojiPicker" :search="search" class="emoji-picker-wrapper"
@emoji="insert">
<a v-tooltip="'Insert emoji'" slot="emoji-invoker" slot-scope="{ events }"
class="emoji-invoker" tabindex="0" v-on="events"
@keyup.enter="events.click" @keyup.space="events.click" />
<div slot="emoji-picker" slot-scope="{ emojis, insert, display }" class="emoji-picker popovermenu">
<div>
<div>
<input v-focus-on-create v-model="search" type="text"
@keyup.enter="insert(emojis)">
</div>
<div>
<div v-for="(emojiGroup, category) in emojis" :key="category">
<h5>{{ category }}</h5>
<div>
<span v-for="(emoji, emojiName) in emojiGroup" :key="emojiName" :title="emojiName"
tabindex="0"
class="emoji" @click="insert(emoji)" @keyup.enter="insert(emoji)"
@keyup.space="insert(emoji)" v-html="$twemoji.parse(emoji)" />
</div>
</div>
</div>
</div>
</div>
</emoji-picker>
<div class="options">
<input :value="currentVisibilityPostLabel" :disabled="post.length < 1" class="submit primary"
type="submit" title="" data-original-title="Post">
<div v-click-outside="hidePopoverMenu">
<button :class="currentVisibilityIconClass" @click.prevent="togglePopoverMenu" />
<div :class="{open: menuOpened}" class="popovermenu menu-center">
<PopoverMenu :menu="visibilityPopover" />
</div>
</div>
</div>
</form>
</div>
</template>
<style scoped>
.new-post {
display: flex;
flex-wrap: wrap;
padding: 10px;
background-color: var(--color-main-background);
position: sticky;
top: 47px;
z-index: 100;
margin-bottom: 10px;
}
.new-post-author {
padding: 5px;
}
.new-post-form {
flex-grow: 1;
position: relative;
}
.message {
width: 100%;
padding-right: 44px;
min-height: 70px;
}
.author .social-id {
opacity: .5;
}
[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%;
flex-direction: row-reverse;
}
.options > div {
position: relative;
}
.options button {
width: 34px;
height: 34px;
}
.emoji-invoker {
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2OCIgaGVpZ2h0PSI2OCI+PHBhdGggZD0iTTM0IDBDMTUuMyAwIDAgMTUuMyAwIDM0czE1LjMgMzQgMzQgMzQgMzQtMTUuMyAzNC0zNFM1Mi43IDAgMzQgMHptMCA2NEMxNy41IDY0IDQgNTAuNSA0IDM0UzE3LjUgNCAzNCA0czMwIDEzLjUgMzAgMzAtMTMuNSAzMC0zMCAzMHoiLz48cGF0aCBkPSJNNDQuNiA0NC42Yy01LjggNS44LTE1LjQgNS44LTIxLjIgMC0uOC0uOC0yLS44LTIuOCAwLS44LjgtLjggMiAwIDIuOEMyNC4zIDUxLjEgMjkuMSA1MyAzNCA1M3M5LjctMS45IDEzLjQtNS42Yy44LS44LjgtMiAwLTIuOC0uOC0uOC0yLS44LTIuOCAweiIvPjxjaXJjbGUgcj0iNSIgY3k9IjI2IiBjeD0iMjQiLz48Y2lyY2xlIHI9IjUiIGN5PSIyNiIgY3g9IjQ0Ii8+PC9zdmc+);
background-position: center center;
background-repeat: no-repeat;
width: 38px;
opacity: 0.5;
background-size: 16px 16px;
height: 38px;
cursor: pointer;
display: block;
}
.emoji-invoker:focus,
.emoji-invoker:hover {
opacity: 1;
}
.emoji-picker-wrapper {
position: absolute;
right: 0;
top: 22px;
}
.emoji-picker.popovermenu {
display: block;
padding: 5px;
width: 200px;
height: 200px;
top: 44px;
}
.emoji-picker > div {
overflow: hidden;
overflow-y: scroll;
height: 190px;
}
.emoji-picker input {
width: 100%;
}
.emoji-picker span.emoji {
padding: 3px;
}
.emoji-picker span.emoji:focus {
background-color: var(--color-background-dark);
}
.emoji-picker .emoji img {
width: 16px;
}
.popovermenu {
top: 55px;
}
</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;
}
img.emoji {
margin: 3px;
width: 16px;
vertical-align: text-bottom;
}
</style>
<script>
import { Avatar, PopoverMenu } from 'nextcloud-vue'
import ClickOutside from 'vue-click-outside'
import EmojiPicker from 'vue-emoji-picker'
import VueTribute from 'vue-tribute'
import { VTooltip } from 'v-tooltip'
import CurrentUserMixin from './../mixins/currentUserMixin'
import FocusOnCreate from '../directives/focusOnCreate'
import axios from 'nextcloud-axios'
export default {
name: 'Composer',
components: {
PopoverMenu,
Avatar,
EmojiPicker,
VueTribute
},
directives: {
tooltip: VTooltip,
ClickOutside: ClickOutside,
FocusOnCreate: FocusOnCreate
},
mixins: [CurrentUserMixin],
props: {
},
data() {
return {
type: localStorage.getItem('social.lastPostType') || 'followers',
post: '',
canType: true,
search: '',
tributeOptions: {
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.remoteSearch(text).then((result) => {
if (result.data.result.exact) {
let user = result.data.result.exact
users.push({
key: user.preferredUsername,
value: user.account,
url: user.url,
avatar: user.local ? OC.generateUrl(`/avatar/${user.preferredUsername}/32`) : ''// TODO: use real avatar from server
})
}
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 ? OC.generateUrl(`/avatar/${user.preferredUsername}/32`) : OC.generateUrl(`apps/social/api/v1/global/actor/avatar?id=${user.id}`)
})
}
cb(users)
})
}
},
menuOpened: false
}
},
computed: {
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('direct') },
icon: this.visibilityIconClass('direct'),
active: this.activeState('direct'),
text: t('social', 'Direct'),
longtext: t('social', 'Post to mentioned users only')
},
{
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('public') },
icon: this.visibilityIconClass('public'),
active: this.activeState('public'),
text: t('social', 'Public'),
longtext: t('social', 'Post to public timelines')
}
]
}
},
methods: {
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 to = []
const re = /@(([\w-_.]+)(@[\w-.]+)?)/g
let match = null
do {
match = re.exec(element.innerText)
if (match) {
to.push(match[1])
}
} while (match)
let content = element.innerText.trim()
return {
content: content,
to: to,
type: this.type
}
},
keyup(event) {
if (event.shiftKey) {
this.createPost(event)
}
},
createPost(event) {
this.$store.dispatch('post', this.getPostData()).then((response) => {
this.post = ''
this.$refs.composerInput.innerText = this.post
this.$store.dispatch('refreshTimeline', {
account: this.currentUser.uid
})
})
},
remoteSearch(text) {
return axios.get(OC.generateUrl('apps/social/api/v1/global/accounts/search?search=' + text))
}
}
}
</script>