feat(frontend): observe posts (#8293)

* After creating the post, the author of it automatically observes it to get notifications when there are interactions

* a user that comments a post, automatically observes that post to get notifications when there are more interactions on that post

* mutation that switches the state of the obeservation of a post on and off

* remove duplicate code

* fix unit tests

* add metric observed users count to posts

* change naming

* Add follow post entry to post menu

* Add FollowButton (WIP), show unfollow in menu when already followed

* Follow/unfollow post => observe

* Update slashed bell

* Add requests to observe/unobserve posts

* Add ObserveButton functionality

* Rename isObservedByMe

* Add observingUsersCount; simplify ObserveButton and menu entries

* Fix locales

* Add snapshot test for ObserveButton (WIP)

* Remove empty routes push

* Add test for ObserveButton

* Add test for ContentMenu, improve ObserveButton test

* Remove unneeded fields from PostQuery

---------

Co-authored-by: Moriz Wahl <moriz.wahl@gmx.de>
This commit is contained in:
Max 2025-04-04 13:54:43 +02:00 committed by GitHub
parent 81b7d4a09c
commit 1e6a74b8ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 383 additions and 3 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="1080" height="1080"><rect width="100%" height="100%" fill="transparent"/><path d="M15 3a2 2 0 0 1 2 2c0 .085-.021.168-.031.25C20.49 6.174 23 9.523 23 13.281V22c0 .565.435 1 1 1h1v2h-7.188c.114.316.188.647.188 1 0 1.645-1.355 3-3 3s-3-1.355-3-3c0-.353.073-.684.188-1H5v-2h1c.565 0 1-.435 1-1v-9c0-3.726 2.574-6.866 6.031-7.75C13.021 5.168 13 5.085 13 5a2 2 0 0 1 2-2zm-.437 4A6.004 6.004 0 0 0 9 13v9c0 .353-.073.684-.188 1h12.375a2.925 2.925 0 0 1-.188-1v-8.719c0-3.319-2.546-6.183-5.813-6.281-.064-.002-.124 0-.188 0-.148 0-.292-.011-.438 0zM15 25c-.564 0-1 .436-1 1 0 .564.436 1 1 1 .564 0 1-.436 1-1 0-.564-.436-1-1-1z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#000;fill-rule:nonzero;opacity:1" transform="translate(33.75) scale(33.75)"/><rect width="74.334" height="74.334" x="-37.167" y="-37.167" rx="0" ry="0" style="stroke:#000;stroke-width:0;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#000;fill-rule:nonzero;opacity:1" transform="matrix(9.42 -12.59 .8 .6 538.54 541.95)" vector-effect="non-scaling-stroke"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -500,6 +500,44 @@ describe('ContentMenu.vue', () => {
],
])
})
it('can observe posts', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
isObservedByMe: false,
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.observe')
.at(0)
.trigger('click')
expect(wrapper.emitted('toggleObservePost')).toEqual([
['d23a4265-f5f7-4e17-9f86-85f714b4b9f8', true],
])
})
it('can unobserve posts', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
isObservedByMe: true,
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.unobserve')
.at(0)
.trigger('click')
expect(wrapper.emitted('toggleObservePost')).toEqual([
['d23a4265-f5f7-4e17-9f86-85f714b4b9f8', false],
])
})
})
})
})

View File

@ -99,6 +99,24 @@ export default {
})
}
}
if (this.resource.isObservedByMe) {
routes.push({
label: this.$t(`post.menu.unobserve`),
callback: () => {
this.$emit('toggleObservePost', this.resource.id, false)
},
icon: 'bell-slashed',
})
} else {
routes.push({
label: this.$t(`post.menu.observe`),
callback: () => {
this.$emit('toggleObservePost', this.resource.id, true)
},
icon: 'bell',
})
}
}
if (this.isOwner && this.resourceType === 'comment') {

View File

@ -0,0 +1,60 @@
import { mount } from '@vue/test-utils'
import ObserveButton from './ObserveButton.vue'
const localVue = global.localVue
describe('ObserveButton', () => {
let mocks
const Wrapper = (count = 1, postId = '123', isObserved = true) => {
return mount(ObserveButton, {
mocks,
localVue,
propsData: {
count,
postId,
isObserved,
},
})
}
let wrapper
beforeEach(() => {
mocks = {
$t: jest.fn(),
}
})
describe('observed', () => {
beforeEach(() => {
wrapper = Wrapper(1, '123', true)
})
it('renders', () => {
expect(wrapper.element).toMatchSnapshot()
})
it('emits toggleObservePost with false when clicked', () => {
const button = wrapper.find('.base-button')
button.trigger('click')
expect(wrapper.emitted('toggleObservePost')).toEqual([['123', false]])
})
})
describe('unobserved', () => {
beforeEach(() => {
wrapper = Wrapper(1, '123', false)
})
it('renders', () => {
expect(wrapper.element).toMatchSnapshot()
})
it('emits toggleObservePost with true when clicked', () => {
const button = wrapper.find('.base-button')
button.trigger('click')
expect(wrapper.emitted('toggleObservePost')).toEqual([['123', true]])
})
})
})

View File

@ -0,0 +1,39 @@
<template>
<ds-space margin="xx-small" class="text-align-center">
<base-button :loading="loading" :filled="isObserved" icon="bell" circle @click="toggle" />
<ds-space margin-bottom="xx-small" />
<ds-text color="soft" class="observe-button-text">
<ds-heading style="display: inline" tag="h3">{{ count }}x</ds-heading>
{{ $t('observeButton.observed') }}
</ds-text>
</ds-space>
</template>
<script>
export default {
props: {
count: { type: Number, default: 0 },
postId: { type: String, default: null },
isObserved: { type: Boolean, default: false },
},
data() {
return {
loading: false,
}
},
methods: {
toggle() {
this.$emit('toggleObservePost', this.postId, !this.isObserved)
},
},
}
</script>
<style lang="scss">
.observe-button-text {
user-select: none;
}
.text-align-center {
text-align: center;
}
</style>

View File

@ -59,7 +59,7 @@
:key="category.id"
v-tooltip="{
content: `
${$t(`contribution.category.name.${category.slug}`)}:
${$t(`contribution.category.name.${category.slug}`)}:
${$t(`contribution.category.description.${category.slug}`)}
`,
placement: 'bottom-start',
@ -97,6 +97,7 @@
:is-owner="isAuthor"
@pinPost="pinPost"
@unpinPost="unpinPost"
@toggleObservePost="toggleObservePost"
/>
</client-only>
</footer>
@ -212,6 +213,9 @@ export default {
unpinPost(post) {
this.$emit('unpinPost', post)
},
toggleObservePost(postId, value) {
this.$emit('toggleObservePost', postId, value)
},
visibilityChanged(isVisible, entry, id) {
if (!this.post.viewedTeaserByCurrentUser && isVisible) {
this.$apollo

View File

@ -0,0 +1,81 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ObserveButton observed renders 1`] = `
<div
class="ds-space text-align-center"
style="margin-top: 4px; margin-bottom: 4px;"
>
<button
class="base-button --icon-only --circle --filled"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div
class="ds-space"
style="margin-bottom: 4px;"
/>
<p
class="ds-text observe-button-text ds-text-soft"
>
<h3
class="ds-heading ds-heading-h3"
style="display: inline;"
>
1x
</h3>
</p>
</div>
`;
exports[`ObserveButton unobserved renders 1`] = `
<div
class="ds-space text-align-center"
style="margin-top: 4px; margin-bottom: 4px;"
>
<button
class="base-button --icon-only --circle"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div
class="ds-space"
style="margin-bottom: 4px;"
/>
<p
class="ds-text observe-button-text ds-text-soft"
>
<h3
class="ds-heading ds-heading-h3"
style="display: inline;"
>
1x
</h3>
</p>
</div>
`;

View File

@ -48,6 +48,9 @@
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
@toggleObservePost="
(postId, value) => toggleObservePost(postId, value, refetchPostList)
"
/>
</masonry-grid-item>
</template>

View File

@ -13,6 +13,8 @@ export default (i18n) => {
updatedAt
disabled
deleted
isPostObservedByMe
postObservingUsersCount
author {
id
slug

View File

@ -38,4 +38,5 @@ module.exports = {
},
moduleFileExtensions: ['js', 'json', 'vue'],
testEnvironment: 'jest-environment-jsdom',
snapshotSerializers: ['jest-serializer-vue'],
}

View File

@ -747,6 +747,9 @@
"title": "Benachrichtigungen",
"user": "Nutzer"
},
"observeButton": {
"observed": "beobachtet"
},
"post": {
"comment": {
"reply": "Antworten",
@ -778,8 +781,12 @@
"menu": {
"delete": "Beitrag löschen",
"edit": "Beitrag bearbeiten",
"observe": "Beitrag beobachten",
"observedSuccessfully": "Du beobachtest diesen Beitrag!",
"pin": "Beitrag anheften",
"pinnedSuccessfully": "Beitrag erfolgreich angeheftet!",
"unobserve": "Beitrag nicht mehr beobachten",
"unobservedSuccessfully": "Du beobachtest diesen Beitrag nicht mehr!",
"unpin": "Beitrag loslösen",
"unpinnedSuccessfully": "Angehefteten Beitrag erfolgreich losgelöst!"
},

View File

@ -747,6 +747,9 @@
"title": "Notifications",
"user": "User"
},
"observeButton": {
"observed": "observed"
},
"post": {
"comment": {
"reply": "Reply",
@ -778,8 +781,12 @@
"menu": {
"delete": "Delete post",
"edit": "Edit post",
"observe": "Observe post",
"observedSuccessfully": "You are now observing this post!",
"pin": "Pin post",
"pinnedSuccessfully": "Post pinned successfully!",
"unobserve": "Stop to observe post",
"unobservedSuccessfully": "You are no longer observing this post!",
"unpin": "Unpin post",
"unpinnedSuccessfully": "Post unpinned successfully!"
},

View File

@ -747,6 +747,9 @@
"title": "Notificaciones",
"user": "Usuario"
},
"observeButton": {
"observed": null
},
"post": {
"comment": {
"reply": "Contestar",
@ -778,8 +781,12 @@
"menu": {
"delete": "Borrar contribución",
"edit": "Editar contribución",
"observe": "Observar contribución",
"observedSuccessfully": null,
"pin": "Anclar contribución",
"pinnedSuccessfully": "¡Contribución anclado con éxito!",
"unobserve": "Dejar de observar contribución",
"unobservedSuccessfully": null,
"unpin": "Desanclar contribución",
"unpinnedSuccessfully": "¡Contribución desanclado con éxito!"
},

View File

@ -747,6 +747,9 @@
"title": "Notifications",
"user": "Utilisateur"
},
"observeButton": {
"observed": null
},
"post": {
"comment": {
"reply": null,
@ -778,8 +781,12 @@
"menu": {
"delete": "Supprimer le Post",
"edit": "Modifier le Post",
"observe": "Observer le Post",
"observedSuccessfully": null,
"pin": "Épingler le Post",
"pinnedSuccessfully": "Poste épinglé avec succès!",
"unobserve": "Ne plus observer le Post",
"unobservedSuccessfully": null,
"unpin": "Retirer l'épingle du poste",
"unpinnedSuccessfully": "Épingle retirer du Post avec succès!"
},

View File

@ -747,6 +747,9 @@
"title": null,
"user": null
},
"observeButton": {
"observed": null
},
"post": {
"comment": {
"reply": null,
@ -778,8 +781,12 @@
"menu": {
"delete": null,
"edit": null,
"observe": null,
"observedSuccessfully": null,
"pin": null,
"pinnedSuccessfully": null,
"unobserve": null,
"unobservedSuccessfully": null,
"unpin": null,
"unpinnedSuccessfully": null
},

View File

@ -747,6 +747,9 @@
"title": null,
"user": null
},
"observeButton": {
"observed": null
},
"post": {
"comment": {
"reply": null,
@ -778,8 +781,12 @@
"menu": {
"delete": null,
"edit": null,
"observe": null,
"observedSuccessfully": null,
"pin": null,
"pinnedSuccessfully": null,
"unobserve": null,
"unobservedSuccessfully": null,
"unpin": null,
"unpinnedSuccessfully": null
},

View File

@ -747,6 +747,9 @@
"title": null,
"user": null
},
"observeButton": {
"observed": null
},
"post": {
"comment": {
"reply": null,
@ -778,8 +781,12 @@
"menu": {
"delete": "Usuń wpis",
"edit": "Edytuj wpis",
"observe": null,
"observedSuccessfully": null,
"pin": null,
"pinnedSuccessfully": null,
"unobserve": null,
"unobservedSuccessfully": null,
"unpin": null,
"unpinnedSuccessfully": null
},

View File

@ -747,6 +747,9 @@
"title": "Notificações",
"user": "Usuário"
},
"observeButton": {
"observed": null
},
"post": {
"comment": {
"reply": null,
@ -778,8 +781,12 @@
"menu": {
"delete": "Excluir publicação",
"edit": "Editar publicação",
"observe": "Observar publicação",
"observedSuccessfully": null,
"pin": "Fixar publicação",
"pinnedSuccessfully": "Publicação fixada com sucesso!",
"unobserve": "Deixar de observar publicação",
"unobservedSuccessfully": null,
"unpin": "Desafixar publicação",
"unpinnedSuccessfully": "Publicação desafixada com sucesso!"
},

View File

@ -747,6 +747,9 @@
"title": "Уведомления",
"user": "Пользователь"
},
"observeButton": {
"observed": null
},
"post": {
"comment": {
"reply": "Ответ",
@ -778,8 +781,12 @@
"menu": {
"delete": "Удалить пост",
"edit": "Редактировать пост",
"observe": null,
"observedSuccessfully": null,
"pin": "Закрепить пост",
"pinnedSuccessfully": "Пост больше не закреплен!",
"unobserve": null,
"unobservedSuccessfully": null,
"unpin": "Открепить пост",
"unpinnedSuccessfully": "Пост успешно не закреплено!"
},

View File

@ -35,5 +35,23 @@ export default {
})
.catch((error) => this.$toast.error(error.message))
},
toggleObservePost(postId, value, refetchPostList = () => {}) {
this.$apollo
.mutate({
mutation: PostMutations().toggleObservePost,
variables: {
value,
id: postId,
},
})
.then(() => {
const message = this.$t(
`post.menu.${value ? 'observedSuccessfully' : 'unobservedSuccessfully'}`,
)
this.$toast.success(message)
refetchPostList()
})
.catch((error) => this.$toast.error(error.message))
},
},
}

View File

@ -40,6 +40,7 @@
"express": "~4.21.2",
"graphql": "~14.7.0",
"intersection-observer": "^0.12.0",
"jest-serializer-vue": "^3.1.0",
"jsonwebtoken": "~9.0.2",
"linkify-it": "~5.0.0",
"mapbox-gl": "1.13.2",

View File

@ -283,6 +283,9 @@
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
@toggleObservePost="
(postId, value) => toggleObservePost(postId, value, refetchPostList)
"
/>
</masonry-grid-item>
</template>

View File

@ -116,6 +116,9 @@
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
@toggleObservePost="
(postId, value) => toggleObservePost(postId, value, refetchPostList)
"
/>
</masonry-grid-item>
</template>

View File

@ -49,6 +49,7 @@
:is-owner="isAuthor"
@pinPost="pinPost"
@unpinPost="unpinPost"
@toggleObservePost="toggleObservePost"
/>
</client-only>
</section>
@ -111,6 +112,18 @@
:post-id="post.id"
/>
</ds-flex-item>
<!-- Follow Button -->
<ds-flex-item
:width="{ lg: '15%', md: '22%', sm: '22%', base: '100%' }"
class="shout-button"
>
<observe-button
:is-observed="post.isObservedByMe"
:count="post.observingUsersCount"
:post-id="post.id"
@toggleObservePost="toggleObservePost"
/>
</ds-flex-item>
</ds-flex>
</ds-space>
<!-- Comments -->
@ -156,6 +169,7 @@ import ContentMenu from '~/components/ContentMenu/ContentMenu'
import DateTimeRange from '~/components/DateTimeRange/DateTimeRange'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import HcShoutButton from '~/components/ShoutButton.vue'
import ObserveButton from '~/components/ObserveButton.vue'
import LocationTeaser from '~/components/LocationTeaser/LocationTeaser'
import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue'
import {
@ -184,6 +198,7 @@ export default {
HcCategory,
HcHashtag,
HcShoutButton,
ObserveButton,
LocationTeaser,
PageParamsLink,
UserTeaser,
@ -302,6 +317,8 @@ export default {
},
async createComment(comment) {
this.post.comments.push(comment)
this.post.isObservedByMe = comment.isPostObservedByMe
this.post.observingUsersCount = comment.postObservingUsersCount
},
pinPost(post) {
this.$apollo
@ -325,6 +342,24 @@ export default {
})
.catch((error) => this.$toast.error(error.message))
},
toggleObservePost(postId, value) {
this.$apollo
.mutate({
mutation: PostMutations().toggleObservePost,
variables: {
value,
id: postId,
},
})
.then(() => {
const message = this.$t(
`post.menu.${value ? 'observedSuccessfully' : 'unobservedSuccessfully'}`,
)
this.$toast.success(message)
this.$apollo.queries.Post.refetch()
})
.catch((error) => this.$toast.error(error.message))
},
toggleNewCommentForm(showNewCommentForm) {
this.showNewCommentForm = showNewCommentForm
},
@ -379,7 +414,7 @@ export default {
position: relative;
/* The padding top makes sure the correct height is set (according to the
hero image aspect ratio) before the hero image loads so
the autoscroll works correctly when following a comment link.
the autoscroll works correctly when following a comment link.
*/
padding-top: calc(var(--hero-image-aspect-ratio) * (100% + 48px));

View File

@ -156,6 +156,9 @@
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
@toggleObservePost="
(postId, value) => toggleObservePost(postId, value, refetchPostList)
"
/>
</masonry-grid-item>
</template>

View File

@ -12609,6 +12609,13 @@ jest-runtime@^29.7.0:
slash "^3.0.0"
strip-bom "^4.0.0"
jest-serializer-vue@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/jest-serializer-vue/-/jest-serializer-vue-3.1.0.tgz#af65817aa416d019f837b6cc53f121a3222846f4"
integrity sha512-vXz9/3IgBbLhsaVANYLG4ROCQd+Wg3qbB6ICofzFL+fbhSFPlqb0/MMGXcueVsjaovdWlYiRaLQLpdi1PTcoRQ==
dependencies:
pretty "2.0.0"
jest-snapshot@^29.7.0:
version "29.7.0"
resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.7.0.tgz#c2c574c3f51865da1bb329036778a69bf88a6be5"
@ -16155,7 +16162,7 @@ pretty-time@^1.1.0:
resolved "https://registry.yarnpkg.com/pretty-time/-/pretty-time-1.1.0.tgz#ffb7429afabb8535c346a34e41873adf3d74dd0e"
integrity sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==
pretty@^2.0.0:
pretty@2.0.0, pretty@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/pretty/-/pretty-2.0.0.tgz#adbc7960b7bbfe289a557dc5f737619a220d06a5"
integrity sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w==