@pusher/chatkit
Version:
Pusher Chatkit client library for browsers and react native
592 lines (553 loc) • 17.4 kB
JavaScript
import { sendRawRequest } from 'pusher-platform'
import {
chain,
compose,
contains,
has,
indexBy,
map,
max,
pipe,
prop,
sort,
uniq,
values
} from 'ramda'
import {
checkOneOf,
typeCheck,
typeCheckArr,
typeCheckObj,
urlEncode
} from './utils'
import {
parseBasicMessage,
parseBasicRoom,
parseFetchedAttachment
} from './parsers'
import { Store } from './store'
import { UserStore } from './user-store'
import { RoomStore } from './room-store'
import { CursorStore } from './cursor-store'
import { TypingIndicators } from './typing-indicators'
import { UserSubscription } from './user-subscription'
import { PresenceSubscription } from './presence-subscription'
import { UserPresenceSubscription } from './user-presence-subscription'
import { CursorSubscription } from './cursor-subscription'
import { MessageSubscription } from './message-subscription'
import { MembershipSubscription } from './membership-subscription'
import { RoomSubscription } from './room-subscription'
import { Message } from './message'
import { SET_CURSOR_WAIT } from './constants'
export class CurrentUser {
constructor ({
apiInstance,
cursorsInstance,
filesInstance,
hooks,
id,
presenceInstance
}) {
this.hooks = {
global: hooks,
internal: {
onAddedToRoom: roomId => this.addMembershipSubscription(roomId),
onRemovedFromRoom: roomId => this.removeMembershipSubscription(roomId)
},
rooms: {}
}
this.id = id
this.encodedId = encodeURIComponent(this.id)
this.apiInstance = apiInstance
this.filesInstance = filesInstance
this.cursorsInstance = cursorsInstance
this.presenceInstance = presenceInstance
this.logger = apiInstance.logger
this.presenceStore = new Store()
this.userStore = new UserStore({
instance: this.apiInstance,
presenceStore: this.presenceStore,
logger: this.logger
})
this.roomStore = new RoomStore({
instance: this.apiInstance,
userStore: this.userStore,
logger: this.logger
})
this.cursorStore = new CursorStore({
instance: this.cursorsInstance,
userStore: this.userStore,
roomStore: this.roomStore,
logger: this.logger
})
this.typingIndicators = new TypingIndicators({
hooks: this.hooks,
instance: this.apiInstance,
logger: this.logger
})
this.userStore.onSetHooks.push(this.subscribeToUserPresence)
this.presenceStore.initialize({})
this.roomSubscriptions = {}
this.membershipSubscriptions = {}
this.readCursorBuffer = {} // roomId -> { position, [{ resolve, reject }] }
this.userPresenceSubscriptions = {}
}
/* public */
get rooms () {
return values(this.roomStore.snapshot())
}
get users () {
return values(this.userStore.snapshot())
}
setReadCursor = ({ roomId, position } = {}) => {
typeCheck('roomId', 'number', roomId)
typeCheck('position', 'number', position)
return new Promise((resolve, reject) => {
if (this.readCursorBuffer[roomId] !== undefined) {
this.readCursorBuffer[roomId].position =
max(this.readCursorBuffer[roomId].position, position)
this.readCursorBuffer[roomId].callbacks.push({ resolve, reject })
} else {
this.readCursorBuffer[roomId] = {
position,
callbacks: [{ resolve, reject }]
}
setTimeout(() => {
this.setReadCursorRequest({
roomId,
...this.readCursorBuffer[roomId]
})
delete this.readCursorBuffer[roomId]
}, SET_CURSOR_WAIT)
}
})
}
readCursor = ({ roomId, userId = this.id } = {}) => {
typeCheck('roomId', 'number', roomId)
typeCheck('userId', 'string', userId)
if (userId !== this.id && !has(roomId, this.roomSubscriptions)) {
const err = new TypeError(
`Must be subscribed to room ${roomId} to access member's read cursors`
)
this.logger.error(err)
throw err
}
return this.cursorStore.getSync(userId, roomId)
}
isTypingIn = ({ roomId } = {}) => {
typeCheck('roomId', 'number', roomId)
return this.typingIndicators.sendThrottledRequest(roomId)
}
createRoom = ({ name, addUserIds, ...rest } = {}) => {
name && typeCheck('name', 'string', name)
addUserIds && typeCheckArr('addUserIds', 'string', addUserIds)
return this.apiInstance.request({
method: 'POST',
path: '/rooms',
json: {
created_by_id: this.id,
name,
private: !!rest.private, // private is a reserved word in strict mode!
user_ids: addUserIds
}
})
.then(res => {
const basicRoom = parseBasicRoom(JSON.parse(res))
return this.roomStore.set(basicRoom.id, basicRoom)
})
.catch(err => {
this.logger.warn('error creating room:', err)
throw err
})
}
getJoinableRooms = () => {
return this.apiInstance
.request({
method: 'GET',
path: `/users/${this.encodedId}/rooms?joinable=true`
})
.then(pipe(JSON.parse, map(parseBasicRoom)))
.catch(err => {
this.logger.warn('error getting joinable rooms:', err)
throw err
})
}
joinRoom = ({ roomId } = {}) => {
typeCheck('roomId', 'number', roomId)
if (this.isMemberOf(roomId)) {
return this.roomStore.get(roomId)
}
return this.apiInstance
.request({
method: 'POST',
path: `/users/${this.encodedId}/rooms/${roomId}/join`
})
.then(res => {
const basicRoom = parseBasicRoom(JSON.parse(res))
return this.roomStore.set(basicRoom.id, basicRoom)
})
.then(room => this.addMembershipSubscription(roomId).then(() => room))
.catch(err => {
this.logger.warn(`error joining room ${roomId}:`, err)
throw err
})
}
leaveRoom = ({ roomId } = {}) => {
typeCheck('roomId', 'number', roomId)
return this.apiInstance
.request({
method: 'POST',
path: `/users/${this.encodedId}/rooms/${roomId}/leave`
})
.then(() => this.removeMembershipSubscription(roomId))
.then(() => this.roomStore.pop(roomId))
.catch(err => {
this.logger.warn(`error leaving room ${roomId}:`, err)
throw err
})
}
addUserToRoom = ({ userId, roomId } = {}) => {
typeCheck('userId', 'string', userId)
typeCheck('roomId', 'number', roomId)
return this.apiInstance
.request({
method: 'PUT',
path: `/rooms/${roomId}/users/add`,
json: {
user_ids: [userId]
}
})
.then(() => this.roomStore.addUserToRoom(roomId, userId))
.catch(err => {
this.logger.warn(`error adding user ${userId} to room ${roomId}:`, err)
throw err
})
}
removeUserFromRoom = ({ userId, roomId } = {}) => {
typeCheck('userId', 'string', userId)
typeCheck('roomId', 'number', roomId)
return this.apiInstance
.request({
method: 'PUT',
path: `/rooms/${roomId}/users/remove`,
json: {
user_ids: [userId]
}
})
.then(() => this.roomStore.removeUserFromRoom(roomId, userId))
.catch(err => {
this.logger.warn(
`error removing user ${userId} from room ${roomId}:`,
err
)
throw err
})
}
sendMessage = ({ text, roomId, attachment } = {}) => {
typeCheck('text', 'string', text)
typeCheck('roomId', 'number', roomId)
return new Promise((resolve, reject) => {
if (attachment !== undefined && isDataAttachment(attachment)) {
resolve(this.uploadDataAttachment(roomId, attachment))
} else if (attachment !== undefined && isLinkAttachment(attachment)) {
resolve({ resource_link: attachment.link, type: attachment.type })
} else if (attachment !== undefined) {
reject(new TypeError('attachment was malformed'))
} else {
resolve()
}
})
.then(attachment => this.apiInstance.request({
method: 'POST',
path: `/rooms/${roomId}/messages`,
json: { text, attachment }
}))
.then(pipe(JSON.parse, prop('message_id')))
.catch(err => {
this.logger.warn(`error sending message to room ${roomId}:`, err)
throw err
})
}
fetchMessages = ({ roomId, initialId, limit, direction } = {}) => {
typeCheck('roomId', 'number', roomId)
initialId && typeCheck('initialId', 'number', initialId)
limit && typeCheck('limit', 'number', limit)
direction && checkOneOf('direction', ['older', 'newer'], direction)
return this.apiInstance
.request({
method: 'GET',
path: `/rooms/${roomId}/messages?${urlEncode({
initial_id: initialId,
limit,
direction
})}`
})
.then(res => {
const messages = map(
compose(this.decorateMessage, parseBasicMessage),
JSON.parse(res)
)
return this.userStore.fetchMissingUsers(
uniq(map(prop('senderId'), messages))
).then(() => sort((x, y) => x.id - y.id, messages))
})
.catch(err => {
this.logger.warn(`error fetching messages from room ${roomId}:`, err)
throw err
})
}
subscribeToRoom = ({ roomId, hooks = {}, messageLimit } = {}) => {
typeCheck('roomId', 'number', roomId)
typeCheckObj('hooks', 'function', hooks)
messageLimit && typeCheck('messageLimit', 'number', messageLimit)
if (this.roomSubscriptions[roomId]) {
this.roomSubscriptions[roomId].cancel()
}
this.hooks.rooms[roomId] = hooks
this.roomSubscriptions[roomId] = new RoomSubscription({
messageSub: new MessageSubscription({
roomId,
hooks: this.hooks,
messageLimit,
userId: this.id,
instance: this.apiInstance,
userStore: this.userStore,
roomStore: this.roomStore,
typingIndicators: this.typingIndicators,
logger: this.logger
}),
cursorSub: new CursorSubscription({
onNewCursorHook: cursor => {
if (
this.hooks.rooms[roomId] &&
this.hooks.rooms[roomId].onNewReadCursor && cursor.type === 0 &&
cursor.userId !== this.id
) {
this.hooks.rooms[roomId].onNewReadCursor(cursor)
}
},
path: `/cursors/0/rooms/${roomId}`,
cursorStore: this.cursorStore,
instance: this.cursorsInstance,
logger: this.logger
})
})
return this.joinRoom({ roomId })
.then(room => Promise.all([
this.roomSubscriptions[roomId].messageSub.connect(),
this.roomSubscriptions[roomId].cursorSub.connect()
]).then(() => room))
.catch(err => {
this.logger.warn(`error subscribing to room ${roomId}:`, err)
throw err
})
}
fetchAttachment = ({ url } = {}) => {
return this.filesInstance.tokenProvider.fetchToken()
.then(token => sendRawRequest({
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
url
}))
.then(pipe(JSON.parse, parseFetchedAttachment))
.catch(err => {
this.logger.warn(`error fetching attachment:`, err)
throw err
})
}
updateRoom = ({ roomId, name, ...rest } = {}) => {
typeCheck('roomId', 'number', roomId)
name && typeCheck('name', 'string', name)
rest.private && typeCheck('private', 'boolean', rest.private)
return this.apiInstance.request({
method: 'PUT',
path: `/rooms/${roomId}`,
json: {
name,
private: rest.private // private is a reserved word in strict mode!
}
})
.then(() => {})
.catch(err => {
this.logger.warn('error updating room:', err)
throw err
})
}
deleteRoom = ({ roomId } = {}) => {
typeCheck('roomId', 'number', roomId)
return this.apiInstance.request({
method: 'DELETE',
path: `/rooms/${roomId}`
})
.then(() => {})
.catch(err => {
this.logger.warn('error deleting room:', err)
throw err
})
}
/* internal */
setReadCursorRequest = ({ roomId, position, callbacks }) => {
return this.cursorsInstance
.request({
method: 'PUT',
path: `/cursors/0/rooms/${roomId}/users/${this.encodedId}`,
json: { position }
})
.then(() => map(x => x.resolve(), callbacks))
.catch(err => {
this.logger.warn('error setting cursor:', err)
map(x => x.reject(err), callbacks)
})
}
uploadDataAttachment = (roomId, { file, name }) => {
// TODO some validation on allowed file names?
// TODO polyfill FormData?
const body = new FormData() // eslint-disable-line no-undef
body.append('file', file, name)
return this.filesInstance.request({
method: 'POST',
path: `/rooms/${roomId}/users/${this.encodedId}/files/${name}`,
body
})
.then(JSON.parse)
}
isMemberOf = roomId => contains(roomId, map(prop('id'), this.rooms))
decorateMessage = basicMessage => new Message(
basicMessage,
this.userStore,
this.roomStore
)
establishUserSubscription = () => {
this.userSubscription = new UserSubscription({
hooks: this.hooks,
userId: this.id,
instance: this.apiInstance,
userStore: this.userStore,
roomStore: this.roomStore,
logger: this.logger
})
return this.userSubscription.connect()
.then(({ user, basicRooms }) => {
this.avatarURL = user.avatarURL
this.createdAt = user.createdAt
this.customData = user.customData
this.name = user.name
this.updatedAt = user.updatedAt
this.roomStore.initialize(indexBy(prop('id'), basicRooms))
})
.then(this.establishMembershipSubscriptions)
.catch(err => {
this.logger.error('error establishing user subscription:', err)
throw err
})
}
establishMembershipSubscriptions = () => {
return Promise.all(
map(({ id }) => this.addMembershipSubscription(id), this.rooms)
)
.then(this.initializeUserStore)
.catch(err => {
this.logger.error('error establishing membership subscriptions:', err)
throw err
})
}
establishCursorSubscription = () => {
this.cursorSubscription = new CursorSubscription({
onNewCursorHook: cursor => {
if (
this.hooks.global.onNewReadCursor && cursor.type === 0 &&
this.isMemberOf(cursor.roomId)
) {
this.hooks.global.onNewReadCursor(cursor)
}
},
path: `/cursors/0/users/${this.encodedId}`,
cursorStore: this.cursorStore,
instance: this.cursorsInstance,
logger: this.logger
})
return this.cursorSubscription.connect()
.then(() => this.cursorStore.initialize({}))
.catch(err => {
this.logger.warn('error establishing cursor subscription:', err)
throw err
})
}
registerAsOnline = () => {
this.presenceSubscription = new PresenceSubscription({
userId: this.id,
instance: this.presenceInstance,
logger: this.logger
})
return this.presenceSubscription.registerAsOnline()
.catch(err => {
this.logger.warn('error registering as online:', err)
throw err
})
}
subscribeToUserPresence = (userId) => {
if (this.userPresenceSubscriptions[userId]) {
return Promise.resolve()
}
if (userId === this.id) {
return Promise.resolve()
}
const userPresenceSub = new UserPresenceSubscription({
hooks: this.hooks,
userId: userId,
instance: this.presenceInstance,
userStore: this.userStore,
roomStore: this.roomStore,
presenceStore: this.presenceStore,
logger: this.logger
})
this.userPresenceSubscriptions[userId] = userPresenceSub
return userPresenceSub.connect()
}
initializeUserStore = () => {
return this.userStore.fetchMissingUsers(
uniq(chain(prop('userIds'), this.rooms))
)
.catch(err => {
this.logger.warn('error fetching initial user information:', err)
})
.then(() => this.userStore.initialize({}))
}
addMembershipSubscription = roomId => {
if (this.membershipSubscriptions[roomId]) {
return Promise.resolve()
}
this.membershipSubscriptions[roomId] = new MembershipSubscription({
roomId,
hooks: this.hooks,
instance: this.apiInstance,
userStore: this.userStore,
roomStore: this.roomStore,
logger: this.logger
})
return this.membershipSubscriptions[roomId].connect()
}
removeMembershipSubscription = roomId => {
if (this.membershipSubscriptions[roomId]) {
this.membershipSubscriptions[roomId].cancel()
delete this.membershipSubscriptions[roomId]
}
return Promise.resolve()
}
}
const isDataAttachment = ({ file, name }) => {
if (file === undefined || name === undefined) {
return false
}
typeCheck('attachment.file', 'object', file)
typeCheck('attachment.name', 'string', name)
return true
}
const isLinkAttachment = ({ link, type }) => {
if (link === undefined || type === undefined) {
return false
}
typeCheck('attachment.link', 'string', link)
typeCheck('attachment.type', 'string', type)
return true
}