@river-build/sdk
Version:
For more details, visit the following resources:
985 lines • 86.9 kB
JavaScript
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)