Merge pull request #2407 from nextcloud/feature/vuejs/participants-list

Participants list in the sidebar
This commit is contained in:
Joas Schilling 2019-11-06 17:49:44 +01:00 коммит произвёл GitHub
Родитель 7ba982ccd5 47a7e89b57
Коммит 041f2499a1
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
11 изменённых файлов: 597 добавлений и 14 удалений

Просмотреть файл

@ -22,8 +22,7 @@
<template> <template>
<AppContentListItem <AppContentListItem
:title="item.displayName" :title="item.displayName"
:to="{ name: 'conversation', params: { token: item.token }}" :to="{ name: 'conversation', params: { token: item.token }}">
@click.prevent.exact="joinConversation">
<ConversationIcon <ConversationIcon
slot="icon" slot="icon"
:item="item" :item="item"

Просмотреть файл

@ -34,6 +34,7 @@
import Conversation from './Conversation' import Conversation from './Conversation'
import Hint from '../Hint/Hint' import Hint from '../Hint/Hint'
import { fetchConversations } from '../../../services/conversationsService' import { fetchConversations } from '../../../services/conversationsService'
import { joinConversation, leaveConversation } from '../../../services/participantsService'
import { EventBus } from '../../../services/EventBus' import { EventBus } from '../../../services/EventBus'
export default { export default {
@ -68,6 +69,15 @@ export default {
window.setInterval(() => { window.setInterval(() => {
this.fetchConversations() this.fetchConversations()
}, 30000) }, 30000)
EventBus.$on('routeChange', ({ from, to }) => {
if (from.name === 'conversation') {
leaveConversation(from.params.token)
}
if (to.name === 'conversation') {
joinConversation(to.params.token)
}
})
}, },
methods: { methods: {
sortConversations(conversation1, conversation2) { sortConversations(conversation1, conversation2) {

Просмотреть файл

@ -21,7 +21,7 @@
<template> <template>
<AppNavigation class="vue navigation"> <AppNavigation class="vue navigation">
<AppNavigationSearch <SearchBox
v-model="searchText" v-model="searchText"
@input="debounceFetchSearchResults" /> @input="debounceFetchSearchResults" />
<ul> <ul>
@ -57,7 +57,6 @@
<script> <script>
import AppNavigation from '@nextcloud/vue/dist/Components/AppNavigation' import AppNavigation from '@nextcloud/vue/dist/Components/AppNavigation'
import AppNavigationSearch from './AppNavigationSearch/AppNavigationSearch'
import Caption from './Caption/Caption' import Caption from './Caption/Caption'
import ContactsList from './ContactsList/ContactsList' import ContactsList from './ContactsList/ContactsList'
import ConversationsList from './ConversationsList/ConversationsList' import ConversationsList from './ConversationsList/ConversationsList'
@ -65,6 +64,7 @@ import GroupsList from './GroupsList/GroupsList'
import Hint from './Hint/Hint' import Hint from './Hint/Hint'
import NewPrivateConversation from './NewConversation/NewPrivateConversation' import NewPrivateConversation from './NewConversation/NewPrivateConversation'
import NewPublicConversation from './NewConversation/NewPublicConversation' import NewPublicConversation from './NewConversation/NewPublicConversation'
import SearchBox from '../SearchBox/SearchBox'
import debounce from 'debounce' import debounce from 'debounce'
import { EventBus } from '../../services/EventBus' import { EventBus } from '../../services/EventBus'
import { searchPossibleConversations } from '../../services/conversationsService' import { searchPossibleConversations } from '../../services/conversationsService'
@ -77,7 +77,6 @@ export default {
components: { components: {
AppNavigation, AppNavigation,
AppNavigationSearch,
Caption, Caption,
ContactsList, ContactsList,
ConversationsList, ConversationsList,
@ -85,6 +84,7 @@ export default {
Hint, Hint,
NewPrivateConversation, NewPrivateConversation,
NewPublicConversation, NewPublicConversation,
SearchBox,
}, },
data() { data() {

Просмотреть файл

@ -27,8 +27,8 @@
</template> </template>
<script> <script>
import AppNavigationNew from 'nextcloud-vue/dist/Components/AppNavigationNew' import AppNavigationNew from '@nextcloud/vue/dist/Components/AppNavigationNew'
import Multiselect from 'nextcloud-vue/dist/Components/Multiselect' import Multiselect from '@nextcloud/vue/dist/Components/Multiselect'
export default { export default {
name: 'NewConversationForm', name: 'NewConversationForm',

Просмотреть файл

@ -35,7 +35,7 @@
<script> <script>
export default { export default {
name: 'AppNavigationSearch', name: 'SearchBox',
props: { props: {
/** /**
* Refers to the focused state of the input search box when loading the page. * Refers to the focused state of the input search box when loading the page.
@ -87,7 +87,7 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '../../../assets/variables.scss'; @import '../../assets/variables.scss';
.app-navigation-search { .app-navigation-search {
height: $top-bar-height !important; height: $top-bar-height !important;

Просмотреть файл

@ -0,0 +1,216 @@
<!--
- @copyright Copyright (c) 2019 Joas Schilling <coding@schilljs.com>
-
- @author Joas Schilling <coding@schilljs.com>
-
- @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>
<li class="participant-row"
:class="{ offline: isOffline, currentUser: isSelf, guestUser: isGuest }">
<div class="participant-row__avatar-wrapper">
<Avatar
:user="userId"
:display-name="displayName" />
</div>
<span class="participant-row__user-name">{{ displayName }}</span>
<span v-if="isModerator" class="participant-row__moderator-indicator">({{ t('spreed', 'moderator') }})</span>
<template v-if="canModerate">
<Actions>
<ActionButton v-if="canBeDemoted"
icon="icon-rename"
@click.prevent.exact="demoteFromModerator">
{{ t('spreed', 'Demote from moderator') }}
</ActionButton>
<ActionButton v-if="canBePromoted"
icon="icon-rename"
@click.prevent.exact="promoteToModerator">
{{ t('spreed', 'Promote to moderator') }}
</ActionButton>
<ActionButton
icon="icon-delete"
@click.prevent.exact="removeParticipant">
{{ t('spreed', 'Remove participant') }}
</ActionButton>
</Actions>
</template>
</li>
</template>
<script>
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import Actions from '@nextcloud/vue/dist/Components/Actions'
import Avatar from '@nextcloud/vue/dist/Components/Avatar'
import { PARTICIPANT } from '../../../constants'
import { getCurrentUser } from '@nextcloud/auth'
export default {
name: 'Participant',
components: {
Actions,
ActionButton,
Avatar,
},
props: {
userId: {
type: String,
required: true,
},
displayName: {
type: String,
required: true,
},
participantType: {
type: Number,
required: true,
},
lastPing: {
type: Number,
default: 0,
},
sessionId: {
type: String,
required: true,
},
},
computed: {
token() {
return this.$route.params.token
},
currentParticipant() {
return this.$store.getters.conversations[this.token]
},
isSelf() {
// User
if (this.userId) {
return getCurrentUser().uid === this.userId
}
// Guest
return this.sessionId !== '0' && this.sessionId === this.currentParticipant.sessionId
},
selfIsModerator() {
return this.participantTypeIsModerator(this.currentParticipant.participantType)
},
isOffline() {
return this.sessionId === '0'
},
isGuest() {
return [PARTICIPANT.TYPE.GUEST, PARTICIPANT.TYPE.GUEST_MODERATOR].indexOf(this.participantType) !== -1
},
isModerator() {
return this.participantTypeIsModerator(this.participantType)
},
canModerate() {
return this.participantType !== PARTICIPANT.TYPE.OWNER && !this.isSelf && this.selfIsModerator
},
canBeDemoted() {
return this.canModerate
&& [PARTICIPANT.TYPE.MODERATOR, PARTICIPANT.TYPE.GUEST_MODERATOR].indexOf(this.participantType) !== -1
},
canBePromoted() {
return this.canModerate && !this.isModerator
},
participantIdentifier() {
let data = {}
if (this.isGuest) {
data = {
sessionId: this.sessionId,
}
} else {
data = {
participant: this.userId,
}
}
return data
},
},
methods: {
participantTypeIsModerator(participantType) {
return [PARTICIPANT.TYPE.OWNER, PARTICIPANT.TYPE.MODERATOR, PARTICIPANT.TYPE.GUEST_MODERATOR].indexOf(participantType) !== -1
},
async promoteToModerator() {
await this.$store.dispatch('promoteToModerator', {
token: this.token,
participantIdentifier: this.participantIdentifier,
})
},
async demoteFromModerator() {
await this.$store.dispatch('demoteFromModerator', {
token: this.token,
participantIdentifier: this.participantIdentifier,
})
},
async removeParticipant() {
await this.$store.dispatch('removeParticipant', {
token: this.token,
participantIdentifier: this.participantIdentifier,
})
},
},
}
</script>
<style lang="scss" scoped>
.participant-row {
display: flex;
align-items: center;
&__avatar-wrapper {
height: 32px;
width: 32px;
}
&__user-name {
margin-left: 6px;
display: inline-block;
vertical-align: middle;
line-height: normal;
}
&__moderator-indicator {
color: var(--color-text-maxcontrast);
font-weight: 300;
padding-left: 5px;
}
&__icon {
width: 32px;
height: 44px;
}
}
.offline {
& > .participant-row__avatar-wrapper {
opacity: .4;
}
& > span {
color: var(--color-text-maxcontrast);
}
}
</style>

Просмотреть файл

@ -0,0 +1,144 @@
<!--
- @copyright Copyright (c) 2019 Joas Schilling <coding@schilljs.com>
-
- @author Joas Schilling <coding@schilljs.com>
-
- @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>
<ul>
<Participant
v-for="participant in participantsList"
:key="participant.userId"
:user-id="participant.userId"
:display-name="participant.displayName"
:participant-type="participant.participantType"
:last-ping="participant.lastPing"
:session-id="participant.sessionId" />
</ul>
</div>
</template>
<script>
import Participant from './Participant'
import { fetchParticipants } from '../../../services/participantsService'
import { EventBus } from '../../../services/EventBus'
import { PARTICIPANT } from '../../../constants'
export default {
name: 'ParticipantsTab',
components: {
Participant,
},
computed: {
token() {
return this.$route.params.token
},
/**
* Gets the participants array.
*
* @returns {array}
*/
participantsList() {
const participants = this.$store.getters.participantsList(this.token)
return participants.slice().sort(this.sortParticipants)
},
},
/**
* Fetches the messages when the MessageList created. The router mounts this
* component only if the token is passed in so there's no need to check the
* token prop.
*/
created() {
this.onRouteChange()
/**
* Add a listener for routeChange event emitted by the App.vue component.
* Call the onRouteChange method function whenever the route changes.
*/
EventBus.$on('routeChange', () => {
this.$nextTick(() => {
this.onRouteChange()
})
})
},
methods: {
onRouteChange() {
this.getParticipants()
},
/**
* Sort two participants by:
* - type (moderators before normal participants)
* - online status
* - display name
*
* @param {object} participant1 First participant
* @param {int} participant1.participantType First participant type
* @param {string} participant1.sessionId First participant session
* @param {string} participant1.displayName First participant display name
* @param {object} participant2 Second participant
* @param {int} participant2.participantType Second participant type
* @param {string} participant2.sessionId Second participant session
* @param {string} participant2.displayName Second participant display name
* @returns {number}
*/
sortParticipants(participant1, participant2) {
const moderatorTypes = [PARTICIPANT.TYPE.OWNER, PARTICIPANT.TYPE.MODERATOR, PARTICIPANT.TYPE.GUEST_MODERATOR]
const moderator1 = moderatorTypes.indexOf(participant1.participantType) !== -1
const moderator2 = moderatorTypes.indexOf(participant2.participantType) !== -1
if (moderator1 !== moderator2) {
return moderator1 ? -1 : 1
}
if (participant1.sessionId === '0') {
if (participant2.sessionId !== '0') {
return 1
}
} else if (participant2.sessionId === '0') {
return -1
}
return participant2.displayName - participant1.displayName
},
async getParticipants() {
const participants = await fetchParticipants(this.token)
this.$store.dispatch('purgeParticipantsStore', this.token)
participants.data.ocs.data.forEach(participant => {
this.$store.dispatch('addParticipant', {
token: this.token,
participant: participant,
})
})
},
},
}
</script>
<style scoped>
</style>

Просмотреть файл

@ -26,7 +26,7 @@
:starred.sync="conversation.isFavorite" :starred.sync="conversation.isFavorite"
@close="handleClose"> @close="handleClose">
<AppSidebarTab :name="t('spreed', 'Participants')" icon="icon-contacts-dark"> <AppSidebarTab :name="t('spreed', 'Participants')" icon="icon-contacts-dark">
Participants <ParticipantsTab />
</AppSidebarTab> </AppSidebarTab>
<AppSidebarTab :name="t('spreed', 'Projects')" icon="icon-projects"> <AppSidebarTab :name="t('spreed', 'Projects')" icon="icon-projects">
<CollectionList v-if="conversation.token" <CollectionList v-if="conversation.token"
@ -40,7 +40,7 @@
<script> <script>
import AppSidebar from '@nextcloud/vue/dist/Components/AppSidebar' import AppSidebar from '@nextcloud/vue/dist/Components/AppSidebar'
import AppSidebarTab from '@nextcloud/vue/dist/Components/AppSidebarTab' import AppSidebarTab from '@nextcloud/vue/dist/Components/AppSidebarTab'
// import ParticipantsTab from './ParticipantsTab/ParticipantsTab' import ParticipantsTab from './ParticipantsTab/ParticipantsTab'
import { CollectionList } from 'nextcloud-vue-collections' import { CollectionList } from 'nextcloud-vue-collections'
export default { export default {
@ -49,7 +49,7 @@ export default {
AppSidebar, AppSidebar,
AppSidebarTab, AppSidebarTab,
CollectionList, CollectionList,
// ParticipantsTab, ParticipantsTab,
}, },
computed: { computed: {

Просмотреть файл

@ -29,9 +29,9 @@ import { generateOcsUrl } from '@nextcloud/router'
* *
* @param {string} token The conversation token; * @param {string} token The conversation token;
*/ */
const joinConversation = async function(token) { const joinConversation = async(token) => {
try { try {
const response = await axios.post(generateOcsUrl(`room/${token}/participants/active`)) const response = await axios.post(generateOcsUrl('apps/spreed/api/v1', 2) + `room/${token}/participants/active`)
return response return response
} catch (error) { } catch (error) {
console.debug(error) console.debug(error)
@ -66,8 +66,56 @@ const removeCurrentUserFromConversation = async function(token) {
} }
} }
const removeUserFromConversation = async function(token, userId) {
try {
const response = await axios.delete(generateOcsUrl('apps/spreed/api/v1', 2) + `room/${token}/participants`, {
params: {
participant: userId,
},
})
return response
} catch (error) {
console.debug(error)
}
}
const removeGuestFromConversation = async function(token, sessionId) {
try {
const response = await axios.delete(generateOcsUrl('apps/spreed/api/v1', 2) + `room/${token}/participants/guests`, {
params: {
participant: sessionId,
},
})
return response
} catch (error) {
console.debug(error)
}
}
const promoteToModerator = async(token, options) => {
const response = await axios.post(generateOcsUrl('apps/spreed/api/v1/room', 2) + token + '/moderators', options)
return response
}
const demoteFromModerator = async(token, options) => {
const response = await axios.delete(generateOcsUrl('apps/spreed/api/v1/room', 2) + token + '/moderators', {
params: options,
})
return response
}
const fetchParticipants = async(token) => {
const response = await axios.get(generateOcsUrl('apps/spreed/api/v1/room', 2) + token + '/participants')
return response
}
export { export {
joinConversation, joinConversation,
leaveConversation, leaveConversation,
removeCurrentUserFromConversation, removeCurrentUserFromConversation,
removeUserFromConversation,
removeGuestFromConversation,
promoteToModerator,
demoteFromModerator,
fetchParticipants,
} }

Просмотреть файл

@ -24,6 +24,7 @@ import Vue from 'vue'
import Vuex, { Store } from 'vuex' import Vuex, { Store } from 'vuex'
import conversationsStore from './conversationsStore' import conversationsStore from './conversationsStore'
import messagesStore from './messagesStore' import messagesStore from './messagesStore'
import participantsStore from './participantsStore'
import quoteReplyStore from './quoteReplyStore' import quoteReplyStore from './quoteReplyStore'
import sidebarStore from './sidebarStore' import sidebarStore from './sidebarStore'
@ -35,6 +36,7 @@ export default new Store({
modules: { modules: {
conversationsStore, conversationsStore,
messagesStore, messagesStore,
participantsStore,
quoteReplyStore, quoteReplyStore,
sidebarStore, sidebarStore,
}, },

Просмотреть файл

@ -0,0 +1,164 @@
/**
* @copyright Copyright (c) 2019 Joas Schilling <coding@schilljs.com>
*
* @author Joas Schilling <coding@schilljs.com>
*
* @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/>.
*
*/
import Vue from 'vue'
import {
promoteToModerator,
demoteFromModerator,
removeUserFromConversation,
removeGuestFromConversation,
} from '../services/participantsService'
import { PARTICIPANT } from '../constants'
const state = {
participants: {
},
}
const getters = {
/**
* Gets the participants array
* @param {object} state the state object.
* @returns {array} the participants array (if there are participants in the store)
*/
participantsList: (state) => (token) => {
if (state.participants[token]) {
return state.participants[token]
}
return []
},
getParticipant: (state) => (token, index) => {
if (state.participants[token] && state.participants[token][index]) {
return state.participants[token][index]
}
return {}
},
getParticipantIndex: (state) => (token, participantIdentifier) => {
let index
if (participantIdentifier.hasOwnProperty('participant')) {
index = state.participants[token].findIndex(participant => participant.userId === participantIdentifier.participant)
} else {
index = state.participants[token].findIndex(participant => participant.sessionId === participantIdentifier.sessionId)
}
return index
},
}
const mutations = {
/**
* Adds a message to the store.
* @param {object} state current store state;
* @param {object} token the token of the conversation;
* @param {object} participant the participant;
*/
addParticipant(state, { token, participant }) {
if (!state.participants[token]) {
Vue.set(state.participants, token, [])
}
state.participants[token].push(participant)
},
updateParticipant(state, { token, index, updatedData }) {
if (state.participants[token] && state.participants[token][index]) {
state.participants[token][index] = Object.assign(state.participants[token][index], updatedData)
}
},
deleteParticipant(state, { token, index }) {
if (state.participants[token] && state.participants[token][index]) {
Vue.delete(state.participants[token], index)
}
},
/**
* Resets the store to it's original state
* @param {object} state current store state;
* @param {string} token the conversation to purge;
*/
purgeParticipantsStore(state, token) {
Vue.delete(state.participants, token)
},
}
const actions = {
/**
* Adds participant to the store.
*
* @param {object} context default store context;
* @param {string} token the conversation to purge;
* @param {object} participant the participant;
*/
addParticipant({ commit }, { token, participant }) {
commit('addParticipant', { token, participant })
},
async promoteToModerator({ commit, getters }, { token, participantIdentifier }) {
const index = getters.getParticipantIndex(token, participantIdentifier)
if (index === -1) {
return
}
await promoteToModerator(token, participantIdentifier)
const participant = getters.getParticipant(token, index)
const updatedData = {
participantType: participant.participantType === PARTICIPANT.TYPE.GUEST ? PARTICIPANT.TYPE.GUEST_MODERATOR : PARTICIPANT.TYPE.MODERATOR,
}
commit('updateParticipant', { token, index, updatedData })
},
async demoteFromModerator({ commit, getters }, { token, participantIdentifier }) {
const index = getters.getParticipantIndex(token, participantIdentifier)
if (index === -1) {
return
}
await demoteFromModerator(token, participantIdentifier)
const participant = getters.getParticipant(token, index)
const updatedData = {
participantType: participant.participantType === PARTICIPANT.TYPE.GUEST_MODERATOR ? PARTICIPANT.TYPE.GUEST : PARTICIPANT.TYPE.USER,
}
commit('updateParticipant', { token, index, updatedData })
},
async removeParticipant({ commit, getters }, { token, participantIdentifier }) {
const index = getters.getParticipantIndex(token, participantIdentifier)
if (index === -1) {
return
}
const participant = getters.getParticipant(token, index)
if (participant.userId) {
await removeUserFromConversation(token, participant.userId)
} else {
await removeGuestFromConversation(token, participant.sessionId)
}
commit('deleteParticipant', { token, index })
},
/**
* Resets the store to it's original state.
* @param {object} context default store context;
* @param {string} token the conversation to purge;
*/
purgeParticipantsStore({ commit }, token) {
commit('purgeParticipantsStore', token)
},
}
export default { state, mutations, getters, actions }