diff --git a/src/components/NewMessage/NewMessage.vue b/src/components/NewMessage/NewMessage.vue
index 168e4b4316c..d2c63d4084d 100644
--- a/src/components/NewMessage/NewMessage.vue
+++ b/src/components/NewMessage/NewMessage.vue
@@ -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" />
@@ -72,9 +74,19 @@
+
-
+
+
@@ -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'
@@ -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'
@@ -214,6 +230,7 @@ export default {
NcEmojiPicker,
NcNoteCard,
NcRichContenteditable,
+ NcTextField,
NewMessageAbsenceInfo,
NewMessageAttachments,
NewMessageAudioRecorder,
@@ -226,6 +243,7 @@ export default {
IconCheck,
IconClose,
IconEmoticonOutline,
+ IconForumOutline,
IconSend,
},
@@ -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(),
@@ -299,6 +318,7 @@ export default {
supportTypingStatus,
autoComplete,
userData,
+ threadId,
createTemporaryMessage,
}
},
@@ -306,6 +326,7 @@ export default {
data() {
return {
text: '',
+ errorTitle: '',
silentChat: false,
// True when the audio recorder component is recording
isRecordingAudio: false,
@@ -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() {
@@ -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() {
@@ -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: {
@@ -485,6 +528,7 @@ export default {
},
text(newValue) {
+ this.errorTitle = ''
if (this.currentUploadId && !this.upload) {
return
} else if (this.dialog && this.broadcast) {
@@ -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)
}
},
@@ -649,6 +705,10 @@ 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
@@ -656,36 +716,44 @@ export default {
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) {
@@ -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)
@@ -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,
diff --git a/src/components/NewMessage/NewMessageAttachments.vue b/src/components/NewMessage/NewMessageAttachments.vue
index 9aee0b56dbd..c276a9b5c0a 100644
--- a/src/components/NewMessage/NewMessageAttachments.vue
+++ b/src/components/NewMessage/NewMessageAttachments.vue
@@ -52,6 +52,16 @@
{{ t('spreed', 'Create new poll') }}
+
+
+
+
+ {{ t('spreed', 'Create a thread') }}
+
+
@@ -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'
@@ -83,6 +94,7 @@ export default {
NcIconSvgWrapper,
// Icons
IconFolder,
+ IconForumOutline,
IconPlus,
IconPoll,
},
@@ -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 {
diff --git a/src/components/NewMessage/NewMessageUploadEditor.vue b/src/components/NewMessage/NewMessageUploadEditor.vue
index 98fa8eb416d..627f17c0876 100644
--- a/src/components/NewMessage/NewMessageUploadEditor.vue
+++ b/src/components/NewMessage/NewMessageUploadEditor.vue
@@ -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)
}
diff --git a/src/components/RightSidebar/BreakoutRooms/BreakoutRoomItem.vue b/src/components/RightSidebar/BreakoutRooms/BreakoutRoomItem.vue
index edfb3eb99b3..c3dbbe24b96 100644
--- a/src/components/RightSidebar/BreakoutRooms/BreakoutRoomItem.vue
+++ b/src/components/RightSidebar/BreakoutRooms/BreakoutRoomItem.vue
@@ -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) {
diff --git a/src/components/RightSidebar/Threads/ThreadItem.vue b/src/components/RightSidebar/Threads/ThreadItem.vue
index bac4c563724..717532fce3d 100644
--- a/src/components/RightSidebar/Threads/ThreadItem.vue
+++ b/src/components/RightSidebar/Threads/ThreadItem.vue
@@ -27,26 +27,15 @@ const { thread } = defineProps<{ thread: ThreadInfo }>()
const router = useRouter()
const route = useRoute()
-const threadAuthor = computed(() => {
- if (!thread.first) {
- return
- }
- return getDisplayNameWithFallback(thread.first.actorDisplayName, thread.first.actorType, true)
-})
const lastActivity = computed(() => thread.thread.lastActivity * 1000)
-const name = computed(() => {
- if (!thread.first) {
- return t('spreed', 'Thread origin message expired')
- }
- return parseToSimpleMessage(thread.first.message, thread.first.messageParameters)
-})
const subname = computed(() => {
- if (!thread.last) {
+ const threadMessage = thread.last ?? thread.first
+ if (!threadMessage) {
return t('spreed', 'No messages')
}
- const actor = getDisplayNameWithFallback(thread.last.actorDisplayName, thread.last.actorType, true)
- const lastMessage = parseToSimpleMessage(thread.last.message, thread.last.messageParameters)
+ const actor = getDisplayNameWithFallback(threadMessage.actorDisplayName, threadMessage.actorType, true)
+ const lastMessage = parseToSimpleMessage(threadMessage.message, threadMessage.messageParameters)
return t('spreed', '{actor}: {lastMessage}', { actor, lastMessage }, {
escape: false,
@@ -77,7 +66,7 @@ const timeFormat = computed(() => {
@@ -94,12 +83,7 @@ const timeFormat = computed(() => {
:size="20" />
-
- {{ threadAuthor }}
-
- {{ name }}
+ {{ thread.thread.title }}
{{ subname }}
@@ -137,11 +121,6 @@ const timeFormat = computed(() => {
color: var(--color-text-maxcontrast);
}
- &__author {
- margin-inline-end: calc(0.5 * var(--default-grid-baseline));
- font-weight: 600;
- }
-
:deep(.list-item-content__subname) {
color: var(--color-main-text);
}
diff --git a/src/components/TopBar/TopBar.vue b/src/components/TopBar/TopBar.vue
index c9d29052339..f1374bdf4df 100644
--- a/src/components/TopBar/TopBar.vue
+++ b/src/components/TopBar/TopBar.vue
@@ -48,7 +48,7 @@
disable-tooltip />