diff --git a/webapp/assets/_new/icons/svgs/bell-slashed.svg b/webapp/assets/_new/icons/svgs/bell-slashed.svg
new file mode 100644
index 000000000..0aae3ff97
--- /dev/null
+++ b/webapp/assets/_new/icons/svgs/bell-slashed.svg
@@ -0,0 +1 @@
+
diff --git a/webapp/components/ContentMenu/ContentMenu.spec.js b/webapp/components/ContentMenu/ContentMenu.spec.js
index 0bd398e41..ce7a45a42 100644
--- a/webapp/components/ContentMenu/ContentMenu.spec.js
+++ b/webapp/components/ContentMenu/ContentMenu.spec.js
@@ -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],
+ ])
+ })
})
})
})
diff --git a/webapp/components/ContentMenu/ContentMenu.vue b/webapp/components/ContentMenu/ContentMenu.vue
index d723a9667..627e5d982 100644
--- a/webapp/components/ContentMenu/ContentMenu.vue
+++ b/webapp/components/ContentMenu/ContentMenu.vue
@@ -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') {
diff --git a/webapp/components/ObserveButton.spec.js b/webapp/components/ObserveButton.spec.js
new file mode 100644
index 000000000..3ecfc40b6
--- /dev/null
+++ b/webapp/components/ObserveButton.spec.js
@@ -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]])
+ })
+ })
+})
diff --git a/webapp/components/ObserveButton.vue b/webapp/components/ObserveButton.vue
new file mode 100644
index 000000000..2c275709b
--- /dev/null
+++ b/webapp/components/ObserveButton.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+ {{ count }}x
+ {{ $t('observeButton.observed') }}
+
+
+
+
+
+
+
diff --git a/webapp/components/PostTeaser/PostTeaser.vue b/webapp/components/PostTeaser/PostTeaser.vue
index 3c7d23c03..ad43a9d31 100644
--- a/webapp/components/PostTeaser/PostTeaser.vue
+++ b/webapp/components/PostTeaser/PostTeaser.vue
@@ -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"
/>
@@ -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
diff --git a/webapp/components/__snapshots__/ObserveButton.spec.js.snap b/webapp/components/__snapshots__/ObserveButton.spec.js.snap
new file mode 100644
index 000000000..c3ba629be
--- /dev/null
+++ b/webapp/components/__snapshots__/ObserveButton.spec.js.snap
@@ -0,0 +1,81 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ObserveButton observed renders 1`] = `
+
+
+
+
+
+
+
+ 1x
+
+
+
+
+
+
+`;
+
+exports[`ObserveButton unobserved renders 1`] = `
+
+
+
+
+
+
+
+ 1x
+
+
+
+
+
+
+`;
diff --git a/webapp/components/_new/features/SearchResults/SearchResults.vue b/webapp/components/_new/features/SearchResults/SearchResults.vue
index 20385ce64..94d569e70 100644
--- a/webapp/components/_new/features/SearchResults/SearchResults.vue
+++ b/webapp/components/_new/features/SearchResults/SearchResults.vue
@@ -48,6 +48,9 @@
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
+ @toggleObservePost="
+ (postId, value) => toggleObservePost(postId, value, refetchPostList)
+ "
/>
diff --git a/webapp/graphql/CommentMutations.js b/webapp/graphql/CommentMutations.js
index 191edf217..dd00527be 100644
--- a/webapp/graphql/CommentMutations.js
+++ b/webapp/graphql/CommentMutations.js
@@ -13,6 +13,8 @@ export default (i18n) => {
updatedAt
disabled
deleted
+ isPostObservedByMe
+ postObservingUsersCount
author {
id
slug
diff --git a/webapp/jest.config.js b/webapp/jest.config.js
index e9185f60e..947d6019d 100644
--- a/webapp/jest.config.js
+++ b/webapp/jest.config.js
@@ -38,4 +38,5 @@ module.exports = {
},
moduleFileExtensions: ['js', 'json', 'vue'],
testEnvironment: 'jest-environment-jsdom',
+ snapshotSerializers: ['jest-serializer-vue'],
}
diff --git a/webapp/locales/de.json b/webapp/locales/de.json
index 4e5d42ac4..518ba99a9 100644
--- a/webapp/locales/de.json
+++ b/webapp/locales/de.json
@@ -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!"
},
diff --git a/webapp/locales/en.json b/webapp/locales/en.json
index 534a376cf..f78728c4f 100644
--- a/webapp/locales/en.json
+++ b/webapp/locales/en.json
@@ -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!"
},
diff --git a/webapp/locales/es.json b/webapp/locales/es.json
index c33169f66..a085a53e0 100644
--- a/webapp/locales/es.json
+++ b/webapp/locales/es.json
@@ -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!"
},
diff --git a/webapp/locales/fr.json b/webapp/locales/fr.json
index 889093b8c..f1b5642de 100644
--- a/webapp/locales/fr.json
+++ b/webapp/locales/fr.json
@@ -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!"
},
diff --git a/webapp/locales/it.json b/webapp/locales/it.json
index 222ac94b2..54248e6ee 100644
--- a/webapp/locales/it.json
+++ b/webapp/locales/it.json
@@ -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
},
diff --git a/webapp/locales/nl.json b/webapp/locales/nl.json
index 6ec9b4c2a..7907ce052 100644
--- a/webapp/locales/nl.json
+++ b/webapp/locales/nl.json
@@ -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
},
diff --git a/webapp/locales/pl.json b/webapp/locales/pl.json
index 1ed0fa050..7a800b3d0 100644
--- a/webapp/locales/pl.json
+++ b/webapp/locales/pl.json
@@ -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
},
diff --git a/webapp/locales/pt.json b/webapp/locales/pt.json
index c2e38fbc6..c0bb8a500 100644
--- a/webapp/locales/pt.json
+++ b/webapp/locales/pt.json
@@ -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!"
},
diff --git a/webapp/locales/ru.json b/webapp/locales/ru.json
index 91a8b30bc..bebae1012 100644
--- a/webapp/locales/ru.json
+++ b/webapp/locales/ru.json
@@ -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": "Пост успешно не закреплено!"
},
diff --git a/webapp/mixins/postListActions.js b/webapp/mixins/postListActions.js
index 57fd28bd1..808af3ff7 100644
--- a/webapp/mixins/postListActions.js
+++ b/webapp/mixins/postListActions.js
@@ -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))
+ },
},
}
diff --git a/webapp/package.json b/webapp/package.json
index d12672224..03ad85d30 100644
--- a/webapp/package.json
+++ b/webapp/package.json
@@ -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",
diff --git a/webapp/pages/groups/_id/_slug.vue b/webapp/pages/groups/_id/_slug.vue
index f261f2ef3..10d2ca8d2 100644
--- a/webapp/pages/groups/_id/_slug.vue
+++ b/webapp/pages/groups/_id/_slug.vue
@@ -283,6 +283,9 @@
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
+ @toggleObservePost="
+ (postId, value) => toggleObservePost(postId, value, refetchPostList)
+ "
/>
diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue
index 6067119e1..c780b0ae3 100644
--- a/webapp/pages/index.vue
+++ b/webapp/pages/index.vue
@@ -116,6 +116,9 @@
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
+ @toggleObservePost="
+ (postId, value) => toggleObservePost(postId, value, refetchPostList)
+ "
/>
diff --git a/webapp/pages/post/_id/_slug/index.vue b/webapp/pages/post/_id/_slug/index.vue
index 1e9622c13..b84486eec 100644
--- a/webapp/pages/post/_id/_slug/index.vue
+++ b/webapp/pages/post/_id/_slug/index.vue
@@ -49,6 +49,7 @@
:is-owner="isAuthor"
@pinPost="pinPost"
@unpinPost="unpinPost"
+ @toggleObservePost="toggleObservePost"
/>
@@ -111,6 +112,18 @@
:post-id="post.id"
/>
+
+
+
+
@@ -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));
diff --git a/webapp/pages/profile/_id/_slug.vue b/webapp/pages/profile/_id/_slug.vue
index cef3a5d45..e60ba1098 100644
--- a/webapp/pages/profile/_id/_slug.vue
+++ b/webapp/pages/profile/_id/_slug.vue
@@ -156,6 +156,9 @@
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
+ @toggleObservePost="
+ (postId, value) => toggleObservePost(postId, value, refetchPostList)
+ "
/>
diff --git a/webapp/yarn.lock b/webapp/yarn.lock
index 55873eb69..250492601 100644
--- a/webapp/yarn.lock
+++ b/webapp/yarn.lock
@@ -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==