Browse Source

Merge pull request #5534 from nextcloud/enh/3110/in-call-new-messages-indicator

New messages indicator while in call
pull/5795/head
Joas Schilling 4 years ago
committed by GitHub
parent
commit
95c25f4360
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 89
      src/components/TopBar/TopBar.vue
  2. 9
      src/store/conversationsStore.js
  3. 68
      src/store/messagesStore.js
  4. 265
      src/store/messagesStore.spec.js

89
src/components/TopBar/TopBar.vue

@ -125,21 +125,42 @@
close-after-click="true"
container="#content-vue">
<ActionButton
v-if="isInCall"
key="openSideBarButtonMessageText"
@click="openSidebar">
<MessageText
slot="icon"
:size="16"
title=""
fill-color="#ffffff"
decorative />
</ActionButton>
<ActionButton
v-else
key="openSideBarButtonMenuPeople"
:icon="iconMenuPeople"
@click="openSidebar" />
</Actions>
</div>
<CounterBubble
v-if="showOpenSidebarButton && isInCall && unreadMessagesCounter > 0"
class="unread-messages-counter"
:highlighted="hasUnreadMentions">
{{ unreadMessagesCounter }}
</CounterBubble>
</div>
</template>
<script>
import { showError, showSuccess } from '@nextcloud/dialogs'
import { showError, showSuccess, showMessage } from '@nextcloud/dialogs'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import Actions from '@nextcloud/vue/dist/Components/Actions'
import CounterBubble from '@nextcloud/vue/dist/Components/CounterBubble'
import CallButton from './CallButton'
import BrowserStorage from '../../services/BrowserStorage'
import ActionLink from '@nextcloud/vue/dist/Components/ActionLink'
import ActionSeparator from '@nextcloud/vue/dist/Components/ActionSeparator'
import MessageText from 'vue-material-design-icons/MessageText'
import MicrophoneOff from 'vue-material-design-icons/MicrophoneOff'
import { CONVERSATION, PARTICIPANT } from '../../constants'
import { generateUrl } from '@nextcloud/router'
@ -160,8 +181,10 @@ export default {
ActionButton,
Actions,
ActionLink,
CounterBubble,
CallButton,
ActionSeparator,
MessageText,
MicrophoneOff,
ConversationIcon,
},
@ -175,6 +198,12 @@ export default {
},
},
data: () => {
return {
unreadNotificationHandle: null,
}
},
computed: {
isFullscreen() {
return this.$store.getters.isFullscreen()
@ -260,6 +289,13 @@ export default {
return this.$store.getters.conversation(this.token) || this.$store.getters.dummyConversation
},
unreadMessagesCounter() {
return this.conversation.unreadMessages
},
hasUnreadMentions() {
return this.conversation.unreadMention
},
linkToConversation() {
if (this.token !== '') {
return window.location.protocol + '//' + window.location.host + generateUrl('/call/' + this.token)
@ -282,6 +318,36 @@ export default {
},
},
watch: {
unreadMessagesCounter(newValue, oldValue) {
if (!this.isInCall || !this.showOpenSidebarButton) {
return
}
// new messages arrived
if (newValue > 0 && oldValue === 0 && !this.hasUnreadMentions) {
this.notifyUnreadMessages(t('spreed', 'You have new unread messages in the chat.'))
}
},
hasUnreadMentions(newValue, oldValue) {
if (!this.isInCall || !this.showOpenSidebarButton) {
return
}
if (newValue) {
this.notifyUnreadMessages(t('spreed', 'You have been mentioned in the chat.'))
}
},
isInCall(newValue) {
if (!newValue) {
// discard notification if the call ends
this.notifyUnreadMessages(null)
}
},
},
mounted() {
document.addEventListener('fullscreenchange', this.fullScreenChanged, false)
document.addEventListener('mozfullscreenchange', this.fullScreenChanged, false)
@ -290,6 +356,7 @@ export default {
},
beforeDestroy() {
this.notifyUnreadMessages(null)
document.removeEventListener('fullscreenchange', this.fullScreenChanged, false)
document.removeEventListener('mozfullscreenchange', this.fullScreenChanged, false)
document.removeEventListener('MSFullscreenChange', this.fullScreenChanged, false)
@ -297,6 +364,16 @@ export default {
},
methods: {
notifyUnreadMessages(message) {
if (this.unreadNotificationHandle) {
this.unreadNotificationHandle.hideToast()
this.unreadNotificationHandle = null
}
if (message) {
this.unreadNotificationHandle = showMessage(message)
}
},
openSidebar() {
this.$store.dispatch('showSidebar')
BrowserStorage.setItem('sidebarOpen', 'true')
@ -418,13 +495,17 @@ export default {
display: flex;
align-items: center;
white-space: nowrap;
svg {
margin-right: 4px !important;
}
.icon {
margin-right: 4px !important;
}
}
.unread-messages-counter {
position: absolute;
top: 40px;
right: 4px;
pointer-events: none;
}
}
.conversation-header {

9
src/store/conversationsStore.js

@ -121,6 +121,15 @@ const mutations = {
Vue.set(state.conversations[token], 'lastMessage', lastMessage)
},
updateUnreadMessages(state, { token, unreadMessages, unreadMention }) {
if (unreadMessages !== undefined) {
Vue.set(state.conversations[token], 'unreadMessages', unreadMessages)
}
if (unreadMention !== undefined) {
Vue.set(state.conversations[token], 'unreadMention', unreadMention)
}
},
setNotificationLevel(state, { token, notificationLevel }) {
Vue.set(state.conversations[token], 'notificationLevel', notificationLevel)
},

68
src/store/messagesStore.js

@ -36,6 +36,43 @@ import {
ATTENDEE,
} from '../constants'
/**
* Returns whether the given message contains a mention to self, directly
* or indirectly through a global mention.
*
* @param {Object} context store context
* @param {Object} message message object
* @returns {bool} true if the message contains a mention to self or all,
* false otherwise
*/
function hasMentionToSelf(context, message) {
if (!message.messageParameters) {
return false
}
for (const key in message.messageParameters) {
const param = message.messageParameters[key]
if (param.type === 'call') {
return true
}
if (param.type === 'guest'
&& context.getters.getActorType() === ATTENDEE.ACTOR_TYPE.GUESTS
&& param.id === ('guest/' + context.getters.getActorId())
) {
return true
}
if (param.type === 'user'
&& context.getters.getActorType() === ATTENDEE.ACTOR_TYPE.USERS
&& param.id === context.getters.getUserId()
) {
return true
}
}
return false
}
const state = {
/**
* Map of conversation token to message list
@ -621,6 +658,9 @@ const actions = {
})
}
const conversation = context.getters.conversation(token)
let countNewMessages = 0
let hasNewMention = conversation.unreadMention
let lastMessage = null
// Process each messages and adds it to the store
response.data.ocs.data.forEach(message => {
@ -632,8 +672,26 @@ const actions = {
}
context.dispatch('processMessage', message)
if (!lastMessage || message.id > lastMessage.id) {
if (!message.systemMessage) {
countNewMessages++
// parse mentions data to update "conversation.unreadMention",
// if needed
if (!hasNewMention && hasMentionToSelf(context, message)) {
hasNewMention = true
}
}
lastMessage = message
}
// in case we encounter an already read message, reset the counter
// this is probably unlikely to happen unless one starts browsing from
// an earlier page and scrolls down
if (conversation.lastReadMessage === message.id) {
// discard counters
countNewMessages = 0
hasNewMention = conversation.unreadMention
}
})
context.dispatch('setLastKnownMessageId', {
@ -641,7 +699,6 @@ const actions = {
id: parseInt(response.headers['x-chat-last-given'], 10),
})
const conversation = context.getters.conversation(token)
if (conversation && conversation.lastMessage && lastMessage.id > conversation.lastMessage.id) {
context.dispatch('updateConversationLastMessage', {
token,
@ -649,6 +706,15 @@ const actions = {
})
}
if (countNewMessages > 0) {
context.commit('updateUnreadMessages', {
token,
unreadMessages: conversation.unreadMessages + countNewMessages,
// only update the value if it's been changed to true
unreadMention: conversation.unreadMention !== hasNewMention ? hasNewMention : undefined,
})
}
return response
},

265
src/store/messagesStore.spec.js

@ -867,6 +867,7 @@ describe('messagesStore', () => {
describe('look for new messages', () => {
let updateLastCommonReadMessageAction
let updateConversationLastMessageAction
let updateUnreadMessagesMutation
let forceGuestNameAction
let cancelFunctionMock
let conversationMock
@ -877,11 +878,13 @@ describe('messagesStore', () => {
conversationMock = jest.fn()
updateConversationLastMessageAction = jest.fn()
updateLastCommonReadMessageAction = jest.fn()
updateUnreadMessagesMutation = jest.fn()
forceGuestNameAction = jest.fn()
testStoreConfig.getters.conversation = jest.fn().mockReturnValue(conversationMock)
testStoreConfig.actions.updateConversationLastMessage = updateConversationLastMessageAction
testStoreConfig.actions.updateLastCommonReadMessage = updateLastCommonReadMessageAction
testStoreConfig.actions.forceGuestName = forceGuestNameAction
testStoreConfig.mutations.updateUnreadMessages = updateUnreadMessagesMutation
cancelFunctionMock = jest.fn()
CancelableRequest.mockImplementation((request) => {
@ -919,7 +922,9 @@ describe('messagesStore', () => {
lookForNewMessages.mockResolvedValueOnce(response)
// smaller number to make it update
conversationMock.mockReturnValue({ lastMessage: { id: 1 } })
conversationMock.mockReturnValue({
lastMessage: { id: 1 },
})
await store.dispatch('lookForNewMessages', {
token: TOKEN,
@ -1023,6 +1028,264 @@ describe('messagesStore', () => {
expect(cancelFunctionMock).toHaveBeenCalledWith('canceled')
})
describe('updates unread counters immediately', () => {
let testConversation
beforeEach(() => {
testConversation = {
lastMessage: { id: 100 },
lastReadMessage: 100,
unreadMessages: 144,
unreadMention: false,
}
})
async function testUpdateMessageCounters(messages, expectedPayload) {
const response = {
headers: {
'x-chat-last-common-read': '123',
'x-chat-last-given': '100',
},
data: {
ocs: {
data: messages,
},
},
}
lookForNewMessages.mockResolvedValueOnce(response)
// smaller number to make it update
conversationMock.mockReturnValue(testConversation)
await store.dispatch('lookForNewMessages', {
token: TOKEN,
lastKnownMessageId: 100,
})
if (expectedPayload) {
expect(updateUnreadMessagesMutation).toHaveBeenCalledWith(expect.anything(), expectedPayload)
} else {
expect(updateUnreadMessagesMutation).not.toHaveBeenCalled()
}
}
describe('updating unread messages counter', () => {
test('updates unread message counter for regular messages', async() => {
const messages = [{
id: 101,
token: TOKEN,
actorType: ATTENDEE.ACTOR_TYPE.USERS,
}, {
id: 102,
token: TOKEN,
actorType: ATTENDEE.ACTOR_TYPE.GUESTS,
}]
const expectedPayload = {
token: TOKEN,
unreadMessages: 146,
unreadMention: undefined,
}
await testUpdateMessageCounters(messages, expectedPayload)
})
test('skips system messages when counting unread messages', async() => {
const messages = [{
id: 101,
token: TOKEN,
actorType: ATTENDEE.ACTOR_TYPE.USERS,
}, {
id: 102,
token: TOKEN,
actorType: ATTENDEE.ACTOR_TYPE.USERS,
systemMessage: 'i_am_the_system',
}]
const expectedPayload = {
token: TOKEN,
unreadMessages: 145,
unreadMention: undefined,
}
await testUpdateMessageCounters(messages, expectedPayload)
})
test('only counts unread messages from the last unread message', async() => {
const messages = [{
id: 99,
token: TOKEN,
actorType: ATTENDEE.ACTOR_TYPE.USERS,
}, {
// this is the last unread message
id: 100,
token: TOKEN,
actorType: ATTENDEE.ACTOR_TYPE.USERS,
}, {
id: 101,
token: TOKEN,
actorType: ATTENDEE.ACTOR_TYPE.USERS,
}, {
id: 102,
token: TOKEN,
actorType: ATTENDEE.ACTOR_TYPE.GUESTS,
}]
const expectedPayload = {
token: TOKEN,
unreadMessages: 146,
unreadMention: undefined,
}
await testUpdateMessageCounters(messages, expectedPayload)
})
test('does not update counter if no new messages were found', async() => {
const messages = [{
// this one is the last read message so doesn't count
id: 100,
token: TOKEN,
actorType: ATTENDEE.ACTOR_TYPE.USERS,
}]
await testUpdateMessageCounters(messages, null)
})
})
describe('updating unread mention flag', () => {
let getActorIdMock
let getActorTypeMock
let getUserIdMock
beforeEach(() => {
getActorIdMock = jest.fn()
getActorTypeMock = jest.fn()
getUserIdMock = jest.fn()
testStoreConfig.getters.getActorId = getActorIdMock
testStoreConfig.getters.getActorType = getActorTypeMock
testStoreConfig.getters.getUserId = getUserIdMock
store = new Vuex.Store(testStoreConfig)
})
async function testMentionFlag(messageParameters, expectedValue) {
const messages = [{
id: 101,
token: TOKEN,
actorType: ATTENDEE.ACTOR_TYPE.USERS,
messageParameters,
}]
const expectedPayload = {
token: TOKEN,
unreadMessages: 145,
unreadMention: expectedValue,
}
await testUpdateMessageCounters(messages, expectedPayload)
}
test('updates unread mention flag for global message', async() => {
await testMentionFlag({
'mention-1': {
type: 'call',
},
}, true)
})
test('updates unread mention flag for guest mention', async() => {
getActorIdMock.mockReturnValue(() => 'me_as_guest')
getActorTypeMock.mockReturnValue(() => ATTENDEE.ACTOR_TYPE.GUESTS)
await testMentionFlag({
'mention-0': {
type: 'user',
id: 'some_user',
},
'mention-1': {
type: 'guest',
id: 'guest/me_as_guest',
},
}, true)
})
test('does not update unread mention flag for a different guest mention', async() => {
getActorIdMock.mockReturnValue(() => 'me_as_guest')
getActorTypeMock.mockReturnValue(() => ATTENDEE.ACTOR_TYPE.GUESTS)
await testMentionFlag({
'mention-1': {
type: 'guest',
id: 'guest/someone_else_as_guest',
},
}, undefined)
})
test('updates unread mention flag for user mention', async() => {
getUserIdMock.mockReturnValue(() => 'me_as_user')
getActorTypeMock.mockReturnValue(() => ATTENDEE.ACTOR_TYPE.USERS)
await testMentionFlag({
'mention-0': {
type: 'user',
id: 'some_user',
},
'mention-1': {
type: 'user',
id: 'me_as_user',
},
}, true)
})
test('does not update unread mention flag for another user mention', async() => {
getUserIdMock.mockReturnValue(() => 'me_as_user')
getActorTypeMock.mockReturnValue(() => ATTENDEE.ACTOR_TYPE.USERS)
await testMentionFlag({
'mention-1': {
type: 'user',
id: 'another_user',
},
}, undefined)
})
test('does not update unread mention flag when no params', async() => {
await testMentionFlag({}, undefined)
await testMentionFlag(null, undefined)
})
test('does not update unread mention flag when already set', async() => {
testConversation.unreadMention = true
await testMentionFlag({
'mention-1': {
type: 'call',
},
}, undefined)
})
test('does not update unread mention flag for non-mention parameter', async() => {
testConversation.unreadMention = true
await testMentionFlag({
'file-1': {
type: 'file',
},
}, undefined)
})
test('does not update unread mention flag for previously read messages', async() => {
const messages = [{
// this message was already read
id: 100,
token: TOKEN,
actorType: ATTENDEE.ACTOR_TYPE.USERS,
messageParameters: {
'mention-1': {
type: 'call',
},
},
}, {
id: 101,
token: TOKEN,
actorType: ATTENDEE.ACTOR_TYPE.USERS,
}]
const expectedPayload = {
token: TOKEN,
unreadMessages: 145,
unreadMention: undefined,
}
await testUpdateMessageCounters(messages, expectedPayload)
})
})
})
})
describe('posting new message', () => {

Loading…
Cancel
Save