diff --git a/src/assets/variables.scss b/src/assets/variables.scss index 67c9f7e9917..ca57566353e 100644 --- a/src/assets/variables.scss +++ b/src/assets/variables.scss @@ -6,13 +6,13 @@ /** Messages list dimensions: * - text max width: ~750px (80 characters per line is recommended by W3C standard) * - avatar width: 32px (AVATAR.SIZE.SMALL) + 16px (paddings) = 48px - * - info width: 8ch(~68px) (timestamp) + 40px (checkmark with paddings) = ~108px + * - info width: 8ch(~68px) (timestamp) + 40px (checkmark with paddings) + 2px (gap) = ~108px * - list max width: 48px (avatar width) + 1058px (text width with paddings) + ~108px (info width) = ~1214px * - input max width: ~1214px (list max width) - 100px (send button) = ~1114px */ $messages-text-max-width: calc(50 * var(--default-font-size)); $messages-avatar-width: calc(32px + 4 * var(--default-grid-baseline)); -$messages-info-width: calc(8ch + var(--clickable-area-small, 24px) + 4 * var(--default-grid-baseline)); +$messages-info-width: calc(8ch + var(--clickable-area-small, 24px) + 4 * var(--default-grid-baseline) + 2px); $messages-list-max-width: calc($messages-avatar-width + $messages-text-max-width + 2 * var(--default-grid-baseline) + $messages-info-width); $messages-input-max-width: calc($messages-list-max-width - 100px); diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue index e2f38803e54..a0a643f01cc 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue @@ -66,13 +66,22 @@ {{ editedDateTime }} + + + {{ pinDurationLabel || pinAuthorLabel }} + {{ t('spreed', 'Set reminder') }} + + @@ -341,6 +418,7 @@ import { emojiSearch } from '@nextcloud/vue/functions/emoji' import { vOnClickOutside as ClickOutside } from '@vueuse/components' import { toRefs } from 'vue' import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActionCaption from '@nextcloud/vue/components/NcActionCaption' import NcActionInput from '@nextcloud/vue/components/NcActionInput' import NcActionLink from '@nextcloud/vue/components/NcActionLink' import NcActions from '@nextcloud/vue/components/NcActions' @@ -370,6 +448,8 @@ import IconForumOutline from 'vue-material-design-icons/ForumOutline.vue' import IconNoteEditOutline from 'vue-material-design-icons/NoteEditOutline.vue' import IconOpenInNew from 'vue-material-design-icons/OpenInNew.vue' import IconPencilOutline from 'vue-material-design-icons/PencilOutline.vue' +import IconUnpin from 'vue-material-design-icons/PinOffOutline.vue' +import IconPin from 'vue-material-design-icons/PinOutline.vue' import IconPlus from 'vue-material-design-icons/Plus.vue' import IconTranslate from 'vue-material-design-icons/Translate.vue' import IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue' @@ -383,17 +463,26 @@ import { useActorStore } from '../../../../../stores/actor.ts' import { useChatExtrasStore } from '../../../../../stores/chatExtras.ts' import { useIntegrationsStore } from '../../../../../stores/integrations.js' import { useReactionsStore } from '../../../../../stores/reactions.js' +import { useSharedItemsStore } from '../../../../../stores/sharedItems.ts' import { generatePublicShareDownloadUrl, generateUserFileUrl } from '../../../../../utils/davUtils.ts' -import { convertToUnix, formatDateTime } from '../../../../../utils/formattedTime.ts' +import { convertToUnix, formatDateTime, ONE_DAY_IN_MS } from '../../../../../utils/formattedTime.ts' import { getCustomDateOptions } from '../../../../../utils/getCustomDateOptions.ts' import { copyConversationLinkToClipboard } from '../../../../../utils/handleUrl.ts' import { parseMentions } from '../../../../../utils/textParse.ts' +const PIN_DURATION_OPTIONS = [ + { key: '24_hours', label: t('spreed', '24 hours'), value: { duration: ONE_DAY_IN_MS } }, + { key: '7_days', label: t('spreed', '7 days'), value: { duration: 7 * ONE_DAY_IN_MS } }, + { key: '30_days', label: t('spreed', '30 days'), value: { duration: 30 * ONE_DAY_IN_MS } }, + { key: 'indefinitely', label: t('spreed', 'Until unpin'), value: { custom: 0 } }, +] + export default { name: 'MessageButtonsBar', components: { NcActionButton, + NcActionCaption, NcActionInput, NcActionLink, NcActionSeparator, @@ -423,10 +512,12 @@ export default { IconNoteEditOutline, IconOpenInNew, IconPencilOutline, + IconPin, IconPlus, IconArrowLeftTop, IconArrowRightTop, IconTranslate, + IconUnpin, }, directives: { @@ -491,6 +582,7 @@ export default { const actorStore = useActorStore() const chatExtrasStore = useChatExtrasStore() const threadId = useGetThreadId() + const sharedItemsStore = useSharedItemsStore() const { isEditable, @@ -504,6 +596,7 @@ export default { } = useMessageInfo(message) const supportReminders = hasTalkFeature(message.value.token, 'remind-me-later') const supportThreads = hasTalkFeature(message.value.token, 'threads') + const supportPinMessage = hasTalkFeature(message.value.token, 'pinned-messages') return { IconFileDownload, @@ -523,6 +616,9 @@ export default { chatExtrasStore, threadId, getCustomDateOptions, + sharedItemsStore, + supportPinMessage, + PIN_DURATION_OPTIONS, } }, @@ -532,6 +628,7 @@ export default { submenu: null, currentReminder: null, customReminderTimestamp: new Date().setHours(new Date().getHours() + 2, 0, 0, 0), + customPinTimestamp: new Date().setMinutes(new Date().getMinutes() + 5, 0, 0, 0), } }, @@ -608,6 +705,18 @@ export default { }, }, + customPinDateTime: { + get() { + return new Date(this.customPinTimestamp) + }, + + set(value) { + if (value !== null) { + this.customPinTimestamp = value.valueOf() + } + }, + }, + clearReminderLabel() { if (!this.currentReminder) { return '' @@ -632,8 +741,41 @@ export default { && this.message.id === this.message.threadId }, + isModerator() { + return this.$store.getters.isModerator + }, + isModeratorOrOwner() { - return this.isCurrentUserOwnMessage || this.$store.getters.isModerator + return this.isCurrentUserOwnMessage || this.isModerator + }, + + pinInfo() { + return this.message.metaData + }, + + isMessagePinned() { + return !!this.pinInfo?.pinnedAt + }, + + pinAuthorLabel() { + if (!this.pinInfo) { + return '' + } + if (this.actorStore.checkIfSelfIsActor({ actorId: this.pinInfo.pinnedActorId, actorType: this.pinInfo.pinnedActorType })) { + return t('spreed', 'Pinned by you') + } + return t('spreed', 'Pinned by {actor}', { + actor: this.pinInfo.pinnedActorDisplayName, + }) + }, + + pinDurationLabel() { + if (!this.isMessagePinned || !this.pinInfo || !this.pinInfo.pinnedUntil) { + return '' + } + + const absoluteDate = formatDateTime(this.pinInfo.pinnedUntil * 1000, 'shortDateWithTime') + return t('spreed', 'Until {absoluteDate}', { absoluteDate }) }, }, @@ -812,12 +954,31 @@ export default { } this.$emit('edit') }, + + pinMessage({ duration = null, custom = null }) { + const timestamp = custom ?? (Date.now() + duration) + this.sharedItemsStore.handlePinMessage(this.message.token, this.message.id, convertToUnix(timestamp)) + }, + + unpinMessage() { + this.sharedItemsStore.handleUnpinMessage(this.message.token, this.message.id) + }, }, } diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js b/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js index bd96e565c6c..5bcb1aabeba 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js +++ b/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js @@ -30,10 +30,19 @@ import storeConfig from '../../../../store/storeConfig.js' import { useActorStore } from '../../../../stores/actor.ts' import { useTokenStore } from '../../../../stores/token.ts' +let store + +vi.mock('vuex', async () => { + const vuex = await vi.importActual('vuex') + return { + ...vuex, + useStore: vi.fn(() => store), + } +}) + describe('MessageItem.vue', () => { const TOKEN = 'XXTOKENXX' let testStoreConfig - let store let messageProps let conversationProps let injected diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue b/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue index 0547e13be6b..63b110d5004 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue @@ -12,6 +12,7 @@ class="message" :class="{ 'message--hovered': showMessageButtonsBar, + 'message--pinned': !isSplitViewEnabled && isPinned, 'message--sided': isSplitViewEnabled, 'message--small-view': (isSmallMobile || isSidebar) && isSplitViewEnabled, }" @@ -67,6 +68,13 @@ @reply="handleReply" @edit="handleEdit" @delete="handleDelete" /> +
+ +
+
+ +
({ removeReactionFromMessage: vi.fn(), })) +vi.mock('vuex', async () => { + const vuex = await vi.importActual('vuex') + return { + ...vuex, + useStore: vi.fn(), + } +}) + describe('ReactionsWrapper.vue', () => { let reactionsStore let token diff --git a/src/components/MessagesList/MessagesList.vue b/src/components/MessagesList/MessagesList.vue index b0aef134d65..ae769699889 100644 --- a/src/components/MessagesList/MessagesList.vue +++ b/src/components/MessagesList/MessagesList.vue @@ -13,6 +13,9 @@ }" @scroll="onScroll" @scrollend="endScroll"> +