UNPKG

@river-build/sdk

Version:

For more details, visit the following resources:

985 lines 86.9 kB
import { create, toBinary, toJsonString } from '@bufbuild/protobuf'; import { Permission, SpaceAddressFromSpaceId, SpaceReviewAction, } from '@river-build/web3'; import { MembershipOp, ChannelOp, Err, BlockchainTransactionReceipt_LogSchema, BlockchainTransactionReceiptSchema, ChannelPropertiesSchema, FullyReadMarkersSchema, ChunkedMediaSchema, EncryptedDataSchema, UserBioSchema, MemberPayload_NftSchema, ChannelMessageSchema, SolanaBlockchainTransactionReceiptSchema, SessionKeysSchema, EnvelopeSchema, } from '@river-build/proto'; import { bin_fromHexString, bin_toHexString, shortenHexString, check, dlog, dlogError, bin_fromString, } from '@river-build/dlog'; import { AES_GCM_DERIVED_ALGORITHM, GroupEncryptionAlgorithmId, GroupEncryptionCrypto, makeSessionKeys, } from '@river-build/encryption'; import { getMaxTimeoutMs, getMiniblocks } from './makeStreamRpcClient'; import { errorContains, getRpcErrorProperty } from './rpcInterceptors'; import { assert, isDefined } from './check'; import EventEmitter from 'events'; import { isChannelStreamId, isDMChannelStreamId, isGDMChannelStreamId, isSpaceStreamId, makeDMStreamId, makeUniqueGDMChannelStreamId, makeUniqueMediaStreamId, makeUserMetadataStreamId, makeUserSettingsStreamId, makeUserStreamId, makeUserInboxStreamId, userIdFromAddress, addressFromUserId, streamIdAsBytes, streamIdAsString, makeSpaceStreamId, STREAM_ID_STRING_LENGTH, contractAddressFromSpaceId, isUserId, } from './id'; import { makeEvent, unpackEnvelope, unpackStream, unpackStreamEx } from './sign'; import { StreamStateView } from './streamStateView'; import { make_UserMetadataPayload_Inception, make_ChannelPayload_Inception, make_ChannelPayload_Message, make_MemberPayload_Membership2, make_SpacePayload_Inception, make_UserPayload_Inception, make_SpacePayload_ChannelUpdate, make_UserSettingsPayload_FullyReadMarkers, make_UserSettingsPayload_UserBlock, make_UserSettingsPayload_Inception, make_MediaPayload_Inception, make_MediaPayload_Chunk, make_DMChannelPayload_Inception, make_DMChannelPayload_Message, make_GDMChannelPayload_Inception, make_GDMChannelPayload_Message, make_UserInboxPayload_Ack, make_UserInboxPayload_Inception, make_UserMetadataPayload_EncryptionDevice, make_UserInboxPayload_GroupEncryptionSessions, make_GDMChannelPayload_ChannelProperties, make_UserPayload_UserMembershipAction, make_UserPayload_UserMembership, make_MemberPayload_DisplayName, make_MemberPayload_Username, getRefEventIdFromChannelMessage, make_ChannelPayload_Redaction, make_MemberPayload_EnsAddress, make_MemberPayload_Nft, make_MemberPayload_Pin, make_MemberPayload_Unpin, make_SpacePayload_UpdateChannelAutojoin, make_SpacePayload_UpdateChannelHideUserJoinLeaveEvents, make_SpacePayload_SpaceImage, make_UserMetadataPayload_ProfileImage, make_UserMetadataPayload_Bio, make_UserPayload_BlockchainTransaction, make_MemberPayload_EncryptionAlgorithm, isSolanaTransactionReceipt, } from './types'; import debug from 'debug'; import { getTime, usernameChecksum } from './utils'; import { isEncryptedContentKind, toDecryptedContent } from './encryptedContentTypes'; import { ClientDecryptionExtensions } from './clientDecryptionExtensions'; import { PersistenceStore, StubPersistenceStore, } from './persistenceStore'; import { SyncedStreams } from './syncedStreams'; import { SyncState } from './syncedStreamsLoop'; import { SyncedStream } from './syncedStream'; import { SyncedStreamsExtension } from './syncedStreamsExtension'; import { decryptAESGCM, deriveKeyAndIV, encryptAESGCM, uint8ArrayToBase64 } from './crypto_utils'; import { makeTags, makeTipTags, makeTransferTags } from './tags'; export class Client extends EventEmitter { opts; signerContext; rpcClient; userId; streams; userStreamId; userSettingsStreamId; userMetadataStreamId; userInboxStreamId; logCall; logSync; logEmitFromStream; logEmitFromClient; logEvent; logError; logInfo; logDebug; cryptoBackend; cryptoStore; getStreamRequests = new Map(); getStreamExRequests = new Map(); initStreamRequests = new Map(); getScrollbackRequests = new Map(); creatingStreamIds = new Set(); entitlementsDelegate; decryptionExtensions; syncedStreamsExtensions; persistenceStore; defaultGroupEncryptionAlgorithm; logId; constructor(signerContext, rpcClient, cryptoStore, entitlementsDelegate, opts) { super(); this.opts = opts; if (opts?.logNamespaceFilter) { debug.enable(opts.logNamespaceFilter); } assert(isDefined(signerContext.creatorAddress) && signerContext.creatorAddress.length === 20, 'creatorAddress must be set'); assert(isDefined(signerContext.signerPrivateKey()) && signerContext.signerPrivateKey().length === 64, 'signerPrivateKey must be set'); this.entitlementsDelegate = entitlementsDelegate; this.signerContext = signerContext; this.rpcClient = rpcClient; this.userId = userIdFromAddress(signerContext.creatorAddress); this.defaultGroupEncryptionAlgorithm = opts?.defaultGroupEncryptionAlgorithm ?? GroupEncryptionAlgorithmId.HybridGroupEncryption; this.logId = opts?.logId ?? shortenHexString(this.userId.startsWith('0x') ? this.userId.slice(2) : this.userId); this.logCall = dlog('csb:cl:call').extend(this.logId); this.logSync = dlog('csb:cl:sync').extend(this.logId); this.logEmitFromStream = dlog('csb:cl:stream').extend(this.logId); this.logEmitFromClient = dlog('csb:cl:emit').extend(this.logId); this.logEvent = dlog('csb:cl:event').extend(this.logId); this.logError = dlogError('csb:cl:error').extend(this.logId); this.logInfo = dlog('csb:cl:info', { defaultEnabled: true }).extend(this.logId); this.logDebug = dlog('csb:cl:debug').extend(this.logId); this.cryptoStore = cryptoStore; if (opts?.persistenceStoreName) { this.persistenceStore = new PersistenceStore(opts.persistenceStoreName); } else { this.persistenceStore = new StubPersistenceStore(); } this.streams = new SyncedStreams(this.userId, this.rpcClient, this, opts?.unpackEnvelopeOpts, this.logId, opts?.streamOpts); this.syncedStreamsExtensions = new SyncedStreamsExtension(opts?.highPriorityStreamIds, { startSyncStreams: async () => { this.streams.startSyncStreams(); this.decryptionExtensions?.start(); }, initStream: (streamId, allowGetStream, persistedData) => this.initStream(streamId, allowGetStream, persistedData), emitClientInitStatus: (status) => this.emit('clientInitStatusUpdated', status), }, this.persistenceStore, this.logId); this.logCall('new Client'); } get streamSyncActive() { return this.streams.syncState === SyncState.Syncing; } get clientInitStatus() { check(this.syncedStreamsExtensions !== undefined, 'syncedStreamsExtensions must be set'); return this.syncedStreamsExtensions.initStatus; } get cryptoInitialized() { return this.cryptoBackend !== undefined; } async stop() { this.logCall('stop'); await this.decryptionExtensions?.stop(); await this.syncedStreamsExtensions?.stop(); await this.stopSync(); } stream(streamId) { return this.streams.get(streamId); } createSyncedStream(streamId) { check(!this.streams.has(streamId), 'stream already exists'); const stream = new SyncedStream(this.userId, streamIdAsString(streamId), this, this.logEmitFromStream, this.persistenceStore); this.streams.set(streamId, stream); return stream; } initUserJoinedStreams() { assert(isDefined(this.userStreamId), 'userStreamId must be set'); assert(isDefined(this.syncedStreamsExtensions), 'syncedStreamsExtensions must be set'); const stream = this.stream(this.userStreamId); assert(isDefined(stream), 'userStream must be set'); stream.on('userJoinedStream', (s) => void this.onJoinedStream(s)); stream.on('userInvitedToStream', (s) => void this.onInvitedToStream(s)); stream.on('userLeftStream', (s) => void this.onLeftStream(s)); this.on('streamInitialized', (s) => void this.onStreamInitialized(s)); const streamIds = Object.entries(stream.view.userContent.streamMemberships).reduce((acc, [streamId, payload]) => { if (payload.op === MembershipOp.SO_JOIN || (payload.op === MembershipOp.SO_INVITE && (isDMChannelStreamId(streamId) || isGDMChannelStreamId(streamId)))) { acc.push(streamId); } return acc; }, []); this.syncedStreamsExtensions.setStreamIds(streamIds); } async initializeUser(opts) { const initUserMetadata = opts?.spaceId ? { spaceId: streamIdAsBytes(opts?.spaceId), } : undefined; const initializeUserStartTime = performance.now(); this.logCall('initializeUser', this.userId); assert(this.userStreamId === undefined, 'already initialized'); const initCrypto = await getTime(() => this.initCrypto(opts?.encryptionDeviceInit)); check(isDefined(this.decryptionExtensions), 'decryptionExtensions must be defined'); check(isDefined(this.syncedStreamsExtensions), 'syncedStreamsExtensions must be defined'); const [initUserStream, initUserInboxStream, initUserMetadataStream, initUserSettingsStream,] = await Promise.all([ getTime(() => this.initUserStream(initUserMetadata)), getTime(() => this.initUserInboxStream(initUserMetadata)), getTime(() => this.initUserMetadataStream(initUserMetadata)), getTime(() => this.initUserSettingsStream(initUserMetadata)), ]); this.initUserJoinedStreams(); this.syncedStreamsExtensions.start(); const initializeUserEndTime = performance.now(); const executionTime = initializeUserEndTime - initializeUserStartTime; this.logCall('initializeUser::executionTime', executionTime); // all of these init calls follow a similar pattern and call highly similar functions // so just tracking more granular times for a single one of these as a start, so there's not too much data to digest const initUserMetadataTimes = initUserMetadataStream.result; return { initCryptoTime: initCrypto.time, initUserStreamTime: initUserStream.time, initUserInboxStreamTime: initUserInboxStream.time, initUserMetadataStreamTime: initUserMetadataStream.time, initUserSettingsStreamTime: initUserSettingsStream.time, ...initUserMetadataTimes, }; } onNetworkStatusChanged(isOnline) { this.streams.onNetworkStatusChanged(isOnline); } async initUserStream(metadata) { this.userStreamId = makeUserStreamId(this.userId); const userStream = this.createSyncedStream(this.userStreamId); if (!(await userStream.initializeFromPersistence())) { const response = (await this.getUserStream(this.userStreamId)) ?? (await this.createUserStream(this.userStreamId, metadata)); await userStream.initializeFromResponse(response); } } async initUserInboxStream(metadata) { this.userInboxStreamId = makeUserInboxStreamId(this.userId); const userInboxStream = this.createSyncedStream(this.userInboxStreamId); if (!(await userInboxStream.initializeFromPersistence())) { const response = (await this.getUserStream(this.userInboxStreamId)) ?? (await this.createUserInboxStream(this.userInboxStreamId, metadata)); await userInboxStream.initializeFromResponse(response); } } async initUserMetadataStream(metadata) { this.userMetadataStreamId = makeUserMetadataStreamId(this.userId); const userMetadataStream = this.createSyncedStream(this.userMetadataStreamId); let initUserMetadataStreamInitFromPersistenceTime = 0; let initUserMetadataStreamGetUserStreamTime = 0; let initUserMetadataStreamCreateUserMetadataStreamTime = 0; let initUserMetadataStreamInitFromResponseTime = 0; const initFromPersistence = await getTime(() => userMetadataStream.initializeFromPersistence()); initUserMetadataStreamInitFromPersistenceTime = initFromPersistence.time; if (!initFromPersistence.result) { const getUserStreamResponse = await getTime(() => { check(!!this.userMetadataStreamId, 'userMetadataStreamId must be set'); return this.getUserStream(this.userMetadataStreamId); }); initUserMetadataStreamGetUserStreamTime = getUserStreamResponse.time; let response; if (getUserStreamResponse.result) { response = getUserStreamResponse.result; } else { const createUserMetadataStreamResponse = await getTime(() => { check(!!this.userMetadataStreamId, 'userMetadataStreamId must be set'); return this.createUserMetadataStream(this.userMetadataStreamId, metadata); }); initUserMetadataStreamCreateUserMetadataStreamTime = createUserMetadataStreamResponse.time; response = createUserMetadataStreamResponse.result; } const initializeFromResponse = await getTime(() => userMetadataStream.initializeFromResponse(response)); initUserMetadataStreamInitFromResponseTime = initializeFromResponse.time; } const times = { ...(initUserMetadataStreamInitFromPersistenceTime ? { initUserMetadataStreamInitFromPersistenceTime } : {}), ...(initUserMetadataStreamGetUserStreamTime ? { initUserMetadataStreamGetUserStreamTime } : {}), ...(initUserMetadataStreamCreateUserMetadataStreamTime ? { initUserMetadataStreamCreateUserMetadataStreamTime, } : {}), ...(initUserMetadataStreamInitFromResponseTime ? { initUserMetadataStreamInitFromResponseTime } : {}), }; return times; } async initUserSettingsStream(metadata) { this.userSettingsStreamId = makeUserSettingsStreamId(this.userId); const userSettingsStream = this.createSyncedStream(this.userSettingsStreamId); if (!(await userSettingsStream.initializeFromPersistence())) { const response = (await this.getUserStream(this.userSettingsStreamId)) ?? (await this.createUserSettingsStream(this.userSettingsStreamId, metadata)); await userSettingsStream.initializeFromResponse(response); } } async getUserStream(streamId) { const response = await this.rpcClient.getStream({ streamId: streamIdAsBytes(streamId), optional: true, }); if (response.stream) { return unpackStream(response.stream, this.opts?.unpackEnvelopeOpts); } else { return undefined; } } async createUserStream(userStreamId, metadata) { const userEvents = [ await makeEvent(this.signerContext, make_UserPayload_Inception({ streamId: streamIdAsBytes(userStreamId), })), ]; const response = await this.rpcClient.createStream({ events: userEvents, streamId: streamIdAsBytes(userStreamId), metadata: metadata, }); return unpackStream(response.stream, this.opts?.unpackEnvelopeOpts); } async createUserMetadataStream(userMetadataStreamId, metadata) { const userDeviceKeyEvents = [ await makeEvent(this.signerContext, make_UserMetadataPayload_Inception({ streamId: streamIdAsBytes(userMetadataStreamId), })), ]; const response = await this.rpcClient.createStream({ events: userDeviceKeyEvents, streamId: streamIdAsBytes(userMetadataStreamId), metadata: metadata, }); return unpackStream(response.stream, this.opts?.unpackEnvelopeOpts); } async createUserInboxStream(userInboxStreamId, metadata) { const userInboxEvents = [ await makeEvent(this.signerContext, make_UserInboxPayload_Inception({ streamId: streamIdAsBytes(userInboxStreamId), })), ]; const response = await this.rpcClient.createStream({ events: userInboxEvents, streamId: streamIdAsBytes(userInboxStreamId), metadata: metadata, }); return unpackStream(response.stream, this.opts?.unpackEnvelopeOpts); } async createUserSettingsStream(inUserSettingsStreamId, metadata) { const userSettingsStreamId = streamIdAsBytes(inUserSettingsStreamId); const userSettingsEvents = [ await makeEvent(this.signerContext, make_UserSettingsPayload_Inception({ streamId: userSettingsStreamId, })), ]; const response = await this.rpcClient.createStream({ events: userSettingsEvents, streamId: userSettingsStreamId, metadata: metadata, }); return unpackStream(response.stream, this.opts?.unpackEnvelopeOpts); } async createStreamAndSync(request) { const streamId = streamIdAsString(request.streamId); try { this.creatingStreamIds.add(streamId); let response = await this.rpcClient.createStream(request); const stream = this.createSyncedStream(streamId); if (!response.stream) { // if a stream alread exists it will return a nil stream in the response, but no error // fetch the stream to get the client in the rigth state response = await this.rpcClient.getStream({ streamId: request.streamId }); } const unpacked = await unpackStream(response.stream, this.opts?.unpackEnvelopeOpts); await stream.initializeFromResponse(unpacked); if (stream.view.syncCookie) { this.streams.addStreamToSync(streamId, stream.view.syncCookie); } } catch (err) { this.logError('Failed to create stream', streamId); this.streams.delete(streamId); this.creatingStreamIds.delete(streamId); throw err; } return { streamId: streamId }; } // createSpace // param spaceAddress: address of the space contract, or address made with makeSpaceStreamId async createSpace(spaceAddressOrId) { const oSpaceId = spaceAddressOrId.length === STREAM_ID_STRING_LENGTH ? spaceAddressOrId : makeSpaceStreamId(spaceAddressOrId); const spaceId = streamIdAsBytes(oSpaceId); this.logCall('createSpace', spaceId); assert(this.userStreamId !== undefined, 'streamId must be set'); assert(isSpaceStreamId(spaceId), 'spaceId must be a valid streamId'); // create utf8 encoder const inceptionEvent = await makeEvent(this.signerContext, make_SpacePayload_Inception({ streamId: spaceId, })); const joinEvent = await makeEvent(this.signerContext, make_MemberPayload_Membership2({ userId: this.userId, op: MembershipOp.SO_JOIN, initiatorId: this.userId, })); return this.createStreamAndSync({ events: [inceptionEvent, joinEvent], streamId: spaceId, metadata: {}, }); } async createChannel(spaceId, channelName, channelTopic, inChannelId, streamSettings, channelSettings) { const oChannelId = inChannelId; const channelId = streamIdAsBytes(oChannelId); this.logCall('createChannel', channelId, spaceId); assert(this.userStreamId !== undefined, 'userStreamId must be set'); assert(isSpaceStreamId(spaceId), 'spaceId must be a valid streamId'); assert(isChannelStreamId(channelId), 'channelId must be a valid streamId'); const inceptionEvent = await makeEvent(this.signerContext, make_ChannelPayload_Inception({ streamId: channelId, spaceId: streamIdAsBytes(spaceId), settings: streamSettings, channelSettings: channelSettings, })); const joinEvent = await makeEvent(this.signerContext, make_MemberPayload_Membership2({ userId: this.userId, op: MembershipOp.SO_JOIN, initiatorId: this.userId, })); return this.createStreamAndSync({ events: [inceptionEvent, joinEvent], streamId: channelId, metadata: {}, }); } async createDMChannel(userId, streamSettings) { const channelIdStr = makeDMStreamId(this.userId, userId); const channelId = streamIdAsBytes(channelIdStr); const inceptionEvent = await makeEvent(this.signerContext, make_DMChannelPayload_Inception({ streamId: channelId, firstPartyAddress: this.signerContext.creatorAddress, secondPartyAddress: addressFromUserId(userId), settings: streamSettings, })); const joinEvent = await makeEvent(this.signerContext, make_MemberPayload_Membership2({ userId: this.userId, op: MembershipOp.SO_JOIN, initiatorId: this.userId, })); const inviteEvent = await makeEvent(this.signerContext, make_MemberPayload_Membership2({ userId: userId, op: MembershipOp.SO_JOIN, initiatorId: this.userId, })); return this.createStreamAndSync({ events: [inceptionEvent, joinEvent, inviteEvent], streamId: channelId, metadata: {}, }); } async createGDMChannel(userIds, channelProperties, streamSettings) { const channelIdStr = makeUniqueGDMChannelStreamId(); const channelId = streamIdAsBytes(channelIdStr); const events = []; const inceptionEvent = await makeEvent(this.signerContext, make_GDMChannelPayload_Inception({ streamId: channelId, channelProperties: channelProperties, settings: streamSettings, })); events.push(inceptionEvent); const joinEvent = await makeEvent(this.signerContext, make_MemberPayload_Membership2({ userId: this.userId, op: MembershipOp.SO_JOIN, initiatorId: this.userId, })); events.push(joinEvent); for (const userId of userIds) { const inviteEvent = await makeEvent(this.signerContext, make_MemberPayload_Membership2({ userId: userId, op: MembershipOp.SO_JOIN, initiatorId: this.userId, })); events.push(inviteEvent); } return this.createStreamAndSync({ events: events, streamId: channelId, metadata: {}, }); } async createMediaStream(channelId, spaceId, userId, chunkCount, streamSettings, perChunkEncryption) { assert(this.userStreamId !== undefined, 'userStreamId must be set'); if (!channelId && !spaceId && !userId) { throw Error('channelId, spaceId or userId must be set'); } if (spaceId) { assert(isSpaceStreamId(spaceId), 'spaceId must be a valid streamId'); } if (channelId) { assert(isChannelStreamId(channelId) || isDMChannelStreamId(channelId) || isGDMChannelStreamId(channelId), 'channelId must be a valid streamId'); } if (userId) { assert(isUserId(userId), 'userId must be a valid userId'); } const streamId = makeUniqueMediaStreamId(); this.logCall('createMedia', channelId ?? spaceId, userId, streamId); const inceptionEvent = await makeEvent(this.signerContext, make_MediaPayload_Inception({ streamId: streamIdAsBytes(streamId), channelId: channelId ? streamIdAsBytes(channelId) : undefined, spaceId: spaceId ? streamIdAsBytes(spaceId) : undefined, userId: userId ? addressFromUserId(userId) : undefined, chunkCount, settings: streamSettings, perChunkEncryption: perChunkEncryption, })); const response = await this.rpcClient.createMediaStream({ events: [inceptionEvent], streamId: streamIdAsBytes(streamId), }); check(response?.nextCreationCookie !== undefined, 'nextCreationCookie was expected but was not returned in response'); return { creationCookie: response.nextCreationCookie }; } async updateChannel(spaceId, channelId, unused1, unused2) { this.logCall('updateChannel', channelId, spaceId, unused1, unused2); assert(isSpaceStreamId(spaceId), 'spaceId must be a valid streamId'); assert(isChannelStreamId(channelId), 'channelId must be a valid streamId'); return this.makeEventAndAddToStream(spaceId, // we send events to the stream of the space where updated channel belongs to make_SpacePayload_ChannelUpdate({ op: ChannelOp.CO_UPDATED, channelId: streamIdAsBytes(channelId), }), { method: 'updateChannel' }); } async updateChannelAutojoin(spaceId, channelId, autojoin) { this.logCall('updateChannelAutojoin', channelId, spaceId, autojoin); assert(isSpaceStreamId(spaceId), 'spaceId must be a valid streamId'); assert(isChannelStreamId(channelId), 'channelId must be a valid streamId'); return this.makeEventAndAddToStream(spaceId, // we send events to the stream of the space where updated channel belongs to make_SpacePayload_UpdateChannelAutojoin({ channelId: streamIdAsBytes(channelId), autojoin: autojoin, }), { method: 'updateChannelAutojoin' }); } async updateChannelHideUserJoinLeaveEvents(spaceId, channelId, hideUserJoinLeaveEvents) { this.logCall('updateChannelHideUserJoinLeaveEvents', channelId, spaceId, hideUserJoinLeaveEvents); assert(isSpaceStreamId(spaceId), 'spaceId must be a valid streamId'); assert(isChannelStreamId(channelId), 'channelId must be a valid streamId'); return this.makeEventAndAddToStream(spaceId, // we send events to the stream of the space where updated channel belongs to make_SpacePayload_UpdateChannelHideUserJoinLeaveEvents({ channelId: streamIdAsBytes(channelId), hideUserJoinLeaveEvents, }), { method: 'updateChannelHideUserJoinLeaveEvents' }); } async updateGDMChannelProperties(streamId, channelName, channelTopic) { this.logCall('updateGDMChannelProperties', streamId, channelName, channelTopic); assert(isGDMChannelStreamId(streamId), 'streamId must be a valid GDM stream id'); check(isDefined(this.cryptoBackend)); const channelProps = create(ChannelPropertiesSchema, { name: channelName, topic: channelTopic, }); const encryptedData = await this.cryptoBackend.encryptGroupEvent(streamId, toBinary(ChannelPropertiesSchema, channelProps), this.defaultGroupEncryptionAlgorithm); const event = make_GDMChannelPayload_ChannelProperties(encryptedData); return this.makeEventAndAddToStream(streamId, event, { method: 'updateGDMChannelProperties', }); } async setStreamEncryptionAlgorithm(streamId, encryptionAlgorithm) { assert(isChannelStreamId(streamId) || isSpaceStreamId(streamId) || isDMChannelStreamId(streamId) || isGDMChannelStreamId(streamId), 'channelId must be a valid streamId'); const stream = this.stream(streamId); check(isDefined(stream), 'stream not found'); check(stream.view.membershipContent.encryptionAlgorithm != encryptionAlgorithm, `encryptionAlgorithm is already set to ${encryptionAlgorithm}`); return this.makeEventAndAddToStream(streamId, make_MemberPayload_EncryptionAlgorithm(encryptionAlgorithm), { method: 'setStreamEncryptionAlgorithm', }); } async sendFullyReadMarkers(channelId, fullyReadMarkers) { this.logCall('sendFullyReadMarker', fullyReadMarkers); if (!isDefined(this.userSettingsStreamId)) { throw Error('userSettingsStreamId is not defined'); } const fullyReadMarkersContent = create(FullyReadMarkersSchema, { markers: fullyReadMarkers, }); return this.makeEventAndAddToStream(this.userSettingsStreamId, make_UserSettingsPayload_FullyReadMarkers({ streamId: streamIdAsBytes(channelId), content: { data: toJsonString(FullyReadMarkersSchema, fullyReadMarkersContent) }, }), { method: 'sendFullyReadMarker' }); } async updateUserBlock(userId, isBlocked) { this.logCall('blockUser', userId); if (!isDefined(this.userSettingsStreamId)) { throw Error('userSettingsStreamId is not defined'); } const dmStreamId = makeDMStreamId(this.userId, userId); const lastBlock = this.stream(this.userSettingsStreamId)?.view.userSettingsContent.getLastBlock(userId); if (lastBlock?.isBlocked === isBlocked) { throw Error(`updateUserBlock isBlocked<${isBlocked}> must be different from existing value`); } let eventNum = this.stream(dmStreamId)?.view.lastEventNum ?? 0n; if (lastBlock && lastBlock.eventNum >= eventNum) { eventNum = lastBlock.eventNum + 1n; } return this.makeEventAndAddToStream(this.userSettingsStreamId, make_UserSettingsPayload_UserBlock({ userId: addressFromUserId(userId), isBlocked: isBlocked, eventNum: eventNum, }), { method: 'updateUserBlock' }); } async setSpaceImage(spaceStreamId, chunkedMediaInfo) { this.logCall('setSpaceImage', spaceStreamId, chunkedMediaInfo.streamId, chunkedMediaInfo.info); // create the chunked media to be added const spaceAddress = contractAddressFromSpaceId(spaceStreamId); const context = spaceAddress.toLowerCase(); // encrypt the chunked media // use the lowercased spaceId as the key phrase const { key, iv } = await deriveKeyAndIV(context); const { ciphertext } = await encryptAESGCM(toBinary(ChunkedMediaSchema, create(ChunkedMediaSchema, chunkedMediaInfo)), key, iv); const encryptedData = create(EncryptedDataSchema, { ciphertext: uint8ArrayToBase64(ciphertext), algorithm: AES_GCM_DERIVED_ALGORITHM, }); // aellis this should probably include `satisfies PlainMessage<EncryptedData>` // add the event to the stream const event = make_SpacePayload_SpaceImage(encryptedData); return this.makeEventAndAddToStream(spaceStreamId, event, { method: 'setSpaceImage' }); } async setUserProfileImage(chunkedMediaInfo) { this.logCall('setUserProfileImage', chunkedMediaInfo.streamId, chunkedMediaInfo.info); // create the chunked media to be added const context = this.userId.toLowerCase(); const userStreamId = makeUserMetadataStreamId(this.userId); // encrypt the chunked media // use the lowercased userId as the key phrase const { key, iv } = await deriveKeyAndIV(context); const { ciphertext } = await encryptAESGCM(toBinary(ChunkedMediaSchema, create(ChunkedMediaSchema, chunkedMediaInfo)), key, iv); const encryptedData = create(EncryptedDataSchema, { ciphertext: uint8ArrayToBase64(ciphertext), algorithm: AES_GCM_DERIVED_ALGORITHM, }); // aellis this should probably include `satisfies PlainMessage<EncryptedData>` // add the event to the stream const event = make_UserMetadataPayload_ProfileImage(encryptedData); return this.makeEventAndAddToStream(userStreamId, event, { method: 'setUserProfileImage' }); } async getUserProfileImage(userId) { const streamId = makeUserMetadataStreamId(userId); return this.stream(streamId)?.view.userMetadataContent.getProfileImage(); } async setUserBio(bio) { this.logCall('setUserBio', bio); // create the chunked media to be added const context = this.userId.toLowerCase(); const userStreamId = makeUserMetadataStreamId(this.userId); // encrypt the chunked media // use the lowercased userId as the key phrase const { key, iv } = await deriveKeyAndIV(context); bio.updatedAtEpochMs = BigInt(Date.now()); const bioBinary = toBinary(UserBioSchema, create(UserBioSchema, bio)); const { ciphertext } = await encryptAESGCM(bioBinary, key, iv); const encryptedData = create(EncryptedDataSchema, { ciphertext: uint8ArrayToBase64(ciphertext), algorithm: AES_GCM_DERIVED_ALGORITHM, }); // aellis this should probably include `satisfies PlainMessage<EncryptedData>` // add the event to the stream const event = make_UserMetadataPayload_Bio(encryptedData); return this.makeEventAndAddToStream(userStreamId, event, { method: 'setUserBio' }); } async getUserBio(userId) { const streamId = makeUserMetadataStreamId(userId); return this.stream(streamId)?.view.userMetadataContent.getBio(); } async setDisplayName(streamId, displayName) { check(isDefined(this.cryptoBackend)); const encryptedData = await this.cryptoBackend.encryptGroupEvent(streamId, new TextEncoder().encode(displayName), this.defaultGroupEncryptionAlgorithm); await this.makeEventAndAddToStream(streamId, make_MemberPayload_DisplayName(encryptedData), { method: 'displayName' }); } async setUsername(streamId, username) { check(isDefined(this.cryptoBackend)); const stream = this.stream(streamId); check(isDefined(stream), 'stream not found'); stream.view.getMemberMetadata().usernames.setLocalUsername(this.userId, username); const encryptedData = await this.cryptoBackend.encryptGroupEvent(streamId, new TextEncoder().encode(username), this.defaultGroupEncryptionAlgorithm); encryptedData.checksum = usernameChecksum(username, streamId); try { await this.makeEventAndAddToStream(streamId, make_MemberPayload_Username(encryptedData), { method: 'username', }); } catch (err) { stream.view.getMemberMetadata().usernames.resetLocalUsername(this.userId); throw err; } } async setEnsAddress(streamId, walletAddress) { check(isDefined(this.cryptoBackend)); const bytes = typeof walletAddress === 'string' ? addressFromUserId(walletAddress) : walletAddress; await this.makeEventAndAddToStream(streamId, make_MemberPayload_EnsAddress(bytes), { method: 'ensAddress', }); } async setNft(streamId, tokenId, chainId, contractAddress) { const payload = tokenId.length > 0 ? create(MemberPayload_NftSchema, { chainId: chainId, contractAddress: bin_fromHexString(contractAddress), tokenId: bin_fromString(tokenId), }) : create(MemberPayload_NftSchema); await this.makeEventAndAddToStream(streamId, make_MemberPayload_Nft(payload), { method: 'nft', }); } async pin(streamId, eventId) { const stream = this.streams.get(streamId); check(isDefined(stream), 'stream not found'); const event = stream.view.events.get(eventId); check(isDefined(event), 'event not found'); const remoteEvent = event.remoteEvent; check(isDefined(remoteEvent), 'remoteEvent not found'); const result = await this.makeEventAndAddToStream(streamId, make_MemberPayload_Pin(remoteEvent.hash, remoteEvent.event), { method: 'pin', }); return result; } async unpin(streamId, eventId) { const stream = this.streams.get(streamId); check(isDefined(stream), 'stream not found'); const pin = stream.view.membershipContent.pins.find((x) => x.event.hashStr === eventId); check(isDefined(pin), 'pin not found'); check(isDefined(pin.event.remoteEvent), 'remoteEvent not found'); const result = await this.makeEventAndAddToStream(streamId, make_MemberPayload_Unpin(pin.event.remoteEvent.hash), { method: 'unpin', }); return result; } isUsernameAvailable(streamId, username) { const stream = this.streams.get(streamId); check(isDefined(stream), 'stream not found'); return (stream.view.getMemberMetadata().usernames.cleartextUsernameAvailable(username) ?? false); } async waitForStream(inStreamId, opts) { this.logCall('waitForStream', inStreamId); const timeoutMs = opts?.timeoutMs ?? getMaxTimeoutMs(this.rpcClient.opts); const streamId = streamIdAsString(inStreamId); let stream = this.stream(streamId); if (stream !== undefined && stream.view.isInitialized) { this.logCall('waitForStream: stream already initialized', streamId); return stream; } const logId = opts?.logId ? opts.logId + ' ' : ''; const timeoutError = new Error(`waitForStream: timeout waiting for ${logId}${streamId} creating streams: ${Array.from(this.creatingStreamIds).join(',')} rpcUrl: ${this.rpcClient.url}`); await new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.off('streamInitialized', handler); reject(timeoutError); }, timeoutMs); const handler = (newStreamId) => { if (newStreamId === streamId) { this.logCall('waitForStream: got streamInitialized', newStreamId); this.off('streamInitialized', handler); clearTimeout(timeout); resolve(); } else { this.logCall('waitForStream: still waiting for ', streamId, ' got ', newStreamId); } }; this.on('streamInitialized', handler); }); stream = this.stream(streamId); if (!stream) { throw new Error(`Stream ${streamIdAsString(streamId)} not found after waiting`); } return stream; } async getStream(streamId) { const existingRequest = this.getStreamRequests.get(streamId); if (existingRequest) { this.logCall(`had existing get request for ${streamId}, returning promise`); return await existingRequest; } const request = this._getStream(streamId); this.getStreamRequests.set(streamId, request); let streamView; try { streamView = await request; } finally { this.getStreamRequests.delete(streamId); } return streamView; } async _getStream(streamId) { try { this.logCall('getStream', streamId); const response = await this.rpcClient.getStream({ streamId: streamIdAsBytes(streamId), }); const unpackedResponse = await unpackStream(response.stream, this.opts?.unpackEnvelopeOpts); return this.streamViewFromUnpackedResponse(streamId, unpackedResponse); } catch (err) { this.logCall('getStream', streamId, 'ERROR', err); throw err; } } streamViewFromUnpackedResponse(streamId, unpackedResponse) { const streamView = new StreamStateView(this.userId, streamIdAsString(streamId)); streamView.initialize(unpackedResponse.streamAndCookie.nextSyncCookie, unpackedResponse.streamAndCookie.events, unpackedResponse.snapshot, unpackedResponse.streamAndCookie.miniblocks, [], unpackedResponse.prevSnapshotMiniblockNum, undefined, [], undefined); return streamView; } async getStreamEx(streamId) { const existingRequest = this.getStreamExRequests.get(streamId); if (existingRequest) { this.logCall(`had existing get request for ${streamId}, returning promise`); return await existingRequest; } const request = this._getStreamEx(streamId); this.getStreamExRequests.set(streamId, request); let streamView; try { streamView = await request; } finally { this.getStreamExRequests.delete(streamId); } return streamView; } async _getStreamEx(streamId) { try { this.logCall('getStreamEx', streamId); const response = this.rpcClient.getStreamEx({ streamId: streamIdAsBytes(streamId), }); const miniblocks = []; let seenEndOfStream = false; for await (const chunk of response) { switch (chunk.data.case) { case 'miniblock': if (seenEndOfStream) { throw new Error(`GetStreamEx: received miniblock after minipool contents for stream ${streamIdAsString(streamId)}.`); } miniblocks.push(chunk.data.value); break; case 'minipool': // TODO: add minipool contents to the unpacked response break; case undefined: seenEndOfStream = true; break; } } if (!seenEndOfStream) { throw new Error(`Failed receive all getStreamEx streaming responses for stream ${streamIdAsString(streamId)}.`); } const unpackedResponse = await unpackStreamEx(miniblocks, this.opts?.unpackEnvelopeOpts); return this.streamViewFromUnpackedResponse(streamId, unpackedResponse); } catch (err) { this.logCall('getStreamEx', streamId, 'ERROR', err); throw err; } } async initStream(streamId, allowGetStream = true, persistedData) { const streamIdStr = streamIdAsString(streamId); const existingRequest = this.initStreamRequests.get(streamIdStr); if (existingRequest) { this.logCall('initStream: had existing request for', streamIdStr, 'returning promise'); return existingRequest; } const request = this._initStream(streamIdStr, allowGetStream, persistedData); this.initStreamRequests.set(streamIdStr, request); let stream; try { stream = await request; } finally { this.initStreamRequests.delete(streamIdStr); } return stream; } async _initStream(streamId, allowGetStream = true, persistedData) { try { this.logCall('initStream', streamId); const stream = this.stream(streamId); if (stream) { if (stream.view.isInitialized) { this.logCall('initStream', streamId, 'already initialized'); return stream; } else { return this.waitForStream(streamId); } } else { this.logCall('initStream creating stream', streamId); const stream = this.createSyncedStream(streamId); // Try initializing from persistence const success = await stream.initializeFromPersistence(persistedData); if (success) { if (stream.view.syncCookie) { this.streams.addStreamToSync(streamId, stream.view.syncCookie); } return stream; } // if we're only allowing initializing from persistence, we've failed. if (!allowGetStream) { this.logCall('initStream deleting stream', streamId); // We need to remove the stream from syncedStreams, since we added it above this.streams.delete(streamId); throw new Error(`Failed to initialize stream from persistence ${streamIdAsString(streamId)}`); } try { const response = await this.rpcClient.getStream({ streamId: streamIdAsBytes(streamId), }); const unpacked = await unpackStream(response.stream, this.opts?.unpackEnvelopeOpts); this.logCall('initStream calling initializingFromResponse', streamId); await stream.initializeFromResponse(unpacked); if (stream.view.syncCookie) { this.streams.addStreamToSync(streamId, stream.view.syncCookie); } } catch (err) { this.logError('Failed to initialize stream', streamId, err); this.streams.delete(streamId); throw err; } return stream; } } catch (err) { this.logCall('initStream', streamId, 'ERROR', err); throw err; } } onJoinedStream = async (streamId) => { this.logEvent('onJoinedStream', streamId); if (!this.creatingStreamIds.has(streamId)) { await this.initStream(streamId); } }; onInvitedToStream = async (streamId) => { this.logEvent('onInvitedToStream', streamId); if (isDMChannelStreamId(streamId) || isGDMChannelStreamId(streamId)) { await this.initStream(streamId); } }; onLeftStream = async (streamId) => { this.logEvent('onLeftStream', streamId); return await this.streams.removeStreamFromSync(streamId); }; onStreamInitialized = (streamId) => { const scrollbackUntilContentFound = async () => { const stream = this.streams.get(streamId); if (!stream) { return; } while (stream.view.getContent().needsScrollback()) { const scrollback = await this.scrollback(streamId); if (scrollback.terminus) { break; } } }; void scrollbackUntilContentFound(); }; startSync() { check(this.syncedStreamsExtensions !== undefined, 'syncedStreamsExtensions must be set'); this.syncedStreamsExtensions.setStartSyncRequested(true); } async stopSync() { this.syncedStreamsExtensions?.setStartSyncRequested(false); await this.streams.stopSync(); } emit(event, ...args) { this.logEmitFromClient(event, ...args); return super.emit(event, ...args); } async sendMessage(streamId, body, mentions, attachments = []) { return this.sendChannelMessage_Text(streamId, { content: { body, mentions: mentions ?? [], attachments: attachments, }, }); } async sendChannelMessage(streamId, inPayload, opts) { const stream = this.stream(streamId); check(stream !== undefined, 'stream not found'); const payload = create(ChannelMessageSchema, inPayload); const localId = stream.appendLocalEvent(payload, 'sending'); opts?.onLocalEventAppended?.(localId); if (opts?.beforeSendEventHook) { await opts?.beforeSendEventHook; } return this.makeAndSendChannelMessageEvent(streamId, payload, localId, { disableTags: opts?.disableTags, }); } async makeAndSendChannelMessageEvent(streamId, payload, localId, opts) { const stream = this.stream(streamId); check(isDefined(stream), 'stream not found'); if (isChannelStreamId(streamId)) { // All channel messages sent via client API make their way to this method. // The client checks for it's own entitlement to send messages to a channel // before sending. check(isDefined(stream?.view.channelContent.spaceId), 'synced channel stream not initialized'); // We check entitlements on the client side for writes to channels. A top-level // message post is only permitted if the user has write permissions. If the message // is a reaction or redaction, the user may also have react permissions. This is // to allow react-only users to react to posts and edit their reactions. We're not // concerned with being overly permissive with redactions, as at this time, a user // is always allowed to redact their own messages. const expectedPermissions = payload.payload.case === 'reaction' || payload.payload.case === 'redaction' ? [Permission.React, Permission.Write] : [Permission.Write]; let isEntitled = false; for (const permission of expectedPermissions)