feat(webapp): add mute/unumute group to menu (#8335)

* basics to notify a user when a followed user posted

* do not notify following user on posts in groups

* followig user wrote post notification

* notify regular group members when a new post is posted in the group

* mute and unmute groups

* clean database at end

* locale for post in group notification

* post in group notification triggers correctly

* email settings for post in group

* Add mute/unumute group to menu (WIP)

* Add mute group functionality (WIP)

* Add locales; use mute/unmute mutations, cleanup tests

* Overhaul group content menu test

* Rename isMuted to isMutedByMe and add it to group query

* Add German and English locales

* Add spanish translations

* Add missing translation keys (with null values)

* Remove console statement

* Add snapshot

* Replace mount by render

* Mock Math.random(), add tests for mute/unmute

* Use container instead of baseElement for snapshots

* fix group slug tests

* undo wrong variable naming

* rename parameter to groupId of mute/unmute group mutation

* rename parameter to groupId of mute/unmute group mutation

* only non pending members have access to the comtext menu

---------

Co-authored-by: Moriz Wahl <moriz.wahl@gmx.de>
Co-authored-by: Ulf Gebhardt <ulf.gebhardt@webcraft-media.de>
This commit is contained in:
Max 2025-04-12 01:10:42 +02:00 committed by GitHub
parent 7a44e1aa4e
commit caeff070b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 8519 additions and 1266 deletions

View File

@ -63,8 +63,8 @@ const notificationQuery = gql`
`
const muteGroupMutation = gql`
mutation ($id: ID!) {
muteGroup(id: $id) {
mutation ($groupId: ID!) {
muteGroup(groupId: $groupId) {
id
isMutedByMe
}
@ -72,8 +72,8 @@ const muteGroupMutation = gql`
`
const unmuteGroupMutation = gql`
mutation ($id: ID!) {
unmuteGroup(id: $id) {
mutation ($groupId: ID!) {
unmuteGroup(groupId: $groupId) {
id
isMutedByMe
}
@ -281,7 +281,7 @@ describe('notify group members of new posts in group', () => {
mutate({
mutation: muteGroupMutation,
variables: {
id: 'g-1',
groupId: 'g-1',
},
}),
).resolves.toMatchObject({
@ -340,7 +340,7 @@ describe('notify group members of new posts in group', () => {
mutate({
mutation: unmuteGroupMutation,
variables: {
id: 'g-1',
groupId: 'g-1',
},
}),
).resolves.toMatchObject({

View File

@ -369,7 +369,7 @@ export default {
}
},
muteGroup: async (_parent, params, context, _resolveInfo) => {
const { id: groupId } = params
const { groupId } = params
const userId = context.user.id
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
@ -398,7 +398,7 @@ export default {
}
},
unmuteGroup: async (_parent, params, context, _resolveInfo) => {
const { id: groupId } = params
const { groupId } = params
const userId = context.user.id
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {

View File

@ -142,6 +142,6 @@ type Mutation {
userId: ID!
): User
muteGroup(id: ID!): Group
unmuteGroup(id: ID!): Group
muteGroup(groupId: ID!): Group
unmuteGroup(groupId: ID!): Group
}

View File

@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import GroupContentMenu from './GroupContentMenu.vue'
import { render, screen, fireEvent } from '@testing-library/vue'
const localVue = global.localVue
@ -7,36 +7,77 @@ const stubs = {
'router-link': {
template: '<span><slot /></span>',
},
'v-popover': true,
}
const propsData = {
usage: 'groupTeaser',
resource: {},
group: {},
resourceType: 'group',
}
// Mock Math.random, used in Dropdown
Object.assign(Math, {
random: () => 0,
})
describe('GroupContentMenu', () => {
let wrapper
let mocks
beforeEach(() => {
mocks = {
$t: jest.fn(),
$t: jest.fn((s) => s),
}
})
describe('mount', () => {
const Wrapper = () => {
return mount(GroupContentMenu, { propsData, mocks, localVue, stubs })
}
const Wrapper = (propsData) => {
return render(GroupContentMenu, { propsData, mocks, localVue, stubs })
}
beforeEach(() => {
wrapper = Wrapper()
it('renders as groupTeaser', () => {
const wrapper = Wrapper({ usage: 'groupTeaser', group: { id: 'groupid' } })
expect(wrapper.container).toMatchSnapshot()
})
it('renders as groupProfile, not muted', () => {
const wrapper = Wrapper({
usage: 'groupProfile',
group: { isMutedByMe: false, id: 'groupid' },
})
expect(wrapper.container).toMatchSnapshot()
})
it('renders', () => {
expect(wrapper.findAll('.group-content-menu')).toHaveLength(1)
it('renders as groupProfile, muted', () => {
const wrapper = Wrapper({
usage: 'groupProfile',
group: { isMutedByMe: true, id: 'groupid' },
})
expect(wrapper.container).toMatchSnapshot()
})
it('renders as groupProfile when I am the owner', () => {
const wrapper = Wrapper({
usage: 'groupProfile',
group: { myRole: 'owner', id: 'groupid' },
})
expect(wrapper.container).toMatchSnapshot()
})
describe('mute button', () => {
it('emits mute', async () => {
const wrapper = Wrapper({
usage: 'groupProfile',
group: { isMutedByMe: false, id: 'groupid' },
})
const muteButton = screen.getByText('group.contentMenu.muteGroup')
await fireEvent.click(muteButton)
expect(wrapper.emitted().mute).toBeTruthy()
})
})
describe('unmute button', () => {
it('emits unmute', async () => {
const wrapper = Wrapper({
usage: 'groupProfile',
group: { isMutedByMe: true, id: 'groupid' },
})
const muteButton = screen.getByText('group.contentMenu.unmuteGroup')
await fireEvent.click(muteButton)
expect(wrapper.emitted().unmute).toBeTruthy()
})
})
})

View File

@ -62,6 +62,27 @@ export default {
params: { id: this.group.id, slug: this.group.slug },
})
}
if (this.usage === 'groupProfile') {
if (this.group.isMutedByMe) {
routes.push({
label: this.$t('group.contentMenu.unmuteGroup'),
icon: 'volume-up',
callback: () => {
this.$emit('unmute', this.group.id)
},
})
} else {
routes.push({
label: this.$t('group.contentMenu.muteGroup'),
icon: 'volume-off',
callback: () => {
this.$emit('mute', this.group.id)
},
})
}
}
if (this.group.myRole === 'owner') {
routes.push({
label: this.$t('admin.settings.name'),

View File

@ -0,0 +1,321 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GroupContentMenu renders as groupProfile when I am the owner 1`] = `
<div>
<v-popover-stub
autohide="true"
class="group-content-menu"
container="body"
delay="0"
handleresize="true"
offset="5"
openclass="open"
opengroup="0"
placement="bottom-end"
popoverarrowclass="tooltip-arrow popover-arrow"
popoverbaseclass="tooltip popover"
popoverclass="vue-popover-theme"
popoverinnerclass="tooltip-inner popover-inner"
popoverwrapperclass="wrapper"
popperoptions="[object Object]"
trigger="manual"
>
<button
class="base-button --icon-only --circle --small"
data-test="group-menu-button"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div>
<div
class="group-menu-popover"
>
<nav
class="ds-menu"
>
<ul
class="ds-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/"
>
<span
class="base-icon"
>
<!---->
</span>
group.contentMenu.muteGroup
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
href="/groups/edit/groupid"
>
<span
class="base-icon"
>
<!---->
</span>
admin.settings.name
</a>
<!---->
</li>
</ul>
</nav>
</div>
</div>
</v-popover-stub>
</div>
`;
exports[`GroupContentMenu renders as groupProfile, muted 1`] = `
<div>
<v-popover-stub
autohide="true"
class="group-content-menu"
container="body"
delay="0"
handleresize="true"
offset="5"
openclass="open"
opengroup="0"
placement="bottom-end"
popoverarrowclass="tooltip-arrow popover-arrow"
popoverbaseclass="tooltip popover"
popoverclass="vue-popover-theme"
popoverinnerclass="tooltip-inner popover-inner"
popoverwrapperclass="wrapper"
popperoptions="[object Object]"
trigger="manual"
>
<button
class="base-button --icon-only --circle --small"
data-test="group-menu-button"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div>
<div
class="group-menu-popover"
>
<nav
class="ds-menu"
>
<ul
class="ds-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/"
>
<span
class="base-icon"
>
<!---->
</span>
group.contentMenu.unmuteGroup
</a>
<!---->
</li>
</ul>
</nav>
</div>
</div>
</v-popover-stub>
</div>
`;
exports[`GroupContentMenu renders as groupProfile, not muted 1`] = `
<div>
<v-popover-stub
autohide="true"
class="group-content-menu"
container="body"
delay="0"
handleresize="true"
offset="5"
openclass="open"
opengroup="0"
placement="bottom-end"
popoverarrowclass="tooltip-arrow popover-arrow"
popoverbaseclass="tooltip popover"
popoverclass="vue-popover-theme"
popoverinnerclass="tooltip-inner popover-inner"
popoverwrapperclass="wrapper"
popperoptions="[object Object]"
trigger="manual"
>
<button
class="base-button --icon-only --circle --small"
data-test="group-menu-button"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div>
<div
class="group-menu-popover"
>
<nav
class="ds-menu"
>
<ul
class="ds-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/"
>
<span
class="base-icon"
>
<!---->
</span>
group.contentMenu.muteGroup
</a>
<!---->
</li>
</ul>
</nav>
</div>
</div>
</v-popover-stub>
</div>
`;
exports[`GroupContentMenu renders as groupTeaser 1`] = `
<div>
<v-popover-stub
autohide="true"
class="group-content-menu"
container="body"
delay="0"
handleresize="true"
offset="5"
openclass="open"
opengroup="0"
placement="bottom-end"
popoverarrowclass="tooltip-arrow popover-arrow"
popoverbaseclass="tooltip popover"
popoverclass="vue-popover-theme"
popoverinnerclass="tooltip-inner popover-inner"
popoverwrapperclass="wrapper"
popperoptions="[object Object]"
trigger="manual"
>
<button
class="base-button --icon-only --circle --small"
data-test="group-menu-button"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div>
<div
class="group-menu-popover"
>
<nav
class="ds-menu"
>
<ul
class="ds-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
href="/groups/groupid"
>
<span
class="base-icon"
>
<!---->
</span>
group.contentMenu.visitGroupPage
</a>
<!---->
</li>
</ul>
</nav>
</div>
</div>
</v-popover-stub>
</div>
`;

View File

@ -177,6 +177,7 @@ export const groupQuery = (i18n) => {
descriptionExcerpt
groupType
actionRadius
isMutedByMe
categories {
id
slug

View File

@ -0,0 +1,25 @@
import gql from 'graphql-tag'
export const muteGroup = () => {
return gql`
mutation ($groupId: ID!) {
muteGroup(groupId: $groupId) {
id
name
isMutedByMe
}
}
`
}
export const unmuteGroup = () => {
return gql`
mutation ($groupId: ID!) {
unmuteGroup(groupId: $groupId) {
id
name
isMutedByMe
}
}
`
}

View File

@ -485,6 +485,8 @@
"categoriesTitle": "Themen der Gruppe",
"changeMemberRole": "Die Rolle wurde auf „{role}“ geändert!",
"contentMenu": {
"muteGroup": "Gruppe stummschalten",
"unmuteGroup": "Gruppe nicht mehr stummschalten",
"visitGroupPage": "Gruppe anzeigen"
},
"createNewGroup": {
@ -535,6 +537,7 @@
"confirmAddGroupMemberText": "Nutzer „{name}“ zur Gruppe hinzufügen?",
"confirmAddGroupMemberTitle": "Bestätigen"
},
"muted": "Gruppe stummgeschaltet",
"myGroups": "Meine Gruppen",
"name": "Gruppenname",
"radius": "Radius",
@ -559,6 +562,8 @@
"hidden": "Geheim — Gruppe (auch der Name) komplett unsichtbar",
"public": "Öffentlich — Gruppe und alle Beiträge für registrierte Nutzer sichtbar"
},
"unmute": "Gruppe nicht mehr stummschalten",
"unmuted": "Gruppe nicht mehr stummgeschaltet",
"update": "Änderung speichern",
"updatedGroup": "Die Gruppendaten wurden geändert!"
},

View File

@ -485,6 +485,8 @@
"categoriesTitle": "Topics of the group",
"changeMemberRole": "The role has been changed to “{role}”!",
"contentMenu": {
"muteGroup": "Mute group",
"unmuteGroup": "Unmute group",
"visitGroupPage": "Show group"
},
"createNewGroup": {
@ -535,6 +537,7 @@
"confirmAddGroupMemberText": "Add user “{name}” to group?",
"confirmAddGroupMemberTitle": "Confirm"
},
"muted": "Group muted",
"myGroups": "My Groups",
"name": "Group name",
"radius": "Radius",
@ -559,6 +562,8 @@
"hidden": "Secret — Group (including the name) is completely invisible",
"public": "Public — Group and all posts are visible for all registered users"
},
"unmute": "Unmute group",
"unmuted": "Group unmuted",
"update": "Save change",
"updatedGroup": "The group data has been changed."
},

View File

@ -485,6 +485,8 @@
"categoriesTitle": null,
"changeMemberRole": null,
"contentMenu": {
"muteGroup": "Silenciar grupo",
"unmuteGroup": "Desactivar silencio del grupo",
"visitGroupPage": null
},
"createNewGroup": {
@ -535,6 +537,7 @@
"confirmAddGroupMemberText": null,
"confirmAddGroupMemberTitle": null
},
"muted": "Grupo silenciado",
"myGroups": null,
"name": null,
"radius": null,
@ -559,6 +562,8 @@
"hidden": null,
"public": null
},
"unmute": "Desactivar silencio",
"unmuted": "Silencio del grupo desactivado",
"update": null,
"updatedGroup": null
},

View File

@ -485,6 +485,8 @@
"categoriesTitle": null,
"changeMemberRole": null,
"contentMenu": {
"muteGroup": null,
"unmuteGroup": null,
"visitGroupPage": null
},
"createNewGroup": {
@ -535,6 +537,7 @@
"confirmAddGroupMemberText": null,
"confirmAddGroupMemberTitle": null
},
"muted": null,
"myGroups": null,
"name": null,
"radius": null,
@ -559,6 +562,8 @@
"hidden": null,
"public": null
},
"unmute": null,
"unmuted": null,
"update": null,
"updatedGroup": null
},

View File

@ -485,6 +485,8 @@
"categoriesTitle": null,
"changeMemberRole": null,
"contentMenu": {
"muteGroup": null,
"unmuteGroup": null,
"visitGroupPage": null
},
"createNewGroup": {
@ -535,6 +537,7 @@
"confirmAddGroupMemberText": null,
"confirmAddGroupMemberTitle": null
},
"muted": null,
"myGroups": null,
"name": null,
"radius": null,
@ -559,6 +562,8 @@
"hidden": null,
"public": null
},
"unmute": null,
"unmuted": null,
"update": null,
"updatedGroup": null
},

View File

@ -485,6 +485,8 @@
"categoriesTitle": null,
"changeMemberRole": null,
"contentMenu": {
"muteGroup": null,
"unmuteGroup": null,
"visitGroupPage": null
},
"createNewGroup": {
@ -535,6 +537,7 @@
"confirmAddGroupMemberText": null,
"confirmAddGroupMemberTitle": null
},
"muted": null,
"myGroups": null,
"name": null,
"radius": null,
@ -559,6 +562,8 @@
"hidden": null,
"public": null
},
"unmute": null,
"unmuted": null,
"update": null,
"updatedGroup": null
},

View File

@ -485,6 +485,8 @@
"categoriesTitle": null,
"changeMemberRole": null,
"contentMenu": {
"muteGroup": null,
"unmuteGroup": null,
"visitGroupPage": null
},
"createNewGroup": {
@ -535,6 +537,7 @@
"confirmAddGroupMemberText": null,
"confirmAddGroupMemberTitle": null
},
"muted": null,
"myGroups": null,
"name": null,
"radius": null,
@ -559,6 +562,8 @@
"hidden": null,
"public": null
},
"unmute": null,
"unmuted": null,
"update": null,
"updatedGroup": null
},

View File

@ -485,6 +485,8 @@
"categoriesTitle": null,
"changeMemberRole": null,
"contentMenu": {
"muteGroup": null,
"unmuteGroup": null,
"visitGroupPage": null
},
"createNewGroup": {
@ -535,6 +537,7 @@
"confirmAddGroupMemberText": null,
"confirmAddGroupMemberTitle": null
},
"muted": null,
"myGroups": null,
"name": null,
"radius": null,
@ -559,6 +562,8 @@
"hidden": null,
"public": null
},
"unmute": null,
"unmuted": null,
"update": null,
"updatedGroup": null
},

View File

@ -485,6 +485,8 @@
"categoriesTitle": null,
"changeMemberRole": null,
"contentMenu": {
"muteGroup": null,
"unmuteGroup": null,
"visitGroupPage": null
},
"createNewGroup": {
@ -535,6 +537,7 @@
"confirmAddGroupMemberText": null,
"confirmAddGroupMemberTitle": null
},
"muted": null,
"myGroups": null,
"name": null,
"radius": null,
@ -559,6 +562,8 @@
"hidden": null,
"public": null
},
"unmute": null,
"unmuted": null,
"update": null,
"updatedGroup": null
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -18,18 +18,14 @@
<!-- Menu -->
<client-only>
<group-content-menu
v-if="isGroupOwner"
v-if="isGroupMemberNonePending"
class="group-profile-content-menu"
:usage="'groupProfile'"
:group="group || {}"
placement="bottom-end"
@mute="muteGroup"
@unmute="unmuteGroup"
/>
<!-- TODO: implement later on -->
<!-- @mute="muteUser"
@unmute="unmuteUser"
@block="blockUser"
@unblock="unblockUser"
@delete="deleteUser" -->
</client-only>
<ds-space margin="small">
<!-- group name -->
@ -84,19 +80,9 @@
</ds-flex-item> -->
</ds-flex>
<div class="action-buttons">
<!-- <base-button v-if="user.isBlocked" @click="unblockUser(user)">
{{ $t('settings.blocked-users.unblock') }}
</base-button>
<base-button v-if="user.isMuted" @click="unmuteUser(user)">
{{ $t('settings.muted-users.unmute') }}
</base-button>
<follow-button
v-if="!user.isMuted && !user.isBlocked"
:follow-id="user.id"
:is-followed="user.followedByCurrentUser"
@optimistic="optimisticFollow"
@update="updateFollow"
/> -->
<base-button danger v-if="group.isMutedByMe" @click="unmuteGroup" icon="volume-up">
{{ $t('group.unmute') }}
</base-button>
<!-- Group join / leave -->
<join-leave-button
:group="group || {}"
@ -108,8 +94,6 @@
@prepare="prepareJoinLeave"
@update="updateJoinLeave"
/>
<!-- implement:
v-if="!user.isMuted && !user.isBlocked" -->
</div>
<hr />
<ds-space margin-top="small" margin-bottom="small">
@ -314,8 +298,7 @@
import uniqBy from 'lodash/uniqBy'
import { profilePagePosts } from '~/graphql/PostQuery'
import { updateGroupMutation, groupQuery, groupMembersQuery } from '~/graphql/groups'
// import { muteUser, unmuteUser } from '~/graphql/settings/MutedUsers'
// import { blockUser, unblockUser } from '~/graphql/settings/BlockedUsers'
import { muteGroup, unmuteGroup } from '~/graphql/settings/MutedGroups'
import UpdateQuery from '~/components/utils/UpdateQuery'
import postListActions from '~/mixins/postListActions'
import AvatarUploader from '~/components/Uploader/AvatarUploader'
@ -470,6 +453,32 @@ export default {
// this.resetPostList()
// }
// },
async muteGroup() {
try {
await this.$apollo.mutate({
mutation: muteGroup(),
variables: {
groupId: this.group.id,
},
})
this.$toast.success(this.$t('group.muted'))
} catch (error) {
this.$toast.error(error.message)
}
},
async unmuteGroup() {
try {
await this.$apollo.mutate({
mutation: unmuteGroup(),
variables: {
groupId: this.group.id,
},
})
this.$toast.success(this.$t('group.unmuted'))
} catch (error) {
this.$toast.error(error.message)
}
},
uniq(items, field = 'id') {
return uniqBy(items, field)
},