Merge pull request #16475 from nextcloud/fix/noid/unread-marker

This commit is contained in:
Maksim Sukharev 2025-12-09 10:28:10 +01:00 committed by GitHub
commit f8778c9734
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 124 additions and 237 deletions

View file

@ -7,7 +7,6 @@
<li
:id="`message_${message.id}`"
:data-message-id="message.id"
:data-seen="seen"
:data-next-message-id="nextMessageId"
:data-previous-message-id="previousMessageId"
class="message"
@ -81,21 +80,6 @@
:message="message.message"
:rich-parameters="richParameters"
@close="isTranslateDialogOpen = false" />
<div
v-if="isLastReadMessage"
v-intersection-observer="lastReadMessageVisibilityChanged"
class="message-unread-marker">
<div class="message-unread-marker__wrapper">
<span class="message-unread-marker__text">{{ t('spreed', 'Unread messages') }}</span>
<NcAssistantButton
v-if="shouldShowSummaryOption"
:disabled="loading"
@click="generateSummary">
{{ t('spreed', 'Generate summary') }}
</NcAssistantButton>
</div>
</div>
</li>
</template>
@ -103,9 +87,7 @@
import { showError, showSuccess, showWarning, TOAST_DEFAULT_TIMEOUT } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile'
import { vIntersectionObserver as IntersectionObserver } from '@vueuse/components'
import { inject, ref } from 'vue'
import NcAssistantButton from '@nextcloud/vue/components/NcAssistantButton'
import { inject } from 'vue'
import MessageButtonsBar from './MessageButtonsBar/MessageButtonsBar.vue'
import MessageForwarder from './MessageButtonsBar/MessageForwarder.vue'
import MessageTranslateDialog from './MessageButtonsBar/MessageTranslateDialog.vue'
@ -120,15 +102,12 @@ import PollCard from './MessagePart/PollCard.vue'
import ReactionsWrapper from './MessagePart/ReactionsWrapper.vue'
import { useGetThreadId } from '../../../../composables/useGetThreadId.ts'
import { CONVERSATION, MENTION, MESSAGE, PARTICIPANT } from '../../../../constants.ts'
import { getTalkConfig, hasTalkFeature } from '../../../../services/CapabilitiesManager.ts'
import { getTalkConfig } from '../../../../services/CapabilitiesManager.ts'
import { EventBus } from '../../../../services/EventBus.ts'
import { useActorStore } from '../../../../stores/actor.ts'
import { useChatExtrasStore } from '../../../../stores/chatExtras.ts'
import { getItemTypeFromMessage } from '../../../../utils/getItemTypeFromMessage.ts'
const canSummarizeChat = hasTalkFeature('local', 'chat-summary-api')
const summaryThreshold = getTalkConfig('local', 'chat', 'summary-threshold') ?? 0
export default {
name: 'MessageItem',
@ -137,14 +116,9 @@ export default {
MessageButtonsBar,
MessageForwarder,
MessageTranslateDialog,
NcAssistantButton,
ReactionsWrapper,
},
directives: {
IntersectionObserver,
},
props: {
message: {
type: Object,
@ -188,11 +162,8 @@ export default {
data() {
return {
loading: false,
isHovered: false,
isDeleting: false,
// whether the message was seen, only used if this was marked as last read message
seen: false,
isActionMenuOpen: false,
// Right side bottom bar
isEmojiPickerOpen: false,
@ -209,30 +180,6 @@ export default {
return this.message.timestamp === 0
},
isLastMessage() {
// never displayed for the very last message
return !this.nextMessageId || this.message.id === this.conversation?.lastMessage?.id
},
visualLastLastReadMessageId() {
return this.$store.getters.getVisualLastReadMessageId(this.message.token)
},
isLastReadMessage() {
if (this.isLastMessage) {
return false
}
return this.message.id === this.visualLastLastReadMessageId
},
shouldShowSummaryOption() {
if (this.conversation.remoteServer || !canSummarizeChat || this.chatExtrasStore.hasChatSummaryTaskRequested(this.message.token)) {
return false
}
return (this.conversation.unreadMessages >= summaryThreshold)
},
isDeletedMessage() {
return this.message.messageType === MESSAGE.TYPE.COMMENT_DELETED
},
@ -365,11 +312,6 @@ export default {
methods: {
t,
lastReadMessageVisibilityChanged([{ isIntersecting }]) {
if (isIntersecting) {
this.seen = true
}
},
handleMouseover() {
if (!this.isHovered) {
@ -435,12 +377,6 @@ export default {
toggleFollowUpEmojiPicker() {
this.isFollowUpEmojiPickerOpen = !this.isFollowUpEmojiPickerOpen
},
async generateSummary() {
this.loading = true
await this.chatExtrasStore.requestChatSummary(this.message.token, this.message.id)
this.loading = false
},
},
}
</script>
@ -569,39 +505,6 @@ export default {
100% { background-color: rgba(var(--color-background-hover), 0); }
}
.message-unread-marker {
position: relative;
margin: calc(4 * var(--default-grid-baseline));
&::before {
content: '';
width: 100%;
border-top: 1px solid var(--color-border-maxcontrast);
position: absolute;
top: 50%;
z-index: -1;
}
&__wrapper {
display: flex;
justify-content: center;
align-items: center;
gap: calc(3 * var(--default-grid-baseline));
margin-inline: auto;
padding-inline: calc(3 * var(--default-grid-baseline));
width: fit-content;
border-radius: var(--border-radius);
background-color: var(--color-main-background);
}
&__text {
text-align: center;
white-space: nowrap;
font-weight: bold;
color: var(--color-main-text);
}
}
.message-buttons-bar {
display: flex;
inset-inline-end: 14px;

View file

@ -7,7 +7,6 @@
<li
:id="`message_${message.id}`"
:data-message-id="message.id"
:data-seen="seen"
:data-next-message-id="nextMessageId"
:data-previous-message-id="previousMessageId"
class="message">
@ -40,28 +39,11 @@
</NcButton>
</div>
</div>
<div
v-if="isLastReadMessage"
v-intersection-observer="lastReadMessageVisibilityChanged"
class="message-unread-marker">
<div class="message-unread-marker__wrapper">
<span class="message-unread-marker__text">{{ t('spreed', 'Unread messages') }}</span>
<NcAssistantButton
v-if="shouldShowSummaryOption"
:disabled="loading"
@click="generateSummary">
{{ t('spreed', 'Generate summary') }}
</NcAssistantButton>
</div>
</div>
</li>
</template>
<script>
import { t } from '@nextcloud/l10n'
import { vIntersectionObserver as IntersectionObserver } from '@vueuse/components'
import NcAssistantButton from '@nextcloud/vue/components/NcAssistantButton'
import NcButton from '@nextcloud/vue/components/NcButton'
import IconUnfoldLessHorizontal from 'vue-material-design-icons/UnfoldLessHorizontal.vue'
import IconUnfoldMoreHorizontal from 'vue-material-design-icons/UnfoldMoreHorizontal.vue'
@ -69,11 +51,6 @@ import DefaultParameter from './MessagePart/DefaultParameter.vue'
import MentionChip from './MessagePart/MentionChip.vue'
import MessageBody from './MessagePart/MessageBody.vue'
import { MENTION } from '../../../../constants.ts'
import { getTalkConfig, hasTalkFeature } from '../../../../services/CapabilitiesManager.ts'
import { useChatExtrasStore } from '../../../../stores/chatExtras.ts'
const canSummarizeChat = hasTalkFeature('local', 'chat-summary-api')
const summaryThreshold = getTalkConfig('local', 'chat', 'summary-threshold') ?? 0
export default {
name: 'MessageItem',
@ -82,14 +59,9 @@ export default {
IconUnfoldLessHorizontal,
IconUnfoldMoreHorizontal,
MessageBody,
NcAssistantButton,
NcButton,
},
directives: {
IntersectionObserver,
},
props: {
message: {
type: Object,
@ -138,49 +110,7 @@ export default {
emits: ['toggleCombinedSystemMessage'],
setup() {
return {
chatExtrasStore: useChatExtrasStore(),
}
},
data() {
return {
loading: false,
// whether the message was seen, only used if this was marked as last read message
seen: false,
}
},
computed: {
isLastMessage() {
// never displayed for the very last message
return !this.nextMessageId || this.message.id === this.conversation?.lastMessage?.id
},
visualLastLastReadMessageId() {
return this.$store.getters.getVisualLastReadMessageId(this.message.token)
},
isLastReadMessage() {
if (this.isLastMessage) {
return false
}
if (this.message.id === this.visualLastLastReadMessageId) {
return !this.isCollapsedSystemMessage || this.message.id !== this.lastCollapsedMessageId
}
return this.isCombinedSystemMessage && this.lastCollapsedMessageId === this.visualLastLastReadMessageId
},
shouldShowSummaryOption() {
if (this.conversation.remoteServer || !canSummarizeChat || this.chatExtrasStore.hasChatSummaryTaskRequested(this.message.token)) {
return false
}
return (this.conversation.unreadMessages >= summaryThreshold)
},
conversation() {
return this.$store.getters.conversation(this.message.token)
},
@ -210,21 +140,10 @@ export default {
methods: {
t,
lastReadMessageVisibilityChanged([{ isIntersecting }]) {
if (isIntersecting) {
this.seen = true
}
},
toggleCombinedSystemMessage() {
this.$emit('toggleCombinedSystemMessage')
},
async generateSummary() {
this.loading = true
await this.chatExtrasStore.requestChatSummary(this.message.token, this.message.id)
this.loading = false
},
},
}
</script>
@ -262,39 +181,6 @@ export default {
}
}
.message-unread-marker {
position: relative;
margin: calc(4 * var(--default-grid-baseline));
&::before {
content: '';
width: 100%;
border-top: 1px solid var(--color-border-maxcontrast);
position: absolute;
top: 50%;
z-index: -1;
}
&__wrapper {
display: flex;
justify-content: center;
align-items: center;
gap: calc(3 * var(--default-grid-baseline));
margin-inline: auto;
padding-inline: calc(3 * var(--default-grid-baseline));
width: fit-content;
border-radius: var(--border-radius);
background-color: var(--color-main-background);
}
&__text {
text-align: center;
white-space: nowrap;
font-weight: bold;
color: var(--color-main-text);
}
}
.message-buttons-bar {
background-color: var(--color-main-background);
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));

View file

@ -91,12 +91,6 @@ export default {
}
},
computed: {
lastReadMessageId() {
return this.$store.getters.conversation(this.token)?.lastReadMessage
},
},
watch: {
messages: {
deep: true,
@ -106,10 +100,6 @@ export default {
this.updateCollapsedState()
},
},
lastReadMessageId() {
this.updateCollapsedState()
},
},
methods: {
@ -188,11 +178,7 @@ export default {
updateCollapsedState() {
for (const group of this.messagesGroupedBySystemMessage) {
const isLastReadInsideGroup = this.lastReadMessageId >= group.id && this.lastReadMessageId < group.lastId
if (isLastReadInsideGroup) {
// If the last read message is inside the group, we should show the group expanded
group.collapsed = false
} else if (this.groupIsCollapsed[group.id] !== undefined) {
if (this.groupIsCollapsed[group.id] !== undefined) {
// If the group was collapsed before, we should keep it collapsed
group.collapsed = this.groupIsCollapsed[group.id]
} else {

View file

@ -51,14 +51,30 @@
role="heading"
aria-level="3" />
</li>
<component
:is="messagesGroupComponent[group.type]"
<template
v-for="group in list"
:key="group.id"
:token="token"
:messages="group.messages"
:previous-message-id="group.previousMessageId"
:next-message-id="group.nextMessageId" />
:key="group.id">
<component
:is="messagesGroupComponent[group.type]"
:token="token"
:messages="group.messages"
:previous-message-id="group.previousMessageId"
:next-message-id="group.nextMessageId" />
<div
v-if="isLastReadMessage(group)"
v-intersection-observer="lastReadMessageVisibilityChanged"
class="message-unread-marker">
<div class="message-unread-marker__wrapper">
<span class="message-unread-marker__text">{{ t('spreed', 'Unread messages') }}</span>
<NcAssistantButton
v-if="shouldShowSummaryOption"
:disabled="loadingSummary"
@click="generateSummary">
{{ t('spreed', 'Generate summary') }}
</NcAssistantButton>
</div>
</div>
</template>
</ul>
<TransitionWrapper name="fade">
@ -75,8 +91,10 @@
<script>
import { n, t } from '@nextcloud/l10n'
import { vIntersectionObserver as IntersectionObserver } from '@vueuse/components'
import debounce from 'debounce'
import { computed, provide } from 'vue'
import { computed, provide, ref } from 'vue'
import NcAssistantButton from '@nextcloud/vue/components/NcAssistantButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconMessageOutline from 'vue-material-design-icons/MessageOutline.vue'
@ -90,6 +108,7 @@ import { useGetMessages } from '../../composables/useGetMessages.ts'
import { useGetThreadId } from '../../composables/useGetThreadId.ts'
import { ATTENDEE, CONVERSATION } from '../../constants.ts'
import { CHAT_STYLE } from '../../constants.ts'
import { getTalkConfig, hasTalkFeature } from '../../services/CapabilitiesManager.ts'
import { EventBus } from '../../services/EventBus.ts'
import { useChatStore } from '../../stores/chat.ts'
import { useChatExtrasStore } from '../../stores/chatExtras.ts'
@ -98,6 +117,8 @@ import { convertToUnix } from '../../utils/formattedTime.ts'
const SCROLL_TOLERANCE = 10
const LOAD_HISTORY_THRESHOLD = 800
const canSummarizeChat = hasTalkFeature('local', 'chat-summary-api')
const summaryThreshold = getTalkConfig('local', 'chat', 'summary-threshold') ?? 0
const messagesGroupComponent = {
system: MessagesSystemGroup,
@ -110,11 +131,16 @@ export default {
IconMessageOutline,
LoadingPlaceholder,
NcEmptyContent,
NcAssistantButton,
NcLoadingIcon,
StaticDateTime,
TransitionWrapper,
},
directives: {
IntersectionObserver,
},
provide() {
return {
getMessagesListScroller: () => this.$refs.scroller,
@ -206,6 +232,10 @@ export default {
stickyDate: null,
endScrollTimeout: () => {},
isUnreadMarkerSeen: false,
loadingSummary: false,
}
},
@ -255,6 +285,13 @@ export default {
currentDay() {
return convertToUnix(new Date().setHours(0, 0, 0, 0))
},
shouldShowSummaryOption() {
if (this.conversation.remoteServer || !canSummarizeChat || this.chatExtrasStore.hasChatSummaryTaskRequested(this.token)) {
return false
}
return (this.conversation.unreadMessages >= summaryThreshold)
},
},
watch: {
@ -277,6 +314,7 @@ export default {
token(newToken, oldToken) {
// Expire older messages when navigating to another conversation
this.$store.dispatch('easeMessageList', { token: oldToken })
this.isUnreadMarkerSeen = false
},
messagesList: {
@ -323,6 +361,16 @@ export default {
})
}
},
visualLastReadMessageId(newValue, oldValue) {
if (newValue === oldValue) {
return
}
const newGroups = this.prepareMessagesGroups(this.messagesList)
this.softUpdateByDateGroups(this.messagesGroupedByDateByAuthor, newGroups)
this.isUnreadMarkerSeen = false
},
},
mounted() {
@ -488,6 +536,12 @@ export default {
return false // No previous message
}
// If there is last read message visually, the group ends there
if ((message1.id === this.visualLastReadMessageId && message2.id > message1.id)
|| (message2.id === this.visualLastReadMessageId && message1.id > message2.id)) {
return false
}
if (!!message1.lastEditTimestamp || !!message2.lastEditTimestamp) {
return false // Edited messages are not grouped
}
@ -813,7 +867,7 @@ export default {
const lastReadMessageElement = this.getVisualLastReadMessageElement()
// first unread message has not been seen yet, so don't move it
if (lastReadMessageElement && lastReadMessageElement.getAttribute('data-seen') !== 'true') {
if (!this.isUnreadMarkerSeen) {
return
}
@ -1036,6 +1090,31 @@ export default {
this.debounceHandleScroll({ skipHeightCheck: true })
}
},
isLastReadMessage(group) {
if (!group.nextMessageId) {
return false
}
const message = group.messages.at(-1)
if (this.conversation.lastMessage && message.id >= this.conversation.lastMessage?.id) {
return false
}
return message.id === this.visualLastReadMessageId
},
lastReadMessageVisibilityChanged([{ isIntersecting }]) {
if (isIntersecting) {
this.isUnreadMarkerSeen = true
}
},
async generateSummary() {
this.loadingSummary = true
await this.chatExtrasStore.requestChatSummary(this.token, this.visualLastReadMessageId)
this.loadingSummary = false
},
},
}
</script>
@ -1139,4 +1218,37 @@ export default {
opacity: 1;
transition: opacity 0s;
}
.message-unread-marker {
position: relative;
margin: calc(4 * var(--default-grid-baseline));
&::before {
content: '';
width: 100%;
border-top: 1px solid var(--color-border-maxcontrast);
position: absolute;
top: 50%;
z-index: -1;
}
&__wrapper {
display: flex;
justify-content: center;
align-items: center;
gap: calc(3 * var(--default-grid-baseline));
margin-inline: auto;
padding-inline: calc(3 * var(--default-grid-baseline));
width: fit-content;
border-radius: var(--border-radius);
background-color: var(--color-main-background);
}
&__text {
text-align: center;
white-space: nowrap;
font-weight: bold;
color: var(--color-main-text);
}
}
</style>