Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 113 additions & 16 deletions src/components/NewMessage/NewMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
:can-upload-files="canUploadFiles"
:can-share-files="canShareFiles"
:can-create-poll="canCreatePoll"
:can-create-thread="canCreateThread"
@open-file-upload="openFileUploadWindow"
@create-thread="setCreateThread"
@handle-file-share="showFilePicker"
@update-new-file-dialog="updateNewFileDialog" />

Expand Down Expand Up @@ -72,9 +74,19 @@
<NcNoteCard v-if="showMentionEditHint"
type="warning"
:text="t('spreed', 'Adding a mention will only notify users who did not read the message.')" />
<NcTextField
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is present, I suggest to make new message input longer in height.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see the reason behind it. There is already a placeholder in place

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Visual reasons

Copy link
Contributor

@DorraJaouad DorraJaouad Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe place the emoji picker in top corner? new-message-form__emoji-picker

v-if="threadCreating"
v-model="threadTitle"
class="new-message-form__thread-title"
:label="t('spreed', 'Thread title')"
:disabled="disabled"
show-trailing-button
@trailing-button-click="setCreateThread(false)" />
<NcRichContenteditable ref="richContenteditable"
:key="container"
v-model="text"
:class="{ 'new-message-form__input-rich--required': errorTitle }"
:title="errorTitle"
:auto-complete="autoComplete"
:disabled="disabled"
:user-data="userData"
Expand Down Expand Up @@ -148,7 +160,8 @@
:aria-label="sendMessageLabel"
@click="handleSubmit">
<template #icon>
<IconSend class="bidirectional-icon" :size="16" />
<IconForumOutline v-if="threadCreating" :size="16" />
<IconSend v-else class="bidirectional-icon" :size="16" />
</template>
</NcButton>
</template>
Expand All @@ -175,10 +188,12 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmojiPicker from '@nextcloud/vue/components/NcEmojiPicker'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcRichContenteditable from '@nextcloud/vue/components/NcRichContenteditable'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import IconBellOffOutline from 'vue-material-design-icons/BellOffOutline.vue'
import IconCheck from 'vue-material-design-icons/Check.vue'
import IconClose from 'vue-material-design-icons/Close.vue'
import IconEmoticonOutline from 'vue-material-design-icons/EmoticonOutline.vue'
import IconForumOutline from 'vue-material-design-icons/ForumOutline.vue'
import IconSend from 'vue-material-design-icons/Send.vue'
import Quote from '../Quote.vue'
import NewMessageAbsenceInfo from './NewMessageAbsenceInfo.vue'
Expand All @@ -188,6 +203,7 @@ import NewMessageChatSummary from './NewMessageChatSummary.vue'
import NewMessageNewFileDialog from './NewMessageNewFileDialog.vue'
import NewMessageTypingIndicator from './NewMessageTypingIndicator.vue'
import { useChatMentions } from '../../composables/useChatMentions.ts'
import { useGetThreadId } from '../../composables/useGetThreadId.ts'
import { useTemporaryMessage } from '../../composables/useTemporaryMessage.ts'
import { CONVERSATION, PARTICIPANT, PRIVACY } from '../../constants.ts'
import BrowserStorage from '../../services/BrowserStorage.js'
Expand All @@ -214,6 +230,7 @@ export default {
NcEmojiPicker,
NcNoteCard,
NcRichContenteditable,
NcTextField,
NewMessageAbsenceInfo,
NewMessageAttachments,
NewMessageAudioRecorder,
Expand All @@ -226,6 +243,7 @@ export default {
IconCheck,
IconClose,
IconEmoticonOutline,
IconForumOutline,
IconSend,
},

Expand Down Expand Up @@ -288,6 +306,7 @@ export default {
const { token } = toRefs(props)
const supportTypingStatus = getTalkConfig(token.value, 'chat', 'typing-privacy') !== undefined
const { autoComplete, userData } = useChatMentions(token)
const threadId = useGetThreadId()
const { createTemporaryMessage } = useTemporaryMessage()
return {
actorStore: useActorStore(),
Expand All @@ -299,13 +318,15 @@ export default {
supportTypingStatus,
autoComplete,
userData,
threadId,
createTemporaryMessage,
}
},

data() {
return {
text: '',
errorTitle: '',
silentChat: false,
// True when the audio recorder component is recording
isRecordingAudio: false,
Expand Down Expand Up @@ -357,11 +378,10 @@ export default {
},

sendMessageLabel() {
if (this.silentChat) {
return t('spreed', 'Send message silently')
} else {
return t('spreed', 'Send message')
if (this.threadCreating) {
return this.silentChat ? t('spreed', 'Create a thread silently') : t('spreed', 'Create a thread')
}
return this.silentChat ? t('spreed', 'Send message silently') : t('spreed', 'Send message')
},

parentMessage() {
Expand Down Expand Up @@ -430,7 +450,7 @@ export default {
},

showAudioRecorder() {
return !this.hasText && this.canUploadFiles && !this.broadcast && !this.upload && !this.messageToEdit
return !this.hasText && this.canUploadFiles && !this.broadcast && !this.upload && !this.messageToEdit && !this.threadCreating
},

showTypingStatus() {
Expand Down Expand Up @@ -466,6 +486,29 @@ export default {
canEditMessage() {
return hasTalkFeature(this.token, 'edit-messages')
},

supportThreads() {
return hasTalkFeature(this.token, 'threads')
},

canCreateThread() {
return this.supportThreads && !this.isReadOnly && !this.noChatPermission
&& !this.threadId && !this.broadcast && !this.threadCreating
},

threadTitle: {
get() {
return this.chatExtrasStore.getThreadTitle(this.token)
},

set(value) {
this.chatExtrasStore.setThreadTitle(this.token, value)
},
},

threadCreating() {
return this.threadTitle !== undefined
},
},

watch: {
Expand All @@ -485,6 +528,7 @@ export default {
},

text(newValue) {
this.errorTitle = ''
if (this.currentUploadId && !this.upload) {
return
} else if (this.dialog && this.broadcast) {
Expand All @@ -496,15 +540,27 @@ export default {
messageToEdit(newValue) {
if (newValue) {
this.text = this.chatExtrasStore.getChatEditInput(this.token)
this.chatExtrasStore.removeParentIdToReply(this.token)
this.chatExtrasStore.removeThreadTitle(this.token)
if (this.parentMessage) {
this.chatExtrasStore.removeParentIdToReply(this.token)
}
} else {
this.text = this.chatInput
}
},

parentMessage(newValue) {
if (newValue && this.messageToEdit) {
this.chatExtrasStore.removeMessageIdToEdit(this.token)
if (newValue) {
this.chatExtrasStore.removeThreadTitle(this.token)
if (this.messageToEdit) {
this.chatExtrasStore.removeMessageIdToEdit(this.token)
}
}
},

threadId(newValue) {
if (newValue) {
this.setCreateThread(false)
}
},

Expand Down Expand Up @@ -649,43 +705,55 @@ export default {

if (this.hasText) {
this.text = parseSpecialSymbols(this.text)
} else if (this.threadCreating && !this.hasText) {
// TRANSLATORS Error indicator: do not allow to create a thread without a message text
this.errorTitle = t('spreed', 'Message text is required')
return
}

// Clear input content from store
this.debouncedUpdateChatInput.clear()
this.chatExtrasStore.removeChatInput(this.token)

if (this.hasText || (this.dialog && this.upload)) {
const message = this.text.trim()
// Substitute thread title with message text, if missing
const threadTitle = this.threadCreating
? this.threadTitle.trim() || this.text.trim()
: undefined

const temporaryMessage = this.createTemporaryMessage({
message: this.text.trim(),
message,
token: this.token,
silent: this.silentChat,
})
this.text = ''
this.chatExtrasStore.removeThreadTitle(this.token)

// 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
this.chatExtrasStore.removeParentIdToReply(this.token)

this.dialog
? await this.submitMessage(this.token, temporaryMessage)
: await this.postMessage(this.token, temporaryMessage)
? await this.submitMessage(this.token, temporaryMessage, threadTitle)
: await this.postMessage(this.token, temporaryMessage, threadTitle)
this.resetTypingIndicator()
}
},

// Post message to conversation
async postMessage(token, temporaryMessage) {
async postMessage(token, temporaryMessage, threadTitle) {
try {
await this.$store.dispatch('postNewMessage', { token, temporaryMessage })
await this.$store.dispatch('postNewMessage', { token, temporaryMessage, threadTitle })
} catch (e) {
console.error(e)
}
},

// Broadcast message to all breakout rooms
async submitMessage(token, temporaryMessage) {
this.$emit('submit', { token, temporaryMessage })
async submitMessage(token, temporaryMessage, threadTitle) {
this.$emit('submit', { token, temporaryMessage, threadTitle })
},

async handleSubmitSpam(numberOfMessages) {
Expand Down Expand Up @@ -741,6 +809,16 @@ export default {
}
},

setCreateThread(value) {
if (value) {
this.chatExtrasStore.setThreadTitle(this.token, '')
this.chatExtrasStore.removeParentIdToReply(this.token)
this.chatExtrasStore.removeMessageIdToEdit(this.token)
} else {
this.chatExtrasStore.removeThreadTitle(this.token)
}
},

async showFilePicker() {
const filePicker = getFilePickerBuilder(t('spreed', 'File to share'))
.setMultiSelect(true)
Expand Down Expand Up @@ -1000,6 +1078,25 @@ export default {
border-radius: var(--border-radius-large);
}

&__thread-title {
margin-bottom: var(--default-grid-baseline);

// Override input style to match NcRichContenteditable
:deep(.input-field__input) {
border: 2px solid var(--color-border-maxcontrast);
}

& + :deep(.rich-contenteditable > .rich-contenteditable__input) {
min-height: calc(2lh + 2 * var(--contenteditable-block-offset) + 4px);
}
}

&__input-rich {
&--required :deep(.rich-contenteditable__input) {
border-color: var(--color-error) !important;
}
}

// put a grey round background when popover is opened or hover-focused
&__icon:hover,
&__icon:focus,
Expand Down
19 changes: 18 additions & 1 deletion src/components/NewMessage/NewMessageAttachments.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@
{{ t('spreed', 'Create new poll') }}
</NcActionButton>

<NcActionButton
v-if="canCreateThread"
close-after-click
@click="$emit('createThread', true)">
<template #icon>
<IconForumOutline :size="16" />
</template>
{{ t('spreed', 'Create a thread') }}
</NcActionButton>

<NcActionButton close-after-click
@click="showSmartPicker">
<template #icon>
Expand All @@ -68,6 +78,7 @@ import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import IconFolder from 'vue-material-design-icons/Folder.vue' // Filled as in Files app icon
import IconForumOutline from 'vue-material-design-icons/ForumOutline.vue'
import IconPlus from 'vue-material-design-icons/Plus.vue'
import IconPoll from 'vue-material-design-icons/Poll.vue'
import IconFileUpload from '../../../img/material-icons/file-upload.svg?raw'
Expand All @@ -83,6 +94,7 @@ export default {
NcIconSvgWrapper,
// Icons
IconFolder,
IconForumOutline,
IconPlus,
IconPoll,
},
Expand Down Expand Up @@ -112,9 +124,14 @@ export default {
type: Boolean,
required: true,
},

canCreateThread: {
type: Boolean,
required: true,
},
},

emits: ['updateNewFileDialog', 'openFileUpload', 'handleFileShare'],
emits: ['updateNewFileDialog', 'openFileUpload', 'handleFileShare', 'createThread'],

setup() {
return {
Expand Down
9 changes: 6 additions & 3 deletions src/components/NewMessage/NewMessageUploadEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -193,21 +193,24 @@ export default {
})
},

async handleUpload({ token, temporaryMessage }) {
async handleUpload({ token, temporaryMessage, threadTitle }) {
if (this.files.length) {
// Create a share with optional caption
await this.$store.dispatch('uploadFiles', {
token,
uploadId: this.currentUploadId,
caption: temporaryMessage.message,
options: { silent: temporaryMessage.silent },
options: {
threadTitle,
silent: temporaryMessage.silent,
},
})
} else {
this.$store.dispatch('discardUpload', this.currentUploadId)
if (temporaryMessage.message.trim()) {
// Proceed as a normal message
try {
await this.$store.dispatch('postNewMessage', { token, temporaryMessage })
await this.$store.dispatch('postNewMessage', { token, temporaryMessage, threadTitle })
} catch (e) {
console.error(e)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,9 @@ export default {
this.showParticipants = !this.showParticipants
},

async sentMessageToRoom({ token, temporaryMessage, options }) {
async sentMessageToRoom({ token, temporaryMessage, threadTitle, options }) {
try {
await this.$store.dispatch('postNewMessage', { token, temporaryMessage, options })
await this.$store.dispatch('postNewMessage', { token, temporaryMessage, threadTitle, options })
showSuccess(t('spreed', 'The message was sent to "{roomName}"', { roomName: this.roomName }))
this.isDialogOpened = false
} catch (e) {
Expand Down
Loading