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 <li
:id="`message_${message.id}`" :id="`message_${message.id}`"
:data-message-id="message.id" :data-message-id="message.id"
:data-seen="seen"
:data-next-message-id="nextMessageId" :data-next-message-id="nextMessageId"
:data-previous-message-id="previousMessageId" :data-previous-message-id="previousMessageId"
class="message" class="message"
@ -81,21 +80,6 @@
:message="message.message" :message="message.message"
:rich-parameters="richParameters" :rich-parameters="richParameters"
@close="isTranslateDialogOpen = false" /> @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> </li>
</template> </template>
@ -103,9 +87,7 @@
import { showError, showSuccess, showWarning, TOAST_DEFAULT_TIMEOUT } from '@nextcloud/dialogs' import { showError, showSuccess, showWarning, TOAST_DEFAULT_TIMEOUT } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n' import { t } from '@nextcloud/l10n'
import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile' import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile'
import { vIntersectionObserver as IntersectionObserver } from '@vueuse/components' import { inject } from 'vue'
import { inject, ref } from 'vue'
import NcAssistantButton from '@nextcloud/vue/components/NcAssistantButton'
import MessageButtonsBar from './MessageButtonsBar/MessageButtonsBar.vue' import MessageButtonsBar from './MessageButtonsBar/MessageButtonsBar.vue'
import MessageForwarder from './MessageButtonsBar/MessageForwarder.vue' import MessageForwarder from './MessageButtonsBar/MessageForwarder.vue'
import MessageTranslateDialog from './MessageButtonsBar/MessageTranslateDialog.vue' import MessageTranslateDialog from './MessageButtonsBar/MessageTranslateDialog.vue'
@ -120,15 +102,12 @@ import PollCard from './MessagePart/PollCard.vue'
import ReactionsWrapper from './MessagePart/ReactionsWrapper.vue' import ReactionsWrapper from './MessagePart/ReactionsWrapper.vue'
import { useGetThreadId } from '../../../../composables/useGetThreadId.ts' import { useGetThreadId } from '../../../../composables/useGetThreadId.ts'
import { CONVERSATION, MENTION, MESSAGE, PARTICIPANT } from '../../../../constants.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 { EventBus } from '../../../../services/EventBus.ts'
import { useActorStore } from '../../../../stores/actor.ts' import { useActorStore } from '../../../../stores/actor.ts'
import { useChatExtrasStore } from '../../../../stores/chatExtras.ts' import { useChatExtrasStore } from '../../../../stores/chatExtras.ts'
import { getItemTypeFromMessage } from '../../../../utils/getItemTypeFromMessage.ts' import { getItemTypeFromMessage } from '../../../../utils/getItemTypeFromMessage.ts'
const canSummarizeChat = hasTalkFeature('local', 'chat-summary-api')
const summaryThreshold = getTalkConfig('local', 'chat', 'summary-threshold') ?? 0
export default { export default {
name: 'MessageItem', name: 'MessageItem',
@ -137,14 +116,9 @@ export default {
MessageButtonsBar, MessageButtonsBar,
MessageForwarder, MessageForwarder,
MessageTranslateDialog, MessageTranslateDialog,
NcAssistantButton,
ReactionsWrapper, ReactionsWrapper,
}, },
directives: {
IntersectionObserver,
},
props: { props: {
message: { message: {
type: Object, type: Object,
@ -188,11 +162,8 @@ export default {
data() { data() {
return { return {
loading: false,
isHovered: false, isHovered: false,
isDeleting: false, isDeleting: false,
// whether the message was seen, only used if this was marked as last read message
seen: false,
isActionMenuOpen: false, isActionMenuOpen: false,
// Right side bottom bar // Right side bottom bar
isEmojiPickerOpen: false, isEmojiPickerOpen: false,
@ -209,30 +180,6 @@ export default {
return this.message.timestamp === 0 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() { isDeletedMessage() {
return this.message.messageType === MESSAGE.TYPE.COMMENT_DELETED return this.message.messageType === MESSAGE.TYPE.COMMENT_DELETED
}, },
@ -365,11 +312,6 @@ export default {
methods: { methods: {
t, t,
lastReadMessageVisibilityChanged([{ isIntersecting }]) {
if (isIntersecting) {
this.seen = true
}
},
handleMouseover() { handleMouseover() {
if (!this.isHovered) { if (!this.isHovered) {
@ -435,12 +377,6 @@ export default {
toggleFollowUpEmojiPicker() { toggleFollowUpEmojiPicker() {
this.isFollowUpEmojiPickerOpen = !this.isFollowUpEmojiPickerOpen this.isFollowUpEmojiPickerOpen = !this.isFollowUpEmojiPickerOpen
}, },
async generateSummary() {
this.loading = true
await this.chatExtrasStore.requestChatSummary(this.message.token, this.message.id)
this.loading = false
},
}, },
} }
</script> </script>
@ -569,39 +505,6 @@ export default {
100% { background-color: rgba(var(--color-background-hover), 0); } 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 { .message-buttons-bar {
display: flex; display: flex;
inset-inline-end: 14px; inset-inline-end: 14px;

View file

@ -7,7 +7,6 @@
<li <li
:id="`message_${message.id}`" :id="`message_${message.id}`"
:data-message-id="message.id" :data-message-id="message.id"
:data-seen="seen"
:data-next-message-id="nextMessageId" :data-next-message-id="nextMessageId"
:data-previous-message-id="previousMessageId" :data-previous-message-id="previousMessageId"
class="message"> class="message">
@ -40,28 +39,11 @@
</NcButton> </NcButton>
</div> </div>
</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> </li>
</template> </template>
<script> <script>
import { t } from '@nextcloud/l10n' 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 NcButton from '@nextcloud/vue/components/NcButton'
import IconUnfoldLessHorizontal from 'vue-material-design-icons/UnfoldLessHorizontal.vue' import IconUnfoldLessHorizontal from 'vue-material-design-icons/UnfoldLessHorizontal.vue'
import IconUnfoldMoreHorizontal from 'vue-material-design-icons/UnfoldMoreHorizontal.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 MentionChip from './MessagePart/MentionChip.vue'
import MessageBody from './MessagePart/MessageBody.vue' import MessageBody from './MessagePart/MessageBody.vue'
import { MENTION } from '../../../../constants.ts' 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 { export default {
name: 'MessageItem', name: 'MessageItem',
@ -82,14 +59,9 @@ export default {
IconUnfoldLessHorizontal, IconUnfoldLessHorizontal,
IconUnfoldMoreHorizontal, IconUnfoldMoreHorizontal,
MessageBody, MessageBody,
NcAssistantButton,
NcButton, NcButton,
}, },
directives: {
IntersectionObserver,
},
props: { props: {
message: { message: {
type: Object, type: Object,
@ -138,49 +110,7 @@ export default {
emits: ['toggleCombinedSystemMessage'], 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: { 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() { conversation() {
return this.$store.getters.conversation(this.message.token) return this.$store.getters.conversation(this.message.token)
}, },
@ -210,21 +140,10 @@ export default {
methods: { methods: {
t, t,
lastReadMessageVisibilityChanged([{ isIntersecting }]) {
if (isIntersecting) {
this.seen = true
}
},
toggleCombinedSystemMessage() { toggleCombinedSystemMessage() {
this.$emit('toggleCombinedSystemMessage') this.$emit('toggleCombinedSystemMessage')
}, },
async generateSummary() {
this.loading = true
await this.chatExtrasStore.requestChatSummary(this.message.token, this.message.id)
this.loading = false
},
}, },
} }
</script> </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 { .message-buttons-bar {
background-color: var(--color-main-background); background-color: var(--color-main-background);
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2)); 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: { watch: {
messages: { messages: {
deep: true, deep: true,
@ -106,10 +100,6 @@ export default {
this.updateCollapsedState() this.updateCollapsedState()
}, },
}, },
lastReadMessageId() {
this.updateCollapsedState()
},
}, },
methods: { methods: {
@ -188,11 +178,7 @@ export default {
updateCollapsedState() { updateCollapsedState() {
for (const group of this.messagesGroupedBySystemMessage) { for (const group of this.messagesGroupedBySystemMessage) {
const isLastReadInsideGroup = this.lastReadMessageId >= group.id && this.lastReadMessageId < group.lastId if (this.groupIsCollapsed[group.id] !== undefined) {
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 the group was collapsed before, we should keep it collapsed // If the group was collapsed before, we should keep it collapsed
group.collapsed = this.groupIsCollapsed[group.id] group.collapsed = this.groupIsCollapsed[group.id]
} else { } else {

View file

@ -51,14 +51,30 @@
role="heading" role="heading"
aria-level="3" /> aria-level="3" />
</li> </li>
<component <template
:is="messagesGroupComponent[group.type]"
v-for="group in list" v-for="group in list"
:key="group.id" :key="group.id">
:token="token" <component
:messages="group.messages" :is="messagesGroupComponent[group.type]"
:previous-message-id="group.previousMessageId" :token="token"
:next-message-id="group.nextMessageId" /> :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> </ul>
<TransitionWrapper name="fade"> <TransitionWrapper name="fade">
@ -75,8 +91,10 @@
<script> <script>
import { n, t } from '@nextcloud/l10n' import { n, t } from '@nextcloud/l10n'
import { vIntersectionObserver as IntersectionObserver } from '@vueuse/components'
import debounce from 'debounce' 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 NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconMessageOutline from 'vue-material-design-icons/MessageOutline.vue' 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 { useGetThreadId } from '../../composables/useGetThreadId.ts'
import { ATTENDEE, CONVERSATION } from '../../constants.ts' import { ATTENDEE, CONVERSATION } from '../../constants.ts'
import { CHAT_STYLE } from '../../constants.ts' import { CHAT_STYLE } from '../../constants.ts'
import { getTalkConfig, hasTalkFeature } from '../../services/CapabilitiesManager.ts'
import { EventBus } from '../../services/EventBus.ts' import { EventBus } from '../../services/EventBus.ts'
import { useChatStore } from '../../stores/chat.ts' import { useChatStore } from '../../stores/chat.ts'
import { useChatExtrasStore } from '../../stores/chatExtras.ts' import { useChatExtrasStore } from '../../stores/chatExtras.ts'
@ -98,6 +117,8 @@ import { convertToUnix } from '../../utils/formattedTime.ts'
const SCROLL_TOLERANCE = 10 const SCROLL_TOLERANCE = 10
const LOAD_HISTORY_THRESHOLD = 800 const LOAD_HISTORY_THRESHOLD = 800
const canSummarizeChat = hasTalkFeature('local', 'chat-summary-api')
const summaryThreshold = getTalkConfig('local', 'chat', 'summary-threshold') ?? 0
const messagesGroupComponent = { const messagesGroupComponent = {
system: MessagesSystemGroup, system: MessagesSystemGroup,
@ -110,11 +131,16 @@ export default {
IconMessageOutline, IconMessageOutline,
LoadingPlaceholder, LoadingPlaceholder,
NcEmptyContent, NcEmptyContent,
NcAssistantButton,
NcLoadingIcon, NcLoadingIcon,
StaticDateTime, StaticDateTime,
TransitionWrapper, TransitionWrapper,
}, },
directives: {
IntersectionObserver,
},
provide() { provide() {
return { return {
getMessagesListScroller: () => this.$refs.scroller, getMessagesListScroller: () => this.$refs.scroller,
@ -206,6 +232,10 @@ export default {
stickyDate: null, stickyDate: null,
endScrollTimeout: () => {}, endScrollTimeout: () => {},
isUnreadMarkerSeen: false,
loadingSummary: false,
} }
}, },
@ -255,6 +285,13 @@ export default {
currentDay() { currentDay() {
return convertToUnix(new Date().setHours(0, 0, 0, 0)) 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: { watch: {
@ -277,6 +314,7 @@ export default {
token(newToken, oldToken) { token(newToken, oldToken) {
// Expire older messages when navigating to another conversation // Expire older messages when navigating to another conversation
this.$store.dispatch('easeMessageList', { token: oldToken }) this.$store.dispatch('easeMessageList', { token: oldToken })
this.isUnreadMarkerSeen = false
}, },
messagesList: { 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() { mounted() {
@ -488,6 +536,12 @@ export default {
return false // No previous message 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) { if (!!message1.lastEditTimestamp || !!message2.lastEditTimestamp) {
return false // Edited messages are not grouped return false // Edited messages are not grouped
} }
@ -813,7 +867,7 @@ export default {
const lastReadMessageElement = this.getVisualLastReadMessageElement() const lastReadMessageElement = this.getVisualLastReadMessageElement()
// first unread message has not been seen yet, so don't move it // first unread message has not been seen yet, so don't move it
if (lastReadMessageElement && lastReadMessageElement.getAttribute('data-seen') !== 'true') { if (!this.isUnreadMarkerSeen) {
return return
} }
@ -1036,6 +1090,31 @@ export default {
this.debounceHandleScroll({ skipHeightCheck: true }) 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> </script>
@ -1139,4 +1218,37 @@ export default {
opacity: 1; opacity: 1;
transition: opacity 0s; 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> </style>