Skip to content

Commit

Permalink
Merge pull request #13778 from nextcloud/fix/noid/split-new-message-l…
Browse files Browse the repository at this point in the history
…ogic
  • Loading branch information
Antreesy authored Nov 15, 2024
2 parents 5b1c701 + 6705e1e commit 4cb807f
Show file tree
Hide file tree
Showing 5 changed files with 180 additions and 93 deletions.
71 changes: 9 additions & 62 deletions src/components/NewMessage/NewMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@
<script>
import debounce from 'debounce'
import { toRefs } from 'vue'
import BellOffIcon from 'vue-material-design-icons/BellOff.vue'
import CheckIcon from 'vue-material-design-icons/Check.vue'
Expand All @@ -185,7 +186,6 @@ import { showError, showWarning } from '@nextcloud/dialogs'
import { FilePickerVue } from '@nextcloud/dialogs/filepicker.js'
import { t } from '@nextcloud/l10n'
import moment from '@nextcloud/moment'
import { generateUrl } from '@nextcloud/router'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
Expand All @@ -204,14 +204,12 @@ import NewMessageTypingIndicator from './NewMessageTypingIndicator.vue'
import PollDraftHandler from '../PollViewer/PollDraftHandler.vue'
import Quote from '../Quote.vue'
import { useIsDarkTheme } from '../../composables/useIsDarkTheme.ts'
import { ATTENDEE, CONVERSATION, PARTICIPANT, PRIVACY } from '../../constants.js'
import { getConversationAvatarOcsUrl, getUserProxyAvatarOcsUrl } from '../../services/avatarService.ts'
import { useChatMentions } from '../../composables/useChatMentions.ts'
import { CONVERSATION, PARTICIPANT, PRIVACY } from '../../constants.js'
import BrowserStorage from '../../services/BrowserStorage.js'
import { getTalkConfig, hasTalkFeature } from '../../services/CapabilitiesManager.ts'
import { EventBus } from '../../services/EventBus.ts'
import { shareFile } from '../../services/filesSharingServices.js'
import { searchPossibleMentions } from '../../services/mentionsService.js'
import { useBreakoutRoomsStore } from '../../stores/breakoutRooms.ts'
import { useChatExtrasStore } from '../../stores/chatExtras.js'
import { useSettingsStore } from '../../stores/settings.js'
Expand Down Expand Up @@ -292,14 +290,17 @@ export default {
expose: ['focusInput'],
setup(props) {
const supportTypingStatus = getTalkConfig(props.token, 'chat', 'typing-privacy') !== undefined
const isDarkTheme = useIsDarkTheme()
const { token } = toRefs(props)
const supportTypingStatus = getTalkConfig(token.value, 'chat', 'typing-privacy') !== undefined
const { autoComplete, userData } = useChatMentions(token)
return {
isDarkTheme,
breakoutRoomsStore: useBreakoutRoomsStore(),
chatExtrasStore: useChatExtrasStore(),
settingsStore: useSettingsStore(),
supportTypingStatus,
autoComplete,
userData,
}
},
Expand All @@ -313,8 +314,6 @@ export default {
showPollDraftHandler: false,
showNewFileDialog: -1,
showFilePicker: false,
// Check empty template by default
userData: {},
clipboardTimeStamp: null,
typingInterval: null,
wasTypingWithinInterval: false,
Expand Down Expand Up @@ -682,7 +681,6 @@ export default {
token: this.token,
})
this.text = ''
this.userData = {}
// Scrolls the message list to the last added message
EventBus.emit('scroll-chat-to-bottom', { smooth: true, force: true })
// Also remove the message to be replied for this conversation
Expand Down Expand Up @@ -924,57 +922,6 @@ export default {
this.showPollDraftHandler = !this.showPollDraftHandler
},
async autoComplete(search, callback) {
const response = await searchPossibleMentions(this.token, search)
if (!response) {
// It was not possible to get the candidate mentions, so just keep the previous ones.
return
}
const possibleMentions = response.data.ocs.data
possibleMentions.forEach(possibleMention => {
// Set icon for candidate mentions that are not for users.
if (possibleMention.source === 'calls') {
possibleMention.icon = 'icon-user-forced-white'
possibleMention.iconUrl = getConversationAvatarOcsUrl(this.token, this.isDarkTheme)
possibleMention.subline = possibleMention?.details ? possibleMention.details : t('spreed', 'Everyone')
} else if (possibleMention.source === ATTENDEE.ACTOR_TYPE.GROUPS) {
possibleMention.icon = 'icon-group-forced-white'
possibleMention.subline = t('spreed', 'Group')
} else if (possibleMention.source === ATTENDEE.ACTOR_TYPE.GUESTS) {
possibleMention.icon = 'icon-user-forced-white'
possibleMention.subline = t('spreed', 'Guest')
} else if (possibleMention.source === ATTENDEE.ACTOR_TYPE.FEDERATED_USERS) {
possibleMention.icon = 'icon-user-forced-white'
possibleMention.iconUrl = getUserProxyAvatarOcsUrl(this.token, possibleMention.id, this.isDarkTheme, 64)
} else {
// The avatar is automatically shown for users, but an icon
// is nevertheless required as fallback.
possibleMention.icon = 'icon-user-forced-white'
if (possibleMention.source === ATTENDEE.ACTOR_TYPE.USERS && possibleMention.id !== possibleMention.mentionId) {
// Prevent local users avatars in federated room to be overwritten
possibleMention.iconUrl = generateUrl('avatar/{userId}/64' + (this.isDarkTheme ? '/dark' : '') + '?v=0', { userId: possibleMention.id })
}
// Convert status properties to an object.
if (possibleMention.status) {
possibleMention.status = {
status: possibleMention.status,
icon: possibleMention.statusIcon,
}
possibleMention.subline = possibleMention.statusMessage
}
}
// Caching the user id data for each possible mention
// mentionId should be the default match since 'federation-v1'
possibleMention.id = possibleMention.mentionId ?? possibleMention.id
this.userData[possibleMention.id] = possibleMention
})
callback(possibleMentions)
},
focusInput() {
if (this.isMobileDevice) {
return
Expand Down
138 changes: 138 additions & 0 deletions src/composables/useChatMentions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { createSharedComposable } from '@vueuse/core'
import type { ComputedRef, Ref } from 'vue'
import Vue, { computed, ref } from 'vue'

import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'

import { useIsDarkTheme } from './useIsDarkTheme.ts'
import { ATTENDEE } from '../constants.js'
import { getConversationAvatarOcsUrl, getUserProxyAvatarOcsUrl } from '../services/avatarService.ts'
import { searchPossibleMentions } from '../services/mentionsService.ts'
import type { ChatMention } from '../types/index.ts'

type AutocompleteChatMention = Omit<ChatMention, 'status'> & {
icon?: string,
iconUrl?: string,
subline?: string | null,
status?: {
status: string,
icon?: string | null,
},
}
type AutoCompleteCallback = (args: AutocompleteChatMention[]) => void
type UserData = Record<string, AutocompleteChatMention>
type UserDataTokenMap = Record<string, UserData>
type ReturnType = {
autoComplete: (search: string, callback: AutoCompleteCallback) => void,
userData: ComputedRef<UserData>,
}

/**
* Provides autoComplete fallback and cached mention object for NcRichContenteditable
* @param token conversation token
*/
function useChatMentionsComposable(token: Ref<string>): ReturnType {
const isDarkTheme = useIsDarkTheme()
const userDataTokenMap = ref<UserDataTokenMap>({})
const userData = computed(() => {
return userDataTokenMap.value[token.value] ?? {}
})

/**
* Prepare and cache search results
* @param possibleMention mention object from API response
* @param token conversation token
* @param isDarkTheme whether current theme is dark
*/
function parseMention(possibleMention: ChatMention, token: string, isDarkTheme: boolean): AutocompleteChatMention {
const chatMention: AutocompleteChatMention = { ...possibleMention, status: undefined }

// Set icon for candidate mentions that are not for users.
if (possibleMention.source === 'calls') {
chatMention.icon = 'icon-user-forced-white'
chatMention.iconUrl = getConversationAvatarOcsUrl(token, isDarkTheme)
chatMention.subline = possibleMention?.details || t('spreed', 'Everyone')
} else if (possibleMention.source === ATTENDEE.ACTOR_TYPE.GROUPS) {
chatMention.icon = 'icon-group-forced-white'
chatMention.subline = t('spreed', 'Group')
} else if (possibleMention.source === ATTENDEE.ACTOR_TYPE.GUESTS) {
chatMention.icon = 'icon-user-forced-white'
chatMention.subline = t('spreed', 'Guest')
} else if (possibleMention.source === ATTENDEE.ACTOR_TYPE.FEDERATED_USERS) {
chatMention.icon = 'icon-user-forced-white'
chatMention.iconUrl = getUserProxyAvatarOcsUrl(token, possibleMention.id, isDarkTheme, 64)
} else {
// The avatar is automatically shown for users, but an icon is nevertheless required as fallback.
chatMention.icon = 'icon-user-forced-white'
if (possibleMention.source === ATTENDEE.ACTOR_TYPE.USERS && possibleMention.id !== possibleMention.mentionId) {
// Prevent local users avatars in federated room to be overwritten
chatMention.iconUrl = generateUrl('avatar/{userId}/64' + (isDarkTheme ? '/dark' : '') + '?v=0', { userId: possibleMention.id })
}
// Convert status properties to an object.
if (possibleMention.status) {
chatMention.status = {
status: possibleMention.status,
icon: possibleMention.statusIcon,
}
chatMention.subline = possibleMention.statusMessage
}
}

// mentionId should be the default match since 'federation-v1'
const id = possibleMention.mentionId ?? possibleMention.id
// caching the user id data for each possible mention
if (!userDataTokenMap.value[token]) {
Vue.set(userDataTokenMap.value, token, {})
}
Vue.set(userDataTokenMap.value[token], id, chatMention)

return chatMention
}

/**
* Prepare and cache search results
* @param token conversation token
* @param search search string
* @param isDarkTheme whether current theme is dark
*/
async function getMentions(token: string, search: string, isDarkTheme: boolean): Promise<AutocompleteChatMention[]> {
try {
const response = await searchPossibleMentions(token, search)
return response.data.ocs.data.map(possibleMention => parseMention(possibleMention, token, isDarkTheme))
} catch (error) {
console.error('Error while searching possible mentions: ', error)
return []
}
}

/**
* @param search search string
* @param callback callback for autocomplete feature
*/
async function autoComplete(search: string, callback: AutoCompleteCallback) {
const autocompleteResults = await getMentions(token.value, search, isDarkTheme.value)
if (!autocompleteResults.length) {
// It was not possible to get the candidate mentions, so just keep the previous ones.
return
}

callback(autocompleteResults)
}

return {
autoComplete,
userData,
}
}

/**
* Shared composable to provide autoComplete fallback and cached mention object for NcRichContenteditable
* @param token conversation token
*/
export const useChatMentions = createSharedComposable(useChatMentionsComposable)
31 changes: 0 additions & 31 deletions src/services/mentionsService.js

This file was deleted.

28 changes: 28 additions & 0 deletions src/services/mentionsService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'

import type { getMentionsParams, getMentionsResponse } from '../types/index.ts'

/**
* Fetch possible mentions
*
* @param token The token of the conversation.
* @param search The string that will be used in the search query.
*/
const searchPossibleMentions = async function(token: string, search: string): getMentionsResponse {
return axios.get(generateOcsUrl('apps/spreed/api/v1/chat/{token}/mentions', { token }), {
params: {
search,
includeStatus: 1,
} as getMentionsParams,
})
}

export {
searchPossibleMentions,
}
5 changes: 5 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,8 @@ export type votePollParams = Required<operations['poll-vote-poll']>['requestBody
export type votePollResponse = ApiResponse<operations['poll-vote-poll']['responses'][200]['content']['application/json']>
export type closePollResponse = ApiResponse<operations['poll-close-poll']['responses'][200]['content']['application/json']>
export type deletePollDraftResponse = ApiResponse<operations['poll-close-poll']['responses'][202]['content']['application/json']>

// Mentions
export type ChatMention = components['schemas']['ChatMentionSuggestion']
export type getMentionsParams = operations['chat-mentions']['parameters']['query']
export type getMentionsResponse = ApiResponse<operations['chat-mentions']['responses'][200]['content']['application/json']>

0 comments on commit 4cb807f

Please sign in to comment.