diff --git a/.husky/post-checkout b/.husky/post-checkout deleted file mode 100755 index ca7fcb400..000000000 --- a/.husky/post-checkout +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } -git lfs post-checkout "$@" diff --git a/.husky/post-commit b/.husky/post-commit deleted file mode 100755 index 52b339cb3..000000000 --- a/.husky/post-commit +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } -git lfs post-commit "$@" diff --git a/.husky/post-merge b/.husky/post-merge deleted file mode 100755 index a912e667a..000000000 --- a/.husky/post-merge +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } -git lfs post-merge "$@" diff --git a/packages/restapi/src/lib/chat/createGroupV2.ts b/packages/restapi/src/lib/chat/createGroupV2.ts index b543e40d6..00f11a07a 100644 --- a/packages/restapi/src/lib/chat/createGroupV2.ts +++ b/packages/restapi/src/lib/chat/createGroupV2.ts @@ -22,7 +22,7 @@ export interface ChatCreateGroupTypeV2 extends EnvOptionsType { groupImage: string | null; rules: Rules | null; isPublic: boolean; - groupType: 'default' | 'space'; + groupType: 'default' | 'spaces'; config: { meta: string | null; scheduleAt: Date | null; diff --git a/packages/restapi/src/lib/chat/helpers/payloadHelper.ts b/packages/restapi/src/lib/chat/helpers/payloadHelper.ts index c206c0a7b..5455ff458 100644 --- a/packages/restapi/src/lib/chat/helpers/payloadHelper.ts +++ b/packages/restapi/src/lib/chat/helpers/payloadHelper.ts @@ -11,6 +11,7 @@ import { SpaceAccess, GroupInfoDTO, ChatMemberProfile, + SpaceInfoDTO, } from '../../types'; import { ENV } from '../../constants'; import { IPGPHelper, PGPHelper, pgpDecrypt } from './pgp'; @@ -19,6 +20,7 @@ import { MessageObj } from '../../types/messageTypes'; import * as CryptoJS from 'crypto-js'; import { getAllGroupMembers } from '../getAllGroupMembers'; +import { ChatListType, SpaceListType } from '../../pushapi/pushAPITypes'; export interface ISendMessagePayload { fromDID: string; toDID: string; @@ -332,6 +334,57 @@ export const groupDtoToSpaceDtoV2 = async ( return spaceDto; }; +export const groupInfoDtoToSpaceInfoDto = ( + groupInfoDto: GroupInfoDTO +): SpaceInfoDTO => { + const spaceInfoDto: SpaceInfoDTO = { + spaceName: groupInfoDto.groupName, + spaceImage: groupInfoDto.groupImage, + spaceDescription: groupInfoDto.groupDescription, + isPublic: groupInfoDto.isPublic, + spaceCreator: groupInfoDto.groupCreator, + spaceId: groupInfoDto.chatId, + scheduleAt: groupInfoDto.scheduleAt, + scheduleEnd: groupInfoDto.scheduleEnd, + status: groupInfoDto.status ?? null, + rules: groupInfoDto.rules ?? null, + meta: groupInfoDto.meta ?? null, + sessionKey: groupInfoDto.sessionKey ?? null, + encryptedSecret: groupInfoDto.encryptedSecret ?? null, + }; + return spaceInfoDto; +}; + +export const spaceDtoToSpaceInfoDto = (spaceDto: SpaceDTO): SpaceInfoDTO => { + return { + spaceName: spaceDto.spaceName, + spaceImage: spaceDto.spaceImage, + spaceDescription: spaceDto.spaceDescription, + isPublic: spaceDto.isPublic, + spaceCreator: spaceDto.spaceCreator, + spaceId: spaceDto.spaceId, + scheduleAt: spaceDto.scheduleAt, + scheduleEnd: spaceDto.scheduleEnd, + status: spaceDto.status, + rules: spaceDto.rules, + meta: spaceDto.meta, + sessionKey: null, + encryptedSecret: null, + inviteeDetails: spaceDto.inviteeDetails, + }; +}; + +export const mapSpaceListTypeToChatListType = (type: SpaceListType): ChatListType => { + switch (type) { + case SpaceListType.SPACES: + return ChatListType.CHATS; + case SpaceListType.REQUESTS: + return ChatListType.REQUESTS; + default: + throw new Error(`Unsupported SpaceListType: ${type}`); + } +} + export const convertSpaceRulesToRules = (spaceRules: SpaceRules): Rules => { return { entry: spaceRules.entry, diff --git a/packages/restapi/src/lib/pushapi/PushAPI.ts b/packages/restapi/src/lib/pushapi/PushAPI.ts index 8beea967d..812e54117 100644 --- a/packages/restapi/src/lib/pushapi/PushAPI.ts +++ b/packages/restapi/src/lib/pushapi/PushAPI.ts @@ -16,6 +16,7 @@ import { STREAM, } from '../pushstream/pushStreamTypes'; import { ALPHA_FEATURE_CONFIG } from '../config'; +import { Space } from './space'; import { Video } from './video'; import { isValidCAIP10NFTAddress } from '../helpers'; @@ -30,6 +31,7 @@ export class PushAPI { private progressHook?: (progress: ProgressHookType) => void; public chat: Chat; // Public instances to be accessed from outside the class + public space: Space; public video: Video; public profile: Profile; @@ -74,6 +76,13 @@ export class PushAPI { this.signer, this.progressHook ); + this.space = new Space( + this.account, + this.env, + this.decryptedPgpPvtKey, + this.signer, + this.progressHook + ); this.profile = new Profile( this.account, this.env, diff --git a/packages/restapi/src/lib/pushapi/pushAPITypes.ts b/packages/restapi/src/lib/pushapi/pushAPITypes.ts index 48753dbc4..57ff674b1 100644 --- a/packages/restapi/src/lib/pushapi/pushAPITypes.ts +++ b/packages/restapi/src/lib/pushapi/pushAPITypes.ts @@ -1,11 +1,23 @@ import Constants, { ENV } from '../constants'; +import { + ChatStatus, + ProgressHookType, + Rules, + SpaceData, + SpaceRules, +} from '../types'; import type { PushStream } from '../pushstream/PushStream'; -import { ChatStatus, ProgressHookType, Rules } from '../types'; export enum ChatListType { CHATS = 'CHATS', REQUESTS = 'REQUESTS', } + +export enum SpaceListType { + SPACES = 'SPACES', + REQUESTS = 'REQUESTS', +} + export interface PushAPIInitializeProps { env?: ENV; progressHook?: (progress: ProgressHookType) => void; @@ -33,6 +45,15 @@ export interface ManageGroupOptions { accounts: string[]; } +export interface ManageSpaceOptions { + role: 'SPEAKER' | 'LISTENER'; + accounts: string[]; +} + +export interface RemoveFromSpaceOptions { + accounts: string[]; +} + export interface RemoveFromGroupOptions { role?: 'ADMIN' | 'MEMBER'; accounts: string[]; @@ -58,16 +79,58 @@ export interface GroupUpdateOptions { rules?: Rules | null; } +export interface SpaceUpdateOptions { + name?: string; + description?: string; + image?: string; + scheduleAt?: Date | null; + scheduleEnd?: Date | null; + status?: ChatStatus | null; + meta?: string | null; + rules?: SpaceRules | null; +} + export interface InfoOptions { overrideAccount?: string; } +export interface SpaceCreationOptions { + description: string; + image: string; + participants: { + speakers: string[]; + listeners: string[]; + }; + schedule: { + start: Date; + end?: Date; + }; + rules?: SpaceRules; + private: boolean; +} + +export interface SpaceQueryOptions { + page: number; + limit: number; +} + export interface ParticipantStatus { pending: boolean; role: 'admin' | 'member'; participant: boolean; } +export interface SpaceParticipantStatus { + pending: boolean; + role: 'SPEAKER' | 'LISTENER'; + participant: boolean; +} + +export interface SpaceInitializeOptions { + spaceId: string; + setSpaceData: (fn: (data: SpaceData) => SpaceData) => void; +} + export interface VideoInitializeOptions { stream: PushStream; config: { diff --git a/packages/restapi/src/lib/pushapi/space.ts b/packages/restapi/src/lib/pushapi/space.ts new file mode 100644 index 000000000..004bbc026 --- /dev/null +++ b/packages/restapi/src/lib/pushapi/space.ts @@ -0,0 +1,581 @@ +import { ENV } from '../constants'; +import { + SignerType, + ProgressHookType, + SpaceInfoDTO, + GroupInfoDTO, + SpaceAccess, + SpaceIFeeds, + IFeeds, + Message, + MessageWithCID, + IMessageIPFS, + GroupParticipantCounts, + ChatMemberProfile, + SpaceMemberProfile, +} from '../types'; +import { + GetGroupParticipantsOptions, + ManageSpaceOptions, + RemoveFromSpaceOptions, + SpaceCreationOptions, + SpaceInitializeOptions, + SpaceListType, + SpaceParticipantStatus, + SpaceQueryOptions, + SpaceUpdateOptions, +} from './pushAPITypes'; +import * as PUSH_SPACE from '../space'; +import * as PUSH_CHAT from '../chat'; + +import { User } from './user'; +import { PushAPI } from './PushAPI'; +import { + ChatUpdateGroupProfileType, + updateGroupProfile, +} from '../chat/updateGroupProfile'; +import { updateGroupConfig } from '../chat/updateGroupConfig'; +import { + groupInfoDtoToSpaceInfoDto, + mapSpaceListTypeToChatListType, +} from '../chat'; +import { isValidETHAddress } from '../helpers'; +import { Chat } from './chat'; +import { Signer as PushSigner } from '../helpers'; + +import { SpaceV2 } from '../space/SpaceV2'; +import { Space as SpaceV1 } from '../space/Space'; + +export class Space { + private chatInstance: Chat; + + constructor( + private account: string, + private env: ENV, + private decryptedPgpPvtKey?: string, + private signer?: SignerType, + private progressHook?: (progress: ProgressHookType) => void + ) { + this.chatInstance = new Chat( + this.account, + this.env, + { feature: [] }, + this.decryptedPgpPvtKey, + this.signer + ); + } + + async create( + name: string, + options: SpaceCreationOptions + ): Promise<SpaceInfoDTO> { + if (!this.signer) { + throw new Error('Signer is required to create a space.'); + } + + const createSpaceOptions: PUSH_SPACE.ChatCreateSpaceTypeV2 = { + signer: this.signer, + pgpPrivateKey: this.decryptedPgpPvtKey, + spaceName: name, + spaceDescription: options.description || null, + listeners: options.participants.listeners, + speakers: options.participants.speakers, + spaceImage: options.image || null, + isPublic: !options.private, + rules: options.rules || {}, + config: { + scheduleAt: options.schedule.start, + scheduleEnd: options.schedule.end || null, + }, + env: this.env, + }; + return await PUSH_SPACE.createV2(createSpaceOptions); + } + + async update( + spaceId: string, + options: SpaceUpdateOptions + ): Promise<SpaceInfoDTO> { + if (!this.signer) { + throw new Error(PushAPI.ensureSignerMessage()); + } + let group = null; + + try { + group = await PUSH_CHAT.getGroupInfo({ + chatId: spaceId, + env: this.env, + }); + if (!group) { + throw new Error('Space not found'); + } + } catch (error) { + throw new Error('Space not found'); + } + + const updateGroupProfileOptions: ChatUpdateGroupProfileType = { + chatId: spaceId, + groupName: options.name ? options.name : group.groupName, + groupDescription: options.description + ? options.description + : group.groupDescription, + groupImage: options.image ? options.image : group.groupImage, + rules: options.rules ? options.rules : group.rules, + account: this.account, + pgpPrivateKey: this.decryptedPgpPvtKey, + env: this.env, + }; + const updateGroupConfigOptions = { + chatId: spaceId, + meta: options.meta ? options.meta : group.meta, + scheduleAt: options.scheduleAt ? options.scheduleAt : group.scheduleAt, + scheduleEnd: options.scheduleEnd + ? options.scheduleEnd + : group.scheduleEnd, + status: options.status ? options.status : group.status, + account: this.account, + pgpPrivateKey: this.decryptedPgpPvtKey, + env: this.env, + }; + await updateGroupProfile(updateGroupProfileOptions); + const groupDto = await updateGroupConfig(updateGroupConfigOptions); + return groupInfoDtoToSpaceInfoDto(groupDto); + } + + async info(spaceId: string): Promise<SpaceInfoDTO> { + const groupDto = await PUSH_CHAT.getGroupInfo({ + chatId: spaceId, + env: this.env, + }); + return groupInfoDtoToSpaceInfoDto(groupDto); + } + + participants = { + list: async ( + chatId: string, + options?: GetGroupParticipantsOptions + ): Promise<{ members: SpaceMemberProfile[] }> => { + const { page = 1, limit = 20 } = options ?? {}; + const getGroupMembersOptions: PUSH_CHAT.FetchChatGroupInfoType = { + chatId, + page, + limit, + env: this.env, + }; + + const chatMembers = await PUSH_CHAT.getGroupMembers( + getGroupMembersOptions + ); + const members: SpaceMemberProfile[] = chatMembers.map( + (member: ChatMemberProfile): SpaceMemberProfile => { + return { + address: member.address, + intent: member.intent, + role: + member.role.toUpperCase() === 'ADMIN' ? 'SPEAKER' : 'LISTENER', + userInfo: member.userInfo, + }; + } + ); + return { members }; + }, + + count: async (chatId: string): Promise<GroupParticipantCounts> => { + const count = await PUSH_CHAT.getGroupMemberCount({ + chatId, + env: this.env, + }); + return { + participants: count.overallCount - count.pendingCount, + pending: count.pendingCount, + }; + }, + + status: async ( + chatId: string, + accountId: string + ): Promise<SpaceParticipantStatus> => { + const status = await PUSH_CHAT.getGroupMemberStatus({ + chatId: chatId, + did: accountId, + env: this.env, + }); + + return { + pending: status.isPending, + role: status.isAdmin ? 'SPEAKER' : 'LISTENER', + participant: status.isMember, + }; + }, + }; + + async permissions(spaceId: string): Promise<SpaceAccess> { + const getGroupAccessOptions: PUSH_CHAT.GetGroupAccessType = { + chatId: spaceId, + did: this.account, + env: this.env, + }; + return await PUSH_CHAT.getGroupAccess(getGroupAccessOptions); + } + + async add( + spaceId: string, + options: ManageSpaceOptions + ): Promise<SpaceInfoDTO> { + if (!this.signer) { + throw new Error(PushAPI.ensureSignerMessage()); + } + const { role, accounts } = options; + + const validRoles = ['SPEAKER', 'LISTENER']; + if (!validRoles.includes(role)) { + throw new Error('Invalid role provided.'); + } + + if (!accounts || accounts.length === 0) { + throw new Error('accounts array cannot be empty!'); + } + + accounts.forEach((account) => { + if (!isValidETHAddress(account)) { + throw new Error(`Invalid account address: ${account}`); + } + }); + + let response: GroupInfoDTO; + if (role === 'SPEAKER') { + response = await PUSH_CHAT.addAdmins({ + chatId: spaceId, + admins: accounts, + env: this.env, + account: this.account, + signer: this.signer, + pgpPrivateKey: this.decryptedPgpPvtKey, + overrideSecretKeyGeneration: false, + }); + } else { + response = await PUSH_CHAT.addMembers({ + chatId: spaceId, + members: accounts, + env: this.env, + account: this.account, + signer: this.signer, + pgpPrivateKey: this.decryptedPgpPvtKey, + overrideSecretKeyGeneration: false, + }); + } + return groupInfoDtoToSpaceInfoDto(response); + } + + async remove( + spaceId: string, + options: RemoveFromSpaceOptions + ): Promise<SpaceInfoDTO> { + const { accounts } = options; + + if (!this.signer) { + throw new Error(PushAPI.ensureSignerMessage()); + } + + if (!accounts || accounts.length === 0) { + throw new Error('Accounts array cannot be empty!'); + } + + accounts.forEach((account) => { + if (!isValidETHAddress(account)) { + throw new Error(`Invalid account address: ${account}`); + } + }); + + const adminsToRemove = []; + const membersToRemove = []; + + for (const account of accounts) { + const status = await PUSH_CHAT.getGroupMemberStatus({ + chatId: spaceId, + did: account, + env: this.env, + }); + + if (status.isAdmin) { + adminsToRemove.push(account); + } else if (status.isMember) { + membersToRemove.push(account); + } + } + if (adminsToRemove.length > 0) { + await PUSH_CHAT.removeAdmins({ + chatId: spaceId, + admins: adminsToRemove, + env: this.env, + account: this.account, + signer: this.signer, + pgpPrivateKey: this.decryptedPgpPvtKey, + overrideSecretKeyGeneration: false, + }); + } + + if (membersToRemove.length > 0) { + await PUSH_CHAT.removeMembers({ + chatId: spaceId, + members: membersToRemove, + env: this.env, + account: this.account, + signer: this.signer, + pgpPrivateKey: this.decryptedPgpPvtKey, + overrideSecretKeyGeneration: false, + }); + } + return await this.info(spaceId); + } + + async modify( + spaceId: string, + options: ManageSpaceOptions + ): Promise<SpaceInfoDTO> { + const { role, accounts } = options; + if (!this.signer) { + throw new Error(PushAPI.ensureSignerMessage()); + } + const validRoles = ['SPEAKER', 'LISTENER']; + if (!validRoles.includes(role)) { + throw new Error('Invalid role provided.'); + } + + if (!accounts || accounts.length === 0) { + throw new Error('accounts array cannot be empty!'); + } + + accounts.forEach((account) => { + if (!isValidETHAddress(account)) { + throw new Error(`Invalid account address: ${account}`); + } + }); + + let newRole = null; + + if (role === 'SPEAKER') { + newRole = 'ADMIN'; + } else { + newRole = 'MEMBER'; + } + const response = await PUSH_CHAT.modifyRoles({ + chatId: spaceId, + newRole: newRole as 'ADMIN' | 'MEMBER', + members: accounts, + env: this.env, + account: this.account, + signer: this.signer, + pgpPrivateKey: this.decryptedPgpPvtKey, + overrideSecretKeyGeneration: false, + }); + return groupInfoDtoToSpaceInfoDto(response); + } + + async join(spaceId: string): Promise<SpaceInfoDTO> { + if (!this.signer) { + throw new Error(PushAPI.ensureSignerMessage()); + } + const status = await PUSH_CHAT.getGroupMemberStatus({ + chatId: spaceId, + did: this.account, + env: this.env, + }); + + if (status.isPending) { + await PUSH_CHAT.approve({ + senderAddress: spaceId, + env: this.env, + account: this.account, + signer: this.signer, + pgpPrivateKey: this.decryptedPgpPvtKey, + overrideSecretKeyGeneration: false, + }); + } else if (!status.isMember) { + await PUSH_CHAT.addMembers({ + chatId: spaceId, + members: [this.account], + env: this.env, + account: this.account, + signer: this.signer, + pgpPrivateKey: this.decryptedPgpPvtKey, + overrideSecretKeyGeneration: false, + }); + } + return await this.info(spaceId); + } + + async leave(spaceId: string): Promise<SpaceInfoDTO> { + if (!this.signer) { + throw new Error(PushAPI.ensureSignerMessage()); + } + + const status = await PUSH_CHAT.getGroupMemberStatus({ + chatId: spaceId, + did: this.account, + env: this.env, + }); + + let response: GroupInfoDTO; + + if (status.isAdmin) { + response = await PUSH_CHAT.removeAdmins({ + chatId: spaceId, + admins: [this.account], + env: this.env, + account: this.account, + signer: this.signer, + pgpPrivateKey: this.decryptedPgpPvtKey, + overrideSecretKeyGeneration: false, + }); + } else { + response = await PUSH_CHAT.removeMembers({ + chatId: spaceId, + members: [this.account], + env: this.env, + account: this.account, + signer: this.signer, + pgpPrivateKey: this.decryptedPgpPvtKey, + overrideSecretKeyGeneration: false, + }); + } + return groupInfoDtoToSpaceInfoDto(response); + } + + async search( + term: string, + options?: SpaceQueryOptions + ): Promise<SpaceInfoDTO[]> { + const { page = 1, limit = 20 } = options ?? {}; + const response = await PUSH_SPACE.search({ + searchTerm: term, + pageNumber: page, + pageSize: limit, + env: this.env, + }); + return response.map((space) => PUSH_CHAT.spaceDtoToSpaceInfoDto(space)); + } + + async trending(options?: SpaceQueryOptions): Promise<SpaceIFeeds[]> { + const { page = 1, limit = 20 } = options ?? {}; + const response = await PUSH_SPACE.trending({ + page: page, + limit: limit, + env: this.env, + }); + return response; + } + + async list( + type: SpaceListType, + options?: { + /** + * @default 1 + */ + page?: number; + limit?: number; + overrideAccount?: string; + } + ): Promise<IFeeds[]> { + const accountToUse = options?.overrideAccount || this.account; + + const listParams = { + account: accountToUse, + pgpPrivateKey: this.decryptedPgpPvtKey, + page: options?.page, + limit: options?.limit, + env: this.env, + toDecrypt: !!this.signer, // Set to false if signer is undefined or null, + }; + + switch (type) { + case SpaceListType.SPACES: + return await PUSH_SPACE.spaces(listParams); + case SpaceListType.REQUESTS: + return await PUSH_SPACE.requests(listParams); + default: + throw new Error('Invalid Space List Type'); + } + } + + async accept(spaceId: string): Promise<string> { + if (!this.signer) { + throw new Error(PushAPI.ensureSignerMessage()); + } + return this.chatInstance.accept(spaceId); + } + + async reject(spaceId: string): Promise<void> { + if (!this.signer) { + throw new Error(PushAPI.ensureSignerMessage()); + } + return this.chatInstance.reject(spaceId); + } + + get chat() { + return { + send: async ( + recipient: string, + options: Message + ): Promise<MessageWithCID> => { + return this.chatInstance.send(recipient, options); + }, + decrypt: async (messages: IMessageIPFS[]) => { + if (!this.signer) { + throw new Error(PushAPI.ensureSignerMessage()); + } + return await this.chatInstance.decrypt(messages); + }, + latest: async (target: string) => { + return await this.chatInstance.latest(target); + }, + history: async ( + target: string, + options?: { reference?: string | null; limit?: number } + ) => { + return await this.chatInstance.history(target, options); + }, + }; + } + + async initialize(options: SpaceInitializeOptions): Promise<SpaceV2> { + const { setSpaceData, spaceId } = options; + + if (!this.signer) { + throw new Error('Signer is required for push space'); + } + + if (!this.decryptedPgpPvtKey) { + throw new Error( + 'PushSDK was initialized in readonly mode. Space functionality is not available.' + ); + } + + const chainId = await new PushSigner(this.signer).getChainId(); + + if (!chainId) { + throw new Error('Chain Id not retrievable from signer'); + } + + // Initialize the spacev1 instance with the provided options + const spaceV1Instance = new SpaceV1({ + signer: this.signer!, + chainId, + pgpPrivateKey: this.decryptedPgpPvtKey!, + setSpaceData: setSpaceData, + address: this.account, + env: this.env, + }); + + // Call the space v1 initialize() method to populate the space data + await spaceV1Instance.initialize({ spaceId }); + + const spaceInfo = await this.info(spaceId); + + // Return an instance of the space v2 class + return new SpaceV2({ + spaceV1Instance, + spaceInfo, + }); + } +} diff --git a/packages/restapi/src/lib/pushstream/DataModifier.ts b/packages/restapi/src/lib/pushstream/DataModifier.ts index 57cf9b387..c28704df8 100644 --- a/packages/restapi/src/lib/pushstream/DataModifier.ts +++ b/packages/restapi/src/lib/pushstream/DataModifier.ts @@ -1,3 +1,4 @@ +import { SpaceRules } from '../types'; import { CreateGroupEvent, GroupMeta, @@ -6,7 +7,6 @@ import { MessageRawData, MessageEvent, MessageEventType, - GroupMember, GroupEventType, LeaveGroupEvent, JoinGroupEvent, @@ -17,9 +17,11 @@ import { NotificationType, NOTIFICATION, ProposedEventNames, + SpaceRequestEvent, + SpaceRemoveEvent, VideoEventType, MessageOrigin, - VideoEvent, + VideoEvent } from './pushStreamTypes'; import { VideoCallStatus, VideoPeerInfo } from '../types'; import { VideoDataType } from '../video'; @@ -381,6 +383,35 @@ export class DataModifier { } } + public static convertToProposedNameForSpace( + currentEventName: string + ): ProposedEventNames { + switch (currentEventName) { + case 'create': + return ProposedEventNames.CreateSpace; + case 'update': + return ProposedEventNames.UpdateSpace; + case 'request': + return ProposedEventNames.SpaceRequest; + case 'accept': + return ProposedEventNames.SpaceAccept; + case 'reject': + return ProposedEventNames.SpaceReject; + case 'leaveSpace': + return ProposedEventNames.LeaveSpace; + case 'joinSpace': + return ProposedEventNames.JoinSpace; + case 'remove': + return ProposedEventNames.SpaceRemove; + case 'start': + return ProposedEventNames.StartSpace; + case 'stop': + return ProposedEventNames.StopSpace; + default: + throw new Error(`Unknown current event name: ${currentEventName}`); + } + } + public static handleToField(data: any): void { switch (data.event) { case ProposedEventNames.LeaveGroup: @@ -400,6 +431,353 @@ export class DataModifier { } } + public static handleSpaceEvent(data: any, includeRaw = false): any { + // Check the eventType and map accordingly + switch (data.eventType) { + case 'create': + return this.mapToCreateSpaceEvent(data, includeRaw); + case 'update': + return this.mapToUpdateSpaceEvent(data, includeRaw); + case 'request': + return this.mapToRequestSpaceEvent(data, includeRaw); + case 'remove': + return this.mapToRemoveSpaceEvent(data, includeRaw); + case 'joinSpace': + return this.mapToJoinSpaceEvent(data, includeRaw); + case 'leaveSpace': + return this.mapToLeaveSpaceEvent(data, includeRaw); + case 'start': + return this.mapToStartSpaceEvent(data, includeRaw); + case 'stop': + return this.mapToStopSpaceEvent(data, includeRaw); + default: + // If the eventType is unknown, check for known messageCategories + switch (data.messageCategory) { + case 'Approve': + return this.mapToSpaceApproveEvent(data, includeRaw); + + case 'Reject': + return this.mapToSpaceRejectEvent(data, includeRaw); + // Add other cases as needed for different message categories + default: + console.warn( + 'Unknown eventType or messageCategory for space:', + data.eventType, + data.messageCategory + ); + return data; + } + } + } + + private static mapToCreateSpaceEvent(data: any, includeRaw: boolean): any { + type BaseEventData = { + event: string; + origin: string; + timestamp: string; + spaceId: string; + from: string; + meta: { + name: string; + description: string; + image: string; + owner: string; + private: boolean; + rules: SpaceRules; + }; + raw?: { + verificationProof: string; + }; + }; + + const baseEventData: BaseEventData = { + event: data.eventType, + origin: data.messageOrigin, + timestamp: data.timestamp, + spaceId: data.spaceId, + from: data.spaceCreator, + meta: { + name: data.spaceName, + description: data.spaceDescription, + image: data.spaceImage, + owner: data.spaceCreator, + private: !data.isPublic, + rules: data.rules || {}, + }, + }; + + if (includeRaw) { + baseEventData.raw = { + verificationProof: data.verificationProof || '', + }; + } + + return baseEventData; + } + + private static mapToUpdateSpaceEvent(data: any, includeRaw: boolean): any { + type BaseEventData = { + event: string; + origin: string; + timestamp: string; + spaceId: string; + from: string; + meta: { + name: string; + description: string; + image: string; + owner: string; + private: boolean; + rules: SpaceRules; + }; + raw?: { + verificationProof: string; + }; + }; + + const baseEventData: BaseEventData = { + event: data.eventType, + origin: data.messageOrigin, + timestamp: data.timestamp, + spaceId: data.spaceId, + from: data.spaceCreator, + meta: { + name: data.spaceName, + description: data.spaceDescription, + image: data.spaceImage, + owner: data.spaceCreator, + private: !data.isPublic, + rules: data.rules || {}, + }, + }; + + if (includeRaw) { + baseEventData.raw = { + verificationProof: data.verificationProof || '', + }; + } + + return baseEventData; + } + + private static mapToRequestSpaceEvent(data: any, includeRaw: boolean): any { + const eventData: SpaceRequestEvent = { + origin: data.messageOrigin, + timestamp: data.timestamp, + spaceId: data.spaceId, + from: data.from, + to: data.to, + event: MessageEventType.Request, + }; + + if (includeRaw) { + eventData.raw = { verificationProof: data.verificationProof }; + } + return eventData; + } + + private static mapToSpaceApproveEvent(data: any, includeRaw: boolean): any { + type BaseEventData = { + event: string; + origin: string; + timestamp: any; + spaceId: any; + from: any; + to: any[]; + raw?: { + verificationProof: string; + }; + }; + + const baseEventData: BaseEventData = { + event: 'request', + origin: data.messageOrigin === 'other' ? 'self' : 'other', + timestamp: data.timestamp, + spaceId: data.chatId, + from: data.fromCAIP10, + to: [data.toCAIP10], + }; + + if (includeRaw) { + baseEventData.raw = { + verificationProof: data.verificationProof || '', + }; + } + + return baseEventData; + } + + private static mapToSpaceRejectEvent(data: any, includeRaw: boolean): any { + type BaseEventData = { + event: string; + origin: string; + timestamp: string; + spaceId: string; + from: string; + to: null; + raw?: { + verificationProof: string; + }; + }; + + const baseEventData: BaseEventData = { + event: 'reject', + origin: data.messageOrigin === 'other' ? 'other' : 'self', + timestamp: data.timestamp.toString(), + spaceId: data.chatId, + from: data.fromCAIP10, + to: null, + }; + + if (includeRaw) { + baseEventData.raw = { + verificationProof: data.verificationProof || '', + }; + } + + return baseEventData; + } + + private static mapToRemoveSpaceEvent(data: any, includeRaw: boolean): any { + type BaseEventData = { + event: string; + origin: string; + timestamp: string; + spaceId: string; + from: string; + to: null; + raw?: { + verificationProof: string; + }; + }; + + const eventData: BaseEventData = { + origin: data.messageOrigin, + timestamp: data.timestamp, + spaceId: data.spaceId, + from: data.from, + to: data.to, + event: 'remove', + }; + + if (includeRaw) { + eventData.raw = { verificationProof: data.verificationProof }; + } + return eventData; + } + + private static mapToJoinSpaceEvent(data: any, includeRaw: boolean): any { + type BaseEventData = { + event: string; + origin: string; + timestamp: string; + spaceId: string; + from: string; + to: null; + raw?: { + verificationProof: string; + }; + }; + + const eventData: BaseEventData = { + origin: data.messageOrigin, + timestamp: data.timestamp, + spaceId: data.spaceId, + from: data.from, + to: data.to, + event: data.eventType, + }; + + if (includeRaw) { + eventData.raw = { verificationProof: data.verificationProof }; + } + return eventData; + } + + private static mapToLeaveSpaceEvent(data: any, includeRaw: boolean): any { + type BaseEventData = { + event: string; + origin: string; + timestamp: string; + spaceId: string; + from: string; + to: null; + raw?: { + verificationProof: string; + }; + }; + + const eventData: BaseEventData = { + origin: data.messageOrigin, + timestamp: data.timestamp, + spaceId: data.spaceId, + from: data.from, + to: data.to, + event: data.eventType, + }; + + if (includeRaw) { + eventData.raw = { verificationProof: data.verificationProof }; + } + return eventData; + } + + private static mapToStartSpaceEvent(data: any, includeRaw: boolean): any { + type BaseEventData = { + event: string; + origin: string; + timestamp: string; + spaceId: string; + from: string; + to: null; + raw?: { + verificationProof: string; + }; + }; + + const eventData: BaseEventData = { + origin: data.messageOrigin, + timestamp: data.timestamp, + spaceId: data.spaceId, + from: data.from, + to: null, + event: data.eventType, + }; + + if (includeRaw) { + eventData.raw = { verificationProof: data.verificationProof }; + } + return eventData; + } + + private static mapToStopSpaceEvent(data: any, includeRaw: boolean): any { + type BaseEventData = { + event: string; + origin: string; + timestamp: string; + spaceId: string; + from: string; + to: null; + raw?: { + verificationProof: string; + }; + }; + + const eventData: BaseEventData = { + origin: data.messageOrigin, + timestamp: data.timestamp, + spaceId: data.spaceId, + from: data.from, + to: null, + event: data.eventType, + }; + + if (includeRaw) { + eventData.raw = { verificationProof: data.verificationProof }; + } + return eventData; + } + public static convertToProposedNameForVideo( currentVideoStatus: VideoCallStatus ): VideoEventType { diff --git a/packages/restapi/src/lib/pushstream/PushStream.ts b/packages/restapi/src/lib/pushstream/PushStream.ts index 6fdc051fe..bdd3c3f70 100644 --- a/packages/restapi/src/lib/pushstream/PushStream.ts +++ b/packages/restapi/src/lib/pushstream/PushStream.ts @@ -13,7 +13,9 @@ import { MessageOrigin, NotificationEventType, PushStreamInitializeProps, - STREAM + SpaceEventType, + STREAM, + EVENTS } from './pushStreamTypes'; import { createSocketConnection } from './socketClient'; @@ -100,7 +102,9 @@ export class PushStream extends EventEmitter { !this.listen || this.listen.length === 0 || this.listen.includes(STREAM.CHAT) || - this.listen.includes(STREAM.CHAT_OPS); + this.listen.includes(STREAM.CHAT_OPS) || + this.listen.includes(STREAM.SPACE) || + this.listen.includes(STREAM.SPACE_OPS); const shouldInitializeNotifSocket = !this.listen || this.listen.length === 0 || @@ -224,7 +228,6 @@ export class PushStream extends EventEmitter { this.pushChatSocket.on(EVENTS.DISCONNECT, async () => { await handleSocketDisconnection('chat'); - //console.log(`Chat Socket Disconnected`); }); this.pushChatSocket.on(EVENTS.CHAT_GROUPS, (data: any) => { @@ -298,6 +301,57 @@ export class PushStream extends EventEmitter { } } ); + + this.pushChatSocket.on('SPACES', (data: any) => { + try { + const modifiedData = DataModifier.handleSpaceEvent(data, this.raw); + modifiedData.event = DataModifier.convertToProposedNameForSpace( + modifiedData.event + ); + + DataModifier.handleToField(modifiedData); + + if (this.shouldEmitSpace(data.spaceId)) { + if ( + data.eventType === SpaceEventType.Join || + data.eventType === SpaceEventType.Leave || + data.eventType === MessageEventType.Request || + data.eventType === SpaceEventType.Remove || + data.eventType === SpaceEventType.Start || + data.eventType === SpaceEventType.Stop + ) { + if (shouldEmit(STREAM.SPACE)) { + this.emit(STREAM.SPACE, modifiedData); + } + } else { + if (shouldEmit(STREAM.SPACE_OPS)) { + this.emit(STREAM.SPACE_OPS, modifiedData); + } + } + } + } catch (error) { + console.error('Error handling SPACES event:', error, 'Data:', data); + } + }); + + this.pushChatSocket.on('SPACES_MESSAGES', (data: any) => { + try { + const modifiedData = DataModifier.handleSpaceEvent(data, this.raw); + modifiedData.event = DataModifier.convertToProposedNameForSpace( + modifiedData.event + ); + + DataModifier.handleToField(modifiedData); + + if (this.shouldEmitSpace(data.spaceId)) { + if (shouldEmit(STREAM.SPACE)) { + this.emit(STREAM.SPACE, modifiedData); + } + } + } catch (error) { + console.error('Error handling SPACES event:', error, 'Data:', data); + } + }); } if (this.pushNotificationSocket) { @@ -413,6 +467,17 @@ export class PushStream extends EventEmitter { return this.options.filter.chats.includes(dataChatId); } + private shouldEmitSpace(dataSpaceId: string): boolean { + if ( + !this.options.filter?.spaces || + this.options.filter.spaces.length === 0 || + this.options.filter.spaces.includes('*') + ) { + return true; + } + return this.options.filter.spaces.includes(dataSpaceId); + } + private shouldEmitChannel(dataChannelId: string): boolean { if ( !this.options.filter?.channels || diff --git a/packages/restapi/src/lib/pushstream/pushStreamTypes.ts b/packages/restapi/src/lib/pushstream/pushStreamTypes.ts index b72b26831..6b2be17c5 100644 --- a/packages/restapi/src/lib/pushstream/pushStreamTypes.ts +++ b/packages/restapi/src/lib/pushstream/pushStreamTypes.ts @@ -5,6 +5,7 @@ export type PushStreamInitializeProps = { filter?: { channels?: string[]; chats?: string[]; + spaces?: string[]; video?: string[]; }; connection?: { @@ -23,6 +24,8 @@ export enum STREAM { NOTIF_OPS = 'STREAM.NOTIF_OPS', CHAT = 'STREAM.CHAT', CHAT_OPS = 'STREAM.CHAT_OPS', + SPACE = 'STREAM.SPACE', + SPACE_OPS = 'STREAM.SPACE_OPS', VIDEO = 'STREAM.VIDEO', CONNECT = 'STREAM.CONNECT', DISCONNECT = 'STREAM.DISCONNECT', @@ -53,6 +56,16 @@ export enum GroupEventType { Remove = 'remove', } +export enum SpaceEventType { + CreateSpace = 'createSpace', + UpdateSpace = 'updateSpace', + Join = 'joinSpace', + Leave = 'leaveSpace', + Remove = 'remove', + Stop = 'stop', + Start = 'start' +} + export enum VideoEventType { REQUEST = 'video.request', APPROVE = 'video.approve', @@ -74,6 +87,17 @@ export enum ProposedEventNames { CreateGroup = 'chat.group.create', UpdateGroup = 'chat.group.update', Remove = 'chat.group.participant.remove', + + CreateSpace = 'space.create', + UpdateSpace = 'space.update', + SpaceRequest = 'space.request', + SpaceAccept = 'space.accept', + SpaceReject = 'space.reject', + LeaveSpace = 'space.participant.leave', + JoinSpace = 'space.participant.join', + SpaceRemove = 'space.participant.remove', + StartSpace = 'space.start', + StopSpace = 'space.stop' } export interface Profile { @@ -151,6 +175,34 @@ export interface RemoveEvent extends GroupMemberEventBase { event: GroupEventType.Remove; } + + +export interface SpaceMemberEventBase { + event: SpaceEventType | MessageEventType; + origin: MessageOrigin; + timestamp: string; + spaceId: string; + from: string; + to: string[]; + raw?: GroupEventRawData; +} + +export interface JoinSpaceEvent extends SpaceMemberEventBase { + event: SpaceEventType.JoinSpace; +} + +export interface LeaveSpaceEvent extends SpaceMemberEventBase { + event: SpaceEventType.LeaveSpace; +} + +export interface SpaceRequestEvent extends SpaceMemberEventBase { + event: MessageEventType.Request; +} + +export interface SpaceRemoveEvent extends SpaceMemberEventBase { + event: SpaceEventType.Remove; +} + export interface MessageEvent { event: MessageEventType; origin: MessageOrigin; diff --git a/packages/restapi/src/lib/space/SpaceV2.ts b/packages/restapi/src/lib/space/SpaceV2.ts new file mode 100644 index 000000000..5d80f6608 --- /dev/null +++ b/packages/restapi/src/lib/space/SpaceV2.ts @@ -0,0 +1,103 @@ +import { SPACE_INVITE_ROLES } from '../payloads/constants'; +import { SpaceInfoDTO } from '../types'; +import Space from './Space'; +import { ChatUpdateSpaceType } from './update'; + +export class SpaceV2 { + private spaceV1Instance: Space; + private spaceInfo: SpaceInfoDTO; + + constructor({ + spaceV1Instance, + spaceInfo, + }: { + spaceV1Instance: Space; + spaceInfo: SpaceInfoDTO; + }) { + this.spaceV1Instance = spaceV1Instance; + this.spaceInfo = spaceInfo; + } + + async activateUserAudio() { + await this.spaceV1Instance.createAudioStream(); + } + + async start() { + await this.spaceV1Instance.start(); + } + + async join() { + await this.spaceV1Instance.join(); + } + + async update(updateSpaceOptions: ChatUpdateSpaceType) { + await this.spaceV1Instance.update(updateSpaceOptions); + } + + async leave() { + await this.spaceV1Instance.leave(); + } + + async stop() { + await this.spaceV1Instance.stop(); + } + + async requestForMic() { + await this.spaceV1Instance.requestToBePromoted({ + role: SPACE_INVITE_ROLES.SPEAKER, + promotorAddress: this.spaceInfo.spaceCreator, + }); + } + + async acceptMicRequest({ + address, + signal, + }: { + address: string; + signal: any; + }) { + await this.spaceV1Instance.acceptPromotionRequest({ + promoteeAddress: address, + spaceId: this.spaceInfo.spaceId, + role: SPACE_INVITE_ROLES.SPEAKER, + signalData: signal, + }); + } + + async rejectMicRequest({ address }: { address: string }) { + await this.spaceV1Instance.rejectPromotionRequest({ + promoteeAddress: address, + }); + } + + async inviteToPromote({ address }: { address: string }) { + await this.spaceV1Instance.inviteToPromote({ + inviteeAddress: address, + role: SPACE_INVITE_ROLES.SPEAKER, + }); + } + + async acceptPromotionInvite({ signal }: { signal: any }) { + await this.spaceV1Instance.acceptPromotionInvite({ + invitorAddress: this.spaceInfo.spaceCreator, + spaceId: this.spaceInfo.spaceId, + signalData: signal, + }); + } + + async rejectPromotionInvite() { + await this.spaceV1Instance.rejectPromotionInvite({ + invitorAddress: this.spaceInfo.spaceCreator, + }); + } + + media({ video, audio }: { video?: boolean; audio?: boolean }) { + if (typeof video === 'boolean') { + this.spaceV1Instance.enableVideo({ state: video }); + } + + if (typeof audio === 'boolean') { + this.spaceV1Instance.enableAudio({ state: audio }); + } + } +} diff --git a/packages/restapi/src/lib/space/createV2.ts b/packages/restapi/src/lib/space/createV2.ts new file mode 100644 index 000000000..0023b997d --- /dev/null +++ b/packages/restapi/src/lib/space/createV2.ts @@ -0,0 +1,73 @@ +import Constants from '../constants'; +import { EnvOptionsType, SignerType, SpaceInfoDTO, SpaceRules } from '../types'; +import { + convertSpaceRulesToRules, + groupInfoDtoToSpaceInfoDto, +} from './../chat/helpers'; +import { createGroupV2 } from '../chat'; + +export interface ChatCreateSpaceTypeV2 extends EnvOptionsType { + account?: string | null; + signer?: SignerType | null; + pgpPrivateKey?: string | null; + spaceName: string; + spaceDescription: string | null; + spaceImage: string | null; + listeners: Array<string>; + speakers: Array<string>; + isPublic: boolean; + rules?: SpaceRules | null; + config: { + scheduleAt: Date; + scheduleEnd: Date | null; + }; +} + +export async function createV2( + options: ChatCreateSpaceTypeV2 +): Promise<SpaceInfoDTO> { + const { + signer, + spaceName, + spaceDescription, + listeners, + spaceImage, + speakers, + isPublic, + env = Constants.ENV.PROD, + pgpPrivateKey = null, + rules, + config, + } = options || {}; + + const spaceRules = rules ? convertSpaceRulesToRules(rules) : null; + + try { + const group = await createGroupV2({ + signer, + groupName: spaceName, + groupDescription: spaceDescription, + members: listeners, + groupImage: spaceImage, + admins: speakers, + isPublic: isPublic, + env, + pgpPrivateKey, + groupType: 'spaces', + config: { + meta: null, + scheduleAt: config.scheduleAt, + scheduleEnd: config.scheduleEnd ?? null, + status: 'PENDING', + }, + rules: spaceRules, + }); + + return groupInfoDtoToSpaceInfoDto(group); + } catch (err) { + console.error(`[Push SDK] - API - Error - API ${createV2.name} -: `, err); + throw new Error( + `[Push SDK] - API - Error - API ${createV2.name} -: ${err}` + ); + } +} diff --git a/packages/restapi/src/lib/space/index.ts b/packages/restapi/src/lib/space/index.ts index eb2a6ca34..f02efd454 100644 --- a/packages/restapi/src/lib/space/index.ts +++ b/packages/restapi/src/lib/space/index.ts @@ -14,6 +14,7 @@ export * from './approve'; export * from './requests'; export * from './getAccess'; export * from './search'; +export * from './createV2'; export {spaceFeed as space} from './spaceFeed'; export * from './Space' diff --git a/packages/restapi/src/lib/types/index.ts b/packages/restapi/src/lib/types/index.ts index 841229adc..9ea2175a0 100644 --- a/packages/restapi/src/lib/types/index.ts +++ b/packages/restapi/src/lib/types/index.ts @@ -398,6 +398,11 @@ export interface GroupAccess { rules?: Rules; } +export interface SpaceAccess { + entry: boolean; + rules?: SpaceRules; +} + export interface GroupMemberStatus { isMember: boolean; isPending: boolean; @@ -438,6 +443,13 @@ export interface ChatMemberProfile { userInfo: UserV2; } +export interface SpaceMemberProfile { + address: string; + intent: boolean; + role: string; + userInfo: UserV2; +} + export interface GroupMembersInfo { totalMembersCount: number; members: ChatMemberProfile[]; @@ -514,6 +526,23 @@ export interface GroupInfoDTO { encryptedSecret: string | null; } +export interface SpaceInfoDTO { + spaceName: string; + spaceImage: string | null; + spaceDescription: string; + isPublic: boolean; + spaceCreator: string; + spaceId: string; + scheduleAt?: Date | null; + scheduleEnd?: Date | null; + status?: ChatStatus | null; + rules?: Rules | null; + meta?: string | null; + sessionKey: string | null; + encryptedSecret: string | null; + inviteeDetails?: { [key: string]: SPACE_INVITE_ROLES }; +} + export interface SpaceDTO { members: { wallet: string; diff --git a/packages/restapi/tests/lib/space/space.test.ts b/packages/restapi/tests/lib/space/space.test.ts new file mode 100644 index 000000000..f535e758b --- /dev/null +++ b/packages/restapi/tests/lib/space/space.test.ts @@ -0,0 +1,249 @@ +import { expect } from 'chai'; +import { ethers } from 'ethers'; +import Constants from '../../../src/lib/constants'; +import { + adjectives, + animals, + colors, + uniqueNamesGenerator, +} from 'unique-names-generator'; +import { PushAPI } from '../../../src/lib/pushapi/PushAPI'; // Ensure correct import path + +const _env = Constants.ENV.DEV; +let spaceName: string; +let spaceDescription: string; +const spaceImage = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAvklEQVR4AcXBsW2FMBiF0Y8r3GQb6jeBxRauYRpo4yGQkMd4A7kg7Z/GUfSKe8703fKDkTATZsJsrr0RlZSJ9r4RLayMvLmJjnQS1d6IhJkwE2bT13U/DBzp5BN73xgRZsJMmM1HOolqb/yWiWpvjJSUiRZWopIykTATZsJs5g+1N6KSMiO1N/5DmAkzYTa9Lh6MhJkwE2ZzSZlo7xvRwson3txERzqJhJkwE2bT6+JhoKTMJ2pvjAgzYSbMfgDlXixqjH6gRgAAAABJRU5ErkJggg=='; +const meta = 'Random Group Meta'; +let userAlice: PushAPI; +let userBob: PushAPI; +let userJohn: PushAPI; + +describe('PushAPI.space', () => { + let account1: string; + let account2: string; + let account3: string; + beforeEach(async () => { + spaceName = uniqueNamesGenerator({ + dictionaries: [adjectives, colors, animals], + }); + spaceDescription = uniqueNamesGenerator({ + dictionaries: [adjectives, colors, animals], + }); + + const WALLET1 = ethers.Wallet.createRandom(); + const signer1 = new ethers.Wallet(WALLET1.privateKey); + account1 = `eip155:${signer1.address}`; + userAlice = await PushAPI.initialize(signer1, { env: _env }); + + const WALLET2 = ethers.Wallet.createRandom(); + const signer2 = new ethers.Wallet(WALLET2.privateKey); + account2 = `eip155:${signer2.address}`; + userBob = await PushAPI.initialize(signer2, { env: _env }); + + const WALLET3 = ethers.Wallet.createRandom(); + const signer3 = new ethers.Wallet(WALLET3.privateKey); + account3 = `eip155:${signer3.address}`; + userJohn = await PushAPI.initialize(signer3, { env: _env }); + }); + describe('space', () => { + it('create space', async () => { + const space = await userAlice.space.create(spaceName, { + description: spaceDescription, + image: spaceImage, + participants: { listeners: [], speakers: [] }, + schedule: { + start: new Date(new Date().getTime() + 24 * 60 * 60 * 1000), + }, + private: false, + }); + expect(space.spaceImage).to.equal(spaceImage); + expect(space.spaceName).to.equal(spaceName); + expect(space.spaceDescription).to.equal(spaceDescription); + }); + + it('update space', async () => { + const space = await userAlice.space.create(spaceName, { + description: spaceDescription, + image: spaceImage, + participants: { listeners: [], speakers: [] }, + schedule: { + start: new Date(new Date().getTime() + 24 * 60 * 60 * 1000), + }, + private: false, + }); + expect(space.spaceImage).to.equal(spaceImage); + expect(space.spaceName).to.equal(spaceName); + expect(space.spaceDescription).to.equal(spaceDescription); + const updatedGroup = await userAlice.space.update(space.spaceId, { + name: 'Updated Test Grp', + description: 'Updated Test Grp Description', + meta: 'Updated Meta', + }); + expect(updatedGroup.spaceImage).to.equal(spaceImage); + expect(updatedGroup.spaceName).to.equal('Updated Test Grp'); + expect(updatedGroup.spaceDescription).to.equal( + 'Updated Test Grp Description' + ); + expect(updatedGroup.meta).to.equal('Updated Meta'); + }); + + it('should retrieve space info correctly', async () => { + const newSpace = await userAlice.space.create(spaceName, { + description: spaceDescription, + image: spaceImage, + participants: { listeners: [], speakers: [] }, + schedule: { + start: new Date(new Date().getTime() + 24 * 60 * 60 * 1000), + }, + private: false, + }); + + // Retrieve space info + const spaceInfo = await userAlice.space.info(newSpace.spaceId); + + // Assertions to validate the returned data + expect(spaceInfo.spaceId).to.equal(newSpace.spaceId); + expect(spaceInfo.spaceName).to.equal(newSpace.spaceName); + expect(spaceInfo.spaceImage).to.equal(newSpace.spaceImage); + expect(spaceInfo.spaceDescription).to.equal(newSpace.spaceDescription); + expect(spaceInfo.isPublic).to.equal(true); + expect(spaceInfo.spaceCreator).to.equal(account1); + expect(spaceInfo.status).to.equal('PENDING'); + expect(spaceInfo.sessionKey).to.be.null; + expect(spaceInfo.encryptedSecret).to.be.null; + expect(spaceInfo.rules).to.be.an('object'); + }); + + it('should retrieve participant counts correctly', async () => { + // Create a new space + const newSpace = await userAlice.space.create(spaceName, { + description: spaceDescription, + image: spaceImage, + participants: { listeners: [], speakers: [] }, + schedule: { + start: new Date(new Date().getTime() + 24 * 60 * 60 * 1000), + }, + private: false, + }); + + // Retrieve participant counts for the space + const participantCounts = await userAlice.space.participants.count( + newSpace.spaceId + ); + + // Assertions to validate the returned data + expect(participantCounts).to.be.an('object'); + expect(participantCounts).to.have.property('participants'); + expect(participantCounts).to.have.property('pending'); + expect(participantCounts.participants).to.be.a('number'); + expect(participantCounts.pending).to.be.a('number'); + expect(participantCounts.participants).to.equal(1); + expect(participantCounts.pending).to.equal(0); + }); + + it('should retrieve participant counts correctly with add', async () => { + // Create a new space + const newSpace = await userAlice.space.create(spaceName, { + description: spaceDescription, + image: spaceImage, + participants: { listeners: [], speakers: [] }, + schedule: { + start: new Date(new Date().getTime() + 24 * 60 * 60 * 1000), + }, + private: false, + }); + + await userAlice.space.add(newSpace.spaceId, { + role: 'LISTENER', + accounts: [account2], + }); + + // Retrieve participant counts for the space + const participantCounts = await userAlice.space.participants.count( + newSpace.spaceId + ); + + // Assertions to validate the returned data + expect(participantCounts).to.be.an('object'); + expect(participantCounts).to.have.property('participants'); + expect(participantCounts).to.have.property('pending'); + expect(participantCounts.participants).to.be.a('number'); + expect(participantCounts.pending).to.be.a('number'); + expect(participantCounts.participants).to.equal(1); + expect(participantCounts.pending).to.equal(1); + }); + + it('should retrieve participant counts correctly with join', async () => { + // Create a new space + const newSpace = await userAlice.space.create(spaceName, { + description: spaceDescription, + image: spaceImage, + participants: { listeners: [], speakers: [] }, + schedule: { + start: new Date(new Date().getTime() + 24 * 60 * 60 * 1000), + }, + private: false, + }); + + await userAlice.space.add(newSpace.spaceId, { + role: 'LISTENER', + accounts: [account3], + }); + + await userBob.space.join(newSpace.spaceId); + + // Retrieve participant counts for the space + const participantCounts = await userAlice.space.participants.count( + newSpace.spaceId + ); + + // Assertions to validate the returned data + expect(participantCounts).to.be.an('object'); + expect(participantCounts).to.have.property('participants'); + expect(participantCounts).to.have.property('pending'); + expect(participantCounts.participants).to.be.a('number'); + expect(participantCounts.pending).to.be.a('number'); + expect(participantCounts.participants).to.equal(2); + expect(participantCounts.pending).to.equal(1); + }); + + it('should retrieve participant counts correctly with remove', async () => { + // Create a new space + const newSpace = await userAlice.space.create(spaceName, { + description: spaceDescription, + image: spaceImage, + participants: { listeners: [], speakers: [] }, + schedule: { + start: new Date(new Date().getTime() + 24 * 60 * 60 * 1000), + }, + private: false, + }); + + await userAlice.space.add(newSpace.spaceId, { + role: 'LISTENER', + accounts: [account3], + }); + + await userBob.space.join(newSpace.spaceId); + + await userAlice.space.remove(newSpace.spaceId, { + accounts: [account3], + }); + + // Retrieve participant counts for the space + const participantCounts = await userAlice.space.participants.count( + newSpace.spaceId + ); + + // Assertions to validate the returned data + expect(participantCounts).to.be.an('object'); + expect(participantCounts).to.have.property('participants'); + expect(participantCounts).to.have.property('pending'); + expect(participantCounts.participants).to.be.a('number'); + expect(participantCounts.pending).to.be.a('number'); + expect(participantCounts.participants).to.equal(2); + expect(participantCounts.pending).to.equal(0); + }); + }); +}); diff --git a/packages/restapi/tests/lib/stream/initialize.test.ts b/packages/restapi/tests/lib/stream/initialize.test.ts index 6346fae44..426ce0b50 100644 --- a/packages/restapi/tests/lib/stream/initialize.test.ts +++ b/packages/restapi/tests/lib/stream/initialize.test.ts @@ -158,6 +158,7 @@ describe('PushStream.initialize functionality', () => { const stream = await userAlice.initStream( [ CONSTANTS.STREAM.CHAT, + CONSTANTS.STREAM.CHAT_OPS, CONSTANTS.STREAM.NOTIF, CONSTANTS.STREAM.CONNECT, CONSTANTS.STREAM.DISCONNECT, @@ -245,7 +246,7 @@ describe('PushStream.initialize functionality', () => { ); // Create and update group - const createdGroup = await user.chat.group.create( + const createdGroup = await userAlice.chat.group.create( 'test', CREATE_GROUP_REQUEST_2 ); diff --git a/packages/restapi/tests/lib/stream/space.test.ts b/packages/restapi/tests/lib/stream/space.test.ts new file mode 100644 index 000000000..f9ccffe94 --- /dev/null +++ b/packages/restapi/tests/lib/stream/space.test.ts @@ -0,0 +1,156 @@ +import * as path from 'path'; +import * as dotenv from 'dotenv'; +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); +import { expect } from 'chai'; // Assuming you're using chai for assertions +import { ethers } from 'ethers'; +import { PushAPI } from '../../../src/lib/pushapi/PushAPI'; +import CONSTANTS from '../../../src/lib/constantsV2'; + +import * as util from 'util'; +import { PushStream } from '../../../src/lib/pushstream/PushStream'; + +describe.only('PushStream.initialize functionality', () => { + it('Should initialize new stream and listen to events', async () => { + const spaceDescription = 'Hey There!!!'; + const spaceImage = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAvklEQVR4AcXBsW2FMBiF0Y8r3GQb6jeBxRauYRpo4yGQkMd4A7kg7Z/GUfSKe8703fKDkTATZsJsrr0RlZSJ9r4RLayMvLmJjnQS1d6IhJkwE2bT13U/DBzp5BN73xgRZsJMmM1HOolqb/yWiWpvjJSUiRZWopIykTATZsJs5g+1N6KSMiO1N/5DmAkzYTa9Lh6MhJkwE2ZzSZlo7xvRwson3txERzqJhJkwE2bT6+JhoKTMJ2pvjAgzYSbMfgDlXixqjH6gRgAAAABJRU5ErkJggg=='; + const provider = ethers.getDefaultProvider(); + + const WALLET = ethers.Wallet.createRandom(); + const signer = new ethers.Wallet(WALLET.privateKey, provider); + + const userAlice = await PushAPI.initialize(signer, { + env: CONSTANTS.ENV.LOCAL, + }); + + const WALLET2 = ethers.Wallet.createRandom(); + const signer2 = new ethers.Wallet(WALLET2.privateKey); + const account2 = `eip155:${signer2.address}`; + const userBob = await PushAPI.initialize(signer2, { + env: CONSTANTS.ENV.LOCAL, + }); + + const userAliceStream = await userAlice.initStream( + [ + CONSTANTS.STREAM.CHAT, + CONSTANTS.STREAM.CHAT_OPS, + CONSTANTS.STREAM.SPACE, + CONSTANTS.STREAM.SPACE_OPS, + CONSTANTS.STREAM.NOTIF, + CONSTANTS.STREAM.CONNECT, + CONSTANTS.STREAM.DISCONNECT, + ], + { raw: true } + ); + + userAliceStream.on(CONSTANTS.STREAM.CONNECT, () => { + console.log('Stream Connected'); + }); + + await userAliceStream.connect(); + + userAliceStream.on(CONSTANTS.STREAM.DISCONNECT, () => { + console.log('Stream Disconnected'); + }); + + const CREATE_GROUP_REQUEST_2 = { + description: spaceDescription, + image: spaceImage, + participants: { listeners: [], speakers: [] }, + schedule: { + start: new Date(new Date().getTime() + 24 * 60 * 60 * 1000), + }, + private: false, + }; + + const createdSpace = await userAlice.space.create( + 'test', + CREATE_GROUP_REQUEST_2 + ); + + /*const updatedGroup = await userAlice.space.update(createdSpace.spaceId, { + name: 'Updated Test Grp', + description: 'Updated Test Grp Description', + meta: 'Updated Meta', + }); + + //await userBob.space.join(updatedGroup.spaceId); + //await userBob.space.leave(updatedGroup.spaceId);*/ + + const createEventPromise = ( + expectedEvent: string, + eventType: string, + expectedEventCount: number, + stream: PushStream + ) => { + return new Promise((resolve, reject) => { + let eventCount = 0; + if (expectedEventCount == 0) { + resolve('Done'); + } + const receivedEvents: any[] = []; + stream.on(eventType, (data: any) => { + try { + receivedEvents.push(data); + eventCount++; + + console.log( + `Event ${eventCount} for ${expectedEvent}:`, + util.inspect(data, { + showHidden: false, + depth: null, + colors: true, + }) + ); + expect(data).to.not.be.null; + + if (eventCount === expectedEventCount) { + resolve(receivedEvents); + } + } catch (error) { + console.error('An error occurred:', error); + reject(error); + } + }); + }); + }; + + const onDataReceived = createEventPromise( + CONSTANTS.STREAM.SPACE_OPS, + CONSTANTS.STREAM.SPACE_OPS, + 2, + userAliceStream + ); + + let timeoutTriggered = false; + + const timeout = new Promise((_, reject) => { + setTimeout(() => { + timeoutTriggered = true; + reject(new Error('Timeout after 5 seconds')); + }, 5000); + }); + + // Wrap the Promise.allSettled inside a Promise.race with the timeout + try { + const result = await Promise.race([ + Promise.allSettled([onDataReceived]), + timeout, + ]); + + if (timeoutTriggered) { + console.error('Timeout reached before events were emitted.'); + } else { + (result as PromiseSettledResult<any>[]).forEach((outcome) => { + if (outcome.status === 'fulfilled') { + //console.log(outcome.value); + } else if (outcome.status === 'rejected') { + console.error(outcome.reason); + } + }); + } + } catch (error) { + console.error(error); + } + }); +}); diff --git a/packages/socket/src/lib/constants.ts b/packages/socket/src/lib/constants.ts index b4639acc5..6f49a3294 100644 --- a/packages/socket/src/lib/constants.ts +++ b/packages/socket/src/lib/constants.ts @@ -29,6 +29,8 @@ export const EVENTS = { // Chat CHAT_RECEIVED_MESSAGE: 'CHATS', - CHAT_GROUPS: 'CHAT_GROUPS' - + CHAT_GROUPS: 'CHAT_GROUPS', + + SPACES: 'SPACES', + SPACES_MESSAGES: 'SPACES_MESSAGES', }; \ No newline at end of file