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 @@ + + + + + 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==