From 3d10d27a03a202979302810aa624ac5d89d905ad Mon Sep 17 00:00:00 2001 From: Nathan Power Date: Fri, 29 Jul 2022 22:26:21 +1000 Subject: [PATCH] feat(groups): build leave group method (#4086) --- .../navigation/sidebar/list/item/Item.vue | 9 +- libraries/Iridium/groups/Group.ts | 10 +- libraries/Iridium/groups/GroupManager.ts | 153 +++++++++++++++++- libraries/Iridium/groups/types.ts | 12 ++ 4 files changed, 174 insertions(+), 10 deletions(-) diff --git a/components/views/navigation/sidebar/list/item/Item.vue b/components/views/navigation/sidebar/list/item/Item.vue index 539702657b..b9bfa96954 100644 --- a/components/views/navigation/sidebar/list/item/Item.vue +++ b/components/views/navigation/sidebar/list/item/Item.vue @@ -39,6 +39,7 @@ export default Vue.extend({ computed: { ...mapState({ ui: (state) => (state as RootState).ui, + accounts: (state) => (state as RootState).accounts, }), ...mapGetters('settings', ['getTimestamp', 'getDate']), contextMenuValues(): ContextMenuItem[] { @@ -58,7 +59,11 @@ export default Vue.extend({ ] : [ { text: this.$t('context.send'), func: this.openConversation }, - // { text: this.$t('context.leave_group'), func: this.leaveGroup }, + { + text: this.$t('context.leave_group'), + func: this.leaveGroup, + type: 'danger', + }, ] }, lastMessage(): ConversationMessage | undefined { @@ -142,7 +147,7 @@ export default Vue.extend({ this.isLoading = false }, async leaveGroup() { - // todo + iridium.groups.leaveGroup(this.conversation.id) }, /** * @method openConversation diff --git a/libraries/Iridium/groups/Group.ts b/libraries/Iridium/groups/Group.ts index b15158f5cd..09e93558bc 100644 --- a/libraries/Iridium/groups/Group.ts +++ b/libraries/Iridium/groups/Group.ts @@ -29,12 +29,6 @@ export default class Group extends Emitter { return this.state?.members } - static async getById(id: string, iridium: IridiumManager) { - const group = new Group(id, iridium) - await group.load() - return group - } - async load() { if (!this.iridium.connector) { throw new Error(GroupsError.GROUP_NOT_INITIALIZED) @@ -45,6 +39,10 @@ export default class Group extends Emitter { await this.iridium.connector.subscribe(`/groups/${this.id}`) } + async unsubscribe() { + this.off(`/groups/${this.id}`, this._onWireMessage) + } + private _onWireMessage(message: IridiumPeerMessage) { const { from, payload } = message const { channel, data } = payload diff --git a/libraries/Iridium/groups/GroupManager.ts b/libraries/Iridium/groups/GroupManager.ts index 74a2a79d57..a96c28973f 100644 --- a/libraries/Iridium/groups/GroupManager.ts +++ b/libraries/Iridium/groups/GroupManager.ts @@ -1,13 +1,37 @@ -import { Emitter, encoding } from '@satellite-im/iridium' +import Vue from 'vue' +import { + didUtils, + Emitter, + encoding, + IridiumGetOptions, + IridiumPeerIdentifier, +} from '@satellite-im/iridium' import type { IridiumMessage } from '@satellite-im/iridium/src/types' import { IridiumManager } from '../IridiumManager' import Group from './Group' -import { GroupConfig, GroupsError } from './types' +import { + GroupConfig, + GroupManagerEvent, + GroupMemberDetails, + GroupsError, +} from './types' +import logger from '~/plugins/local/logger' + +export type IridiumGroupEvent = { + to: IridiumPeerIdentifier + status: GroupManagerEvent + at: number + group: Group + member?: GroupMemberDetails + data?: any +} export default class GroupManager extends Emitter { groupIds?: string[] state: { [key: string]: Group } = {} + private loggerTag = 'iridium/groups' + constructor(private readonly iridium: IridiumManager) { super() this.iridium = iridium @@ -19,6 +43,8 @@ export default class GroupManager extends Emitter { throw new Error('cannot initialize groups, no iridium connector') } + iridium.pubsub.on('/groups/announce', this.onGroupsAnnounce.bind(this)) + await this.fetch() } @@ -26,6 +52,53 @@ export default class GroupManager extends Emitter { this.state = await this.iridium.connector?.get('/groups') } + /** + * @method get + * @description get remote state + * @param path string (required) + * @param options object + * @returns iridium's connector result + */ + get(path: string, options: IridiumGetOptions = {}): Promise { + if (!this.iridium.connector) { + logger.error(this.loggerTag, 'network error') + throw new Error(GroupsError.NETWORK_ERROR) + } + return this.iridium.connector?.get( + `/groups${path === '/' ? '' : path}`, + options, + ) + } + + private async onGroupsAnnounce(message: IridiumMessage) { + const { from, payload } = message + const { to, at, status, member, group } = payload.body + + if (to !== this.iridium.connector?.id) return + const request = await this.getGroupAnnouncement(from).catch(() => undefined) + switch (status) { + case 'group-member-left': + if (!request) { + logger.warn( + this.loggerTag, + 'ignoring group-member-left for unknown group', + ) + return + } + await this.removeMemberFromGroup( + group.id, + (member as GroupMemberDetails).id, + ) + break + } + + return this.state + } + + getGroupAnnouncement(did: IridiumPeerIdentifier): Promise { + return this.get(`/announce/${didUtils.didString(did)}`) + } + /** * @method createGroup * attempt to create conversation with the provided config. if successful, create a new group too @@ -63,6 +136,7 @@ export default class GroupManager extends Emitter { if (!group) { throw new Error(GroupsError.GROUP_NOT_FOUND) } + await group.load() return group } @@ -70,4 +144,79 @@ export default class GroupManager extends Emitter { const group = await this.getGroup(groupId) return group.members } + + async send(event: IridiumGroupEvent) { + return this.iridium.connector?.publish(`/groups/announce`, event, { + encrypt: { + recipients: [typeof event.to === 'string' ? event.to : event.to.id], + }, + }) + } + + async leaveGroup(groupId: string) { + const profile = await this.iridium.profile?.get() + + if (!this.iridium.connector || !profile) { + logger.error(this.loggerTag, 'network error') + throw new Error(GroupsError.NETWORK_ERROR) + } + // unsubscribe from group chat + const group = await this.getGroup(groupId) + await this.iridium.chat.unsubscribeFromConversation(group.id) + + // announce to group members + if (!group.members?.[this.iridium.connector.id]) { + logger.error(this.loggerTag, 'not a member of group') + throw new Error(GroupsError.NOT_A_MEMBER) + } + + Vue.delete(group.members, this.iridium.connector.id) + + Object.values(group.members).forEach(async (member) => { + const payload: IridiumGroupEvent = { + to: member.id, + status: 'group-member-left', + at: Date.now(), + group, + member: { + id: this.iridium.connector?.id as string, + name: profile?.name, + photoHash: profile?.photoHash, + }, + } + logger.info(this.loggerTag, 'announce group left', { groupId, payload }) + await this.send(payload) + }) + + // remove group from local state and iridium + const conversations = this.iridium.chat.state.conversations + Vue.delete(conversations, group.id) + this.iridium.chat.set('/conversations', conversations) + logger.info(this.loggerTag, 'group left', { groupId, group }) + } + + async removeMemberFromGroup(groupId: string, remotePeerDID: string) { + if (!this.iridium.connector) { + logger.error(this.loggerTag, 'network error') + throw new Error(GroupsError.NETWORK_ERROR) + } + + const group = await this.getGroup(groupId) + const members = group.members + if (!members || !members[remotePeerDID]) { + throw new Error(GroupsError.RECIPIENT_NOT_FOUND) + } + + Vue.delete(members, remotePeerDID) + await this.iridium.connector.set(`/groups/${groupId}`, { + id: groupId, + origin: this.iridium.connector.id, + group, + }) + logger.info(this.loggerTag, 'member removed from group', { + groupId, + memberDid: remotePeerDID, + members, + }) + } } diff --git a/libraries/Iridium/groups/types.ts b/libraries/Iridium/groups/types.ts index 6fee152448..4774bbef61 100644 --- a/libraries/Iridium/groups/types.ts +++ b/libraries/Iridium/groups/types.ts @@ -21,7 +21,19 @@ export type GroupData = GroupConfig & { export type GroupMap = { [key: string]: Group } +export type GroupManagerEvent = + | 'group-created' + | 'group-updated' + | 'group-deleted' + | 'group-member-added' + | 'group-member-removed' + | 'group-member-updated' + | 'group-member-joined' + | 'group-member-left' + export enum GroupsError { + NETWORK_ERROR = 'errors.groups.network', + NOT_A_MEMBER = 'errors.groups.not_a_member', GROUP_ALREADY_EXISTS = 'error.groups.group_already_exists', GROUP_NOT_FOUND = 'error.groups.group_not_found', GROUP_NOT_INITIALIZED = 'errors.groups.group_not_initialized',