diff --git a/packages/screens/Settings/SettingsScreen.tsx b/packages/screens/Settings/SettingsScreen.tsx index fab104d815..2b892d1a29 100644 --- a/packages/screens/Settings/SettingsScreen.tsx +++ b/packages/screens/Settings/SettingsScreen.tsx @@ -32,7 +32,7 @@ import { ScreenFC, useAppNavigation } from "../../utils/navigation"; import { neutralA3, primaryColor } from "../../utils/style/colors"; import { fontSemibold14 } from "../../utils/style/fonts"; import { modalMarginPadding } from "../../utils/style/modals"; -import { createWeshClient } from "../../utils/weshnet"; +import { createWeshClient } from "../../weshnet"; const NFTAPIKeyInput: React.FC = () => { const userIPFSKey = useSelector(selectNFTStorageAPI); diff --git a/packages/store/slices/message.ts b/packages/store/slices/message.ts new file mode 100644 index 0000000000..c014729279 --- /dev/null +++ b/packages/store/slices/message.ts @@ -0,0 +1,214 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { uniqBy } from "lodash"; +import moment from "moment"; + +import { + ContactRequest, + Conversation, + MessageList, + Message, + CONVERSATION_TYPES, + PeerItem, +} from "./../../utils/types/message"; +import { weshConfig } from "../../weshnet/config"; +import { stringFromBytes } from "../../weshnet/utils"; +import { RootState } from "../store"; + +export interface MessageState { + isWeshConnected: boolean; + peerList: PeerItem[]; + contactInfo: { + name: string; + avatar: string; + publicRendezvousSeed: string; + shareLink: string; + }; + contactRequestList: ContactRequest[]; + messageList: MessageList; + conversationList: { + [key: string]: Conversation; + }; + lastIds: { + [key: string]: string; + }; +} + +const initialState: MessageState = { + isWeshConnected: false, + contactInfo: { + name: "Anon", + avatar: "", + publicRendezvousSeed: "", + shareLink: "", + }, + messageList: {}, + contactRequestList: [], + conversationList: {}, + lastIds: {}, + peerList: [], +}; + +export const selectIsWeshConnected = (state: RootState) => + state.message.isWeshConnected; + +export const selectContactInfo = (state: RootState) => + state.message.contactInfo; + +export const selectMessageList = (groupPk: string) => (state: RootState) => + state.message.messageList[groupPk] || []; + +export const selectPeerList = (state: RootState) => state.message.peerList; + +export const selectPeerListById = (id: string) => (state: RootState) => + state.message.peerList.find((item) => item.id === id); + +export const selectLastIdByKey = (key: string) => (state: RootState) => + state.message.lastIds[key]; + +export const selectMessageListByGroupPk = + (groupPk: string) => + (state: RootState): Message[] => + Object.values(state.message.messageList[groupPk] || {}) + .filter((item) => item.id) + .sort( + (a: Message, b: Message) => + moment(b.timestamp).valueOf() - moment(a.timestamp).valueOf(), + ); + +export const selectLastMessageByGroupPk = + (groupPk: string) => (state: RootState) => + selectMessageListByGroupPk(groupPk)(state)[0]; + +export const selectLastContactMessageByGroupPk = + (groupPk: string) => (state: RootState) => + selectMessageListByGroupPk(groupPk)(state).filter( + (item) => + item.senderId !== stringFromBytes(weshConfig?.config?.accountPk), + )[0]; + +export const selectContactRequestList = (state: RootState) => + state.message.contactRequestList; + +export const selectConversationList = + (conversationType: CONVERSATION_TYPES = CONVERSATION_TYPES.ACTIVE) => + (state: RootState): Conversation[] => { + switch (conversationType) { + case CONVERSATION_TYPES.ALL: { + return Object.values(state.message.conversationList); + } + case CONVERSATION_TYPES.ARCHIVED: { + return Object.values(state.message.conversationList).filter( + (conv) => conv.status === "archived", + ); + } + case CONVERSATION_TYPES.ACTIVE: + default: + return Object.values(state.message.conversationList).filter( + (conv) => conv.status === "active", + ); + } + }; + +export const selectConversationById = + (id: string) => + (state: RootState): Conversation => + state.message.conversationList[id]; + +const messageSlice = createSlice({ + name: "message", + initialState, + reducers: { + setIsWeshConnected: (state, action: PayloadAction) => { + state.isWeshConnected = action.payload; + }, + setMessageList: ( + state, + action: PayloadAction<{ groupPk: string; data: Message }>, + ) => { + if (!state.messageList[action.payload.groupPk]) { + state.messageList[action.payload.groupPk] = {}; + } + state.messageList[action.payload.groupPk][action.payload.data.id] = + action.payload.data; + }, + setPeerList: (state, action: PayloadAction) => { + state.peerList = action.payload; + }, + updateMessageReaction: ( + state, + action: PayloadAction<{ groupPk: string; data: Message }>, + ) => { + if (action.payload.data.parentId) { + try { + state.messageList[action.payload.groupPk][ + action.payload.data?.parentId + ].reactions = uniqBy( + [ + ...(state.messageList[action.payload.groupPk][ + action.payload.data.parentId + ].reactions || []), + action.payload.data, + ], + "id", + ); + } catch (err) { + console.error("update reaction failed", err); + } + } + }, + setContactRequestList: ( + state, + action: PayloadAction, + ) => { + if (Array.isArray(action.payload)) { + state.contactRequestList = action.payload; + } else { + state.contactRequestList = [ + action.payload, + ...state.contactRequestList, + ]; + } + }, + setConversationList: (state, action: PayloadAction) => { + state.conversationList[action.payload.id] = action.payload; + }, + updateConversationById: ( + state, + action: PayloadAction>, + ) => { + if (action.payload.id) { + state.conversationList[action.payload.id] = { + ...(state.conversationList[action.payload.id] || {}), + ...action.payload, + }; + } + }, + setLastId: ( + state, + action: PayloadAction<{ key: string; value: string }>, + ) => { + state.lastIds[action.payload.key] = action.payload.value; + }, + + setContactInfo: ( + state, + action: PayloadAction>, + ) => { + state.contactInfo = { ...state.contactInfo, ...action.payload }; + }, + }, +}); + +export const { + setMessageList, + setContactRequestList, + setConversationList, + updateMessageReaction, + setLastId, + setContactInfo, + updateConversationById, + setPeerList, + setIsWeshConnected, +} = messageSlice.actions; + +export const messageReducer = messageSlice.reducer; diff --git a/packages/store/store.ts b/packages/store/store.ts index df42238996..007230e7b1 100644 --- a/packages/store/store.ts +++ b/packages/store/store.ts @@ -12,6 +12,7 @@ import { marketplaceFilters, marketplaceFilterUI, } from "./slices/marketplaceFilters"; +import { messageReducer } from "./slices/message"; import { searchReducer } from "./slices/search"; import { multisigTokensAdapter, @@ -79,6 +80,7 @@ const rootReducer = combineReducers({ marketplaceFilters, marketplaceFilterUI, search: searchReducer, + message: messageReducer, }); const persistedReducer = persistReducer(persistConfig, rootReducer); diff --git a/packages/utils/isElectron.ts b/packages/utils/isElectron.ts new file mode 100644 index 0000000000..1ea5ee0048 --- /dev/null +++ b/packages/utils/isElectron.ts @@ -0,0 +1,11 @@ +export const isElectron = () => { + try { + const userAgent = navigator.userAgent.toLowerCase(); + if (userAgent.indexOf(" electron/") > -1) { + return true; + } + } catch (err) { + console.error("isElectron err", err); + } + return false; +}; diff --git a/packages/utils/types/message.ts b/packages/utils/types/message.ts new file mode 100644 index 0000000000..83f3231f5c --- /dev/null +++ b/packages/utils/types/message.ts @@ -0,0 +1,98 @@ +import { RemoteFileData } from "./files"; + +export type MessageType = + | "message" + | "accept-contact" + | "reject-contact" + | "group-invite" + | "group-join" + | "group-leave" + | "group-create" + | "reaction" + | "contact-request" + | "read"; + +export type MessageFriendsTabItem = "friends" | "request" | "addFriend"; + +export type ConversationType = "contact" | "group"; + +export interface MessageFileData extends RemoteFileData { + type: string; +} + +interface MessagePayload { + files: MessageFileData[]; + message: string; + metadata?: { + groupName?: string; + group?: any; + contact?: Contact; + lastReadId?: string; + lastReadBy?: string; + }; +} + +export interface Message { + id: string; + senderId: string; + groupId: string; + type: MessageType; + payload?: MessagePayload; + timestamp: string; + parentId?: string; + reactions?: any[]; + isRead?: boolean; +} + +export interface Contact { + id: string; + tokenId?: string; + name: string; + avatar: string; + rdvSeed: string; + peerId?: string; + hasLeft?: boolean; +} + +export interface Conversation { + id: string; + type: ConversationType; + members: Contact[]; + name: string; + status: "active" | "archived" | "deleted" | "blocked"; + lastReadIdByMe?: string; + lastReadIdByContact?: string; +} + +export interface MessageList { + [key: string]: { [key: string]: Message }; +} + +export interface ConversationList { + [key: string]: Conversation[]; +} + +export interface ContactRequest { + id: string; + contactId: string; + rdvSeed: string; + avatar: string; + name: string; + peerId?: string; +} + +export interface ReplyTo { + id: string; + message: string; +} + +export enum CONVERSATION_TYPES { + ACTIVE = "Active Conversations", + ALL = "All Conversations", + ARCHIVED = "Archived Conversations", +} + +export interface PeerItem { + id: string; + isActive: boolean; +} diff --git a/packages/utils/weshnet.ts b/packages/utils/weshnet.ts deleted file mode 100644 index 651bdfc86d..0000000000 --- a/packages/utils/weshnet.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - GrpcWebImpl, - ProtocolServiceClientImpl, -} from "../api/weshnet/protocoltypes"; - -export const createWeshClient = (url: string) => { - const rpc = new GrpcWebImpl(url, { debug: false }); - const client = new ProtocolServiceClientImpl(rpc); - return client; -}; diff --git a/packages/weshnet/client.ts b/packages/weshnet/client.ts new file mode 100644 index 0000000000..5cfc8a309e --- /dev/null +++ b/packages/weshnet/client.ts @@ -0,0 +1,95 @@ +import { Platform } from "react-native"; + +import { + GrpcWebImpl, + ProtocolServiceClientImpl, +} from "./../api/weshnet/protocoltypes"; +import { weshConfig } from "./config"; +import { fixWeshPortURLParams } from "./devWeshPortFix"; +import { bootWeshnet } from "./services"; + +export const createWeshClient = (url: string) => { + const rpc = new GrpcWebImpl(url, { debug: false }); + const client = new ProtocolServiceClientImpl(rpc); + return client; +}; + +const getAddress = (port: number) => { + switch (Platform.OS) { + case "android": + return `10.0.2.2:${port}`; + case "ios": + return `http://127.0.0.1:${port}`; + default: + return `http://localhost:${port}`; + } +}; + +class WeshClient { + private _client: ProtocolServiceClientImpl = new ProtocolServiceClientImpl( + new GrpcWebImpl("", {}), + ); + private _port: number = 0; + private _intervalId: null | ReturnType = null; + + get client() { + return this._client; + } + + get port() { + return this._port; + } + + set port(port) { + this._port = port; + } + async createClient(port: number) { + try { + if (port === 0) { + return false; + } + + const address = getAddress(port); + const client = createWeshClient(address); + const config = await client.ServiceGetConfiguration({}); + weshConfig.config = config; + this._client = client; + await bootWeshnet(); + } catch (err) { + console.error("create Client err", err); + } + return true; + } + + checkPortAndStart() { + if (this._port) { + if (this._intervalId) { + clearInterval(this._intervalId); + this._intervalId = null; + } + this.createClient(this._port); + } + } + + watchPort() { + this._intervalId = setInterval(() => { + this.checkPortAndStart(); + }, 5000); + } +} + +const weshClient = new WeshClient(); + +if (Platform.OS === "web") { + const queryString = window.location.search; + const urlParams = new URLSearchParams(queryString); + const port = urlParams.get("weshPort"); + + if (port) { + fixWeshPortURLParams(); + } + + weshClient.createClient(Number(port) || 4242); +} + +export { weshClient }; diff --git a/packages/weshnet/config.ts b/packages/weshnet/config.ts new file mode 100644 index 0000000000..5fa64be738 --- /dev/null +++ b/packages/weshnet/config.ts @@ -0,0 +1,33 @@ +import { ServiceGetConfiguration_Reply } from "../api/weshnet/protocoltypes"; + +class WeshConfig { + private _config: ServiceGetConfiguration_Reply | undefined; + private _shareLink: string = ""; + private _metadata = { + rdvSeed: new Uint8Array(), + tokenId: "", + }; + + get metadata() { + return this._metadata; + } + set metadata(data) { + this._metadata = data; + } + + get config(): ServiceGetConfiguration_Reply | undefined { + return this._config; + } + set config(config) { + this._config = config; + } + + get shareLink(): string { + return this._shareLink; + } + set shareLink(shareLink) { + this._shareLink = shareLink; + } +} + +export const weshConfig = new WeshConfig(); diff --git a/packages/weshnet/devWeshPortFix.ts b/packages/weshnet/devWeshPortFix.ts new file mode 100644 index 0000000000..03f5a07537 --- /dev/null +++ b/packages/weshnet/devWeshPortFix.ts @@ -0,0 +1,42 @@ +function bootListener() { + const oldPushState = history.pushState; + + history.pushState = function (...args) { + const ret = oldPushState.apply(this, args); + window.dispatchEvent(new Event("pushstate")); + window.dispatchEvent(new Event("locationchange")); + return ret; + }; + + const oldReplaceState = history.replaceState; + history.replaceState = function (...args) { + const ret = oldReplaceState.apply(this, args); + window.dispatchEvent(new Event("replacestate")); + window.dispatchEvent(new Event("locationchange")); + return ret; + }; + + window.addEventListener("popstate", () => { + window.dispatchEvent(new Event("locationchange")); + }); +} + +export const fixWeshPortURLParams = () => { + bootListener(); + + const queryString = window.location.search; + window.addEventListener("locationchange", (event) => { + const url = window.location.href; + let newURL = url; + if (url.includes("?")) { + if (!url.includes("weshPort")) { + newURL = `${url}&${queryString.replace("?", "")}`; + } + } else { + newURL = `${url}${queryString}`; + } + if (url !== newURL) { + window.history.replaceState({}, "", newURL); + } + }); +}; diff --git a/packages/weshnet/index.ts b/packages/weshnet/index.ts new file mode 100644 index 0000000000..cbe69e081b --- /dev/null +++ b/packages/weshnet/index.ts @@ -0,0 +1,5 @@ +import { weshClient, createWeshClient } from "./client"; +import { weshConfig } from "./config"; +import * as weshServices from "./services"; + +export { weshServices, weshClient, weshConfig, createWeshClient }; diff --git a/packages/weshnet/message/processEvent.ts b/packages/weshnet/message/processEvent.ts new file mode 100644 index 0000000000..b92fe54d74 --- /dev/null +++ b/packages/weshnet/message/processEvent.ts @@ -0,0 +1,130 @@ +import { GroupMessageEvent } from "../../api/weshnet/protocoltypes"; +import { + selectConversationById, + setMessageList, + updateConversationById, + updateMessageReaction, +} from "../../store/slices/message"; +import { store } from "../../store/store"; +import { ContactRequest, Conversation } from "../../utils/types/message"; +import { weshConfig } from "../config"; +import { getConversationName } from "../messageHelpers"; +import { decodeJSON, stringFromBytes } from "../utils"; + +export const processMessage = async ( + data: GroupMessageEvent, + groupPk: string, +) => { + try { + const conversation = selectConversationById(groupPk)(store.getState()); + const decodedMessage = decodeJSON(data.message); + + const message = { + id: stringFromBytes(data.eventContext?.id), + ...decodedMessage, + }; + const isSender = + message.senderId === stringFromBytes(weshConfig.config?.accountPk); + + switch (message.type) { + case "reaction": { + store.dispatch( + updateMessageReaction({ + groupPk, + data: message, + }), + ); + break; + } + + case "group-create": { + store.dispatch( + updateConversationById({ + id: groupPk, + name: message?.payload?.metadata?.groupName, + }), + ); + + break; + } + case "group-invite": { + const formattedMessage = isSender + ? `You invited ${getConversationName( + conversation, + )} to a group ${message?.payload?.metadata?.groupName}` + : `${getConversationName( + conversation, + )} invited you to a group ${message?.payload?.metadata?.groupName}`; + + if (!message.payload) { + message.payload = { files: [], message: "" }; + } + message.payload.message = formattedMessage; + + store.dispatch( + setMessageList({ + groupPk, + data: message, + }), + ); + + break; + } + case "group-join": { + const newMember: ContactRequest[] = []; + + if ( + message?.payload?.metadata?.contact?.id && + stringFromBytes(weshConfig.config?.accountPk) !== + message?.payload?.metadata?.contact?.id + ) { + newMember.push(message?.payload?.metadata?.contact); + } + + const conversation = selectConversationById(groupPk)(store.getState()); + store.dispatch( + updateConversationById({ + id: groupPk, + name: message?.payload?.metadata?.groupName, + members: [...(conversation.members || []), ...newMember], + }), + ); + store.dispatch( + setMessageList({ + groupPk, + data: message, + }), + ); + break; + } + case "read": { + const data: Partial = {}; + const lastReadId = message?.payload?.metadata?.lastReadId; + const lastReadBy = message?.payload?.metadata?.lastReadBy; + if (lastReadBy === stringFromBytes(weshConfig.config?.accountPk)) { + data.lastReadIdByMe = lastReadId; + } else { + data.lastReadIdByContact = lastReadId; + } + + store.dispatch( + updateConversationById({ + id: groupPk, + ...data, + }), + ); + break; + } + default: { + store.dispatch( + setMessageList({ + groupPk, + data: message, + }), + ); + } + } + } catch (err) { + console.error("process message err", err); + } +}; diff --git a/packages/weshnet/message/subscriber.ts b/packages/weshnet/message/subscriber.ts new file mode 100644 index 0000000000..cee75bd003 --- /dev/null +++ b/packages/weshnet/message/subscriber.ts @@ -0,0 +1,86 @@ +import { Platform } from "react-native"; + +import { processMessage } from "./processEvent"; +import { + GroupMessageList_Request, + GroupMessageEvent, +} from "../../api/weshnet/protocoltypes"; +import { selectLastIdByKey, setLastId } from "../../store/slices/message"; +import { store } from "../../store/store"; +import { weshClient } from "../client"; +import { bytesFromString, stringFromBytes } from "../utils"; + +export const subscribeMessages = async (groupPk: string) => { + try { + const lastId = selectLastIdByKey(groupPk)(store.getState()); + + const config: Partial = { + groupPk: bytesFromString(groupPk), + }; + + if (lastId) { + config.sinceId = bytesFromString(lastId); + } else { + config.untilNow = true; + config.reverseOrder = true; + } + + try { + await weshClient.client.ActivateGroup({ + groupPk: bytesFromString(groupPk), + }); + const messages = await weshClient.client.GroupMessageList(config); + let isLastIdSet = false; + + const observer = { + next: (data: GroupMessageEvent) => { + try { + const id = stringFromBytes(data.eventContext?.id); + + if (lastId === id) { + return; + } + if (!lastId && !isLastIdSet) { + store.dispatch( + setLastId({ + key: groupPk, + value: id, + }), + ); + isLastIdSet = true; + } + + if (lastId) { + store.dispatch( + setLastId({ + key: groupPk, + value: id, + }), + ); + } + + processMessage(data, groupPk); + } catch (err) { + console.error("subscribe message next err:", err); + } + }, + error: (e: any) => { + console.error("get message err", e); + }, + complete: async () => { + const lastId = selectLastIdByKey(groupPk)(store.getState()); + if (Platform.OS === "web" && lastId) { + subscribeMessages(groupPk); + } else { + setTimeout(() => subscribeMessages(groupPk), 3500); + } + }, + }; + return messages.subscribe(observer); + } catch (err) { + console.error("get messages err", err); + } + } catch (err) { + console.error("subscribe message", err); + } +}; diff --git a/packages/weshnet/messageHelpers.ts b/packages/weshnet/messageHelpers.ts new file mode 100644 index 0000000000..20a45df69f --- /dev/null +++ b/packages/weshnet/messageHelpers.ts @@ -0,0 +1,33 @@ +import { Conversation, Message } from "../utils/types/message"; + +export const getLineTextByMessageType = ({ message }: { message: Message }) => { + switch (message.type) { + case "contact-request": + return `Anon has sent a contact`; + } +}; + +export const getNewConversationText = (conversation: Conversation) => { + if (conversation.type === "contact") { + return `Your contact request with ${ + conversation?.members?.[0]?.name || "Anon" + } is still pending and has not yet been accepted.`; + } else { + return "Congratulations on creating this group! Currently, there are no members who have joined yet."; + } +}; + +export const getConversationName = (conversation: Conversation) => { + if (conversation.type === "contact") { + return conversation?.members?.[0]?.name || "Anon"; + } else { + return conversation?.name || "Group"; + } +}; + +export const getConversationAvatar = ( + conversation: Conversation, + index = 0, +) => { + return conversation?.members?.[index]?.avatar || ""; +}; diff --git a/packages/weshnet/metadata/processEvent.ts b/packages/weshnet/metadata/processEvent.ts new file mode 100644 index 0000000000..6524404ec3 --- /dev/null +++ b/packages/weshnet/metadata/processEvent.ts @@ -0,0 +1,195 @@ +import { + EventType, + GroupMetadataEvent, + AccountContactRequestIncomingReceived, + AccountContactRequestOutgoingEnqueued, + AccountContactRequestIncomingAccepted, + AccountGroupJoined, +} from "../../api/weshnet/protocoltypes"; +import { + selectContactRequestList, + setContactRequestList, + setConversationList, +} from "../../store/slices/message"; +import { store } from "../../store/store"; +import { weshClient } from "../client"; +import { subscribeMessages } from "../message/subscriber"; +import { bytesFromString, decodeJSON, stringFromBytes } from "../utils"; + +const processedMetadataIds: string[] = []; + +export const processMetadata = async (data: GroupMetadataEvent) => { + const id = stringFromBytes(data.eventContext?.id); + + if (processedMetadataIds.includes(id)) { + return; + } + processedMetadataIds.push(id); + try { + switch (data.metadata?.eventType) { + case EventType.EventTypeAccountContactRequestOutgoingEnqueued: { + try { + const parsedData: any = GroupMetadataEvent.toJSON(data); + const payload = AccountContactRequestOutgoingEnqueued.decode( + data.metadata.payload, + ); + parsedData.payload = payload; + + parsedData.payload.ownMetadata = decodeJSON(payload.ownMetadata); + + const groupInfo = await weshClient.client.GroupInfo({ + contactPk: payload.contact?.pk, + }); + + await weshClient.client.ActivateGroup({ + groupPk: groupInfo.group?.publicKey, + }); + + store.dispatch( + setConversationList({ + id: stringFromBytes(groupInfo.group?.publicKey), + type: "contact", + name: "", + members: [ + { + id: stringFromBytes(parsedData.payload.contact.pk), + rdvSeed: parsedData.payload.contact.publicRendezvousSeed, + name: parsedData.payload.ownMetadata.contact.name, + avatar: parsedData.payload.ownMetadata.contact.avatar, + peerId: parsedData.payload.ownMetadata.contact.peerId, + }, + ], + status: "active", + }), + ); + subscribeMessages(stringFromBytes(groupInfo.group?.publicKey)); + } catch (err) { + console.error("Outgoing enqueue err", err); + } + + break; + } + + case EventType.EventTypeAccountContactRequestIncomingReceived: { + const parsedData: any = GroupMetadataEvent.toJSON(data); + + parsedData.payload = AccountContactRequestIncomingReceived.decode( + data.metadata.payload, + ); + + parsedData.payload.contactMetadata = decodeJSON( + parsedData.payload.contactMetadata, + ); + + store.dispatch( + setContactRequestList({ + id: stringFromBytes(parsedData.payload.contactPk), + contactId: stringFromBytes(parsedData.payload.contactPk), + rdvSeed: stringFromBytes(parsedData.payload.contactRendezvousSeed), + name: parsedData.payload.contactMetadata.name, + avatar: parsedData.payload.contactMetadata.avatar, + peerId: parsedData.payload.contactMetadata.peerId, + }), + ); + + break; + } + case EventType.EventTypeAccountContactRequestIncomingDiscarded: { + const contactRequests = selectContactRequestList(store.getState()); + const parsedData: any = GroupMetadataEvent.toJSON(data); + + parsedData.payload = AccountContactRequestIncomingAccepted.decode( + data.metadata.payload, + ); + + store.dispatch( + setContactRequestList( + contactRequests.filter( + (item) => + item.contactId !== + stringFromBytes(parsedData.payload.contactPk), + ), + ), + ); + + break; + } + case EventType.EventTypeAccountContactRequestIncomingAccepted: { + const contactRequests = selectContactRequestList(store.getState()); + const parsedData: any = GroupMetadataEvent.toJSON(data); + + parsedData.payload = AccountContactRequestIncomingAccepted.decode( + data.metadata.payload, + ); + + const contactRequestIndex = contactRequests.findIndex( + (request) => + request.contactId === stringFromBytes(parsedData.payload.contactPk), + ); + + if (contactRequestIndex !== -1) { + const contactRequest = contactRequests[contactRequestIndex]; + store.dispatch( + setContactRequestList( + contactRequests.filter( + (item) => + item.contactId !== + stringFromBytes(parsedData.payload.contactPk), + ), + ), + ); + const group = await weshClient.client.GroupInfo({ + contactPk: bytesFromString(contactRequest.contactId), + }); + await weshClient.client.ActivateGroup({ + groupPk: group.group?.publicKey, + }); + + store.dispatch( + setConversationList({ + id: stringFromBytes(group.group?.publicKey), + type: "contact", + members: [ + { + id: contactRequest.contactId, + rdvSeed: contactRequest.rdvSeed, + name: contactRequest.name, + avatar: contactRequest.avatar, + peerId: contactRequest.peerId, + }, + ], + name: "", + status: "active", + }), + ); + + subscribeMessages(stringFromBytes(group.group?.publicKey)); + } + + break; + } + case EventType.EventTypeAccountGroupJoined: { + const parsedData: any = GroupMetadataEvent.toJSON(data); + + parsedData.payload = AccountGroupJoined.decode(data.metadata.payload); + store.dispatch( + setConversationList({ + id: stringFromBytes(parsedData.payload.group.publicKey), + type: "group", + members: [], + name: "Group", + status: "active", + }), + ); + subscribeMessages(stringFromBytes(parsedData.payload.group.publicKey)); + + break; + } + + default: + return null; + } + } catch (err) { + console.error("metadata next err", err); + } +}; diff --git a/packages/weshnet/metadata/subscriber.ts b/packages/weshnet/metadata/subscriber.ts new file mode 100644 index 0000000000..df716e2c96 --- /dev/null +++ b/packages/weshnet/metadata/subscriber.ts @@ -0,0 +1,159 @@ +import { Platform } from "react-native"; + +import { processMetadata } from "./processEvent"; +import { + GroupMessageList_Request, + GroupMessageEvent, + GroupMetadataEvent, + GroupMetadataList_Request, +} from "../../api/weshnet/protocoltypes"; +import { selectLastIdByKey, setLastId } from "../../store/slices/message"; +import { store } from "../../store/store"; +import { weshClient } from "../client"; +import { processMessage } from "../message/processEvent"; +import { bytesFromString, stringFromBytes } from "../utils"; + +export const subscribeMessages = async (groupPk: string) => { + try { + const lastId = selectLastIdByKey(groupPk)(store.getState()); + + const config: Partial = { + groupPk: bytesFromString(groupPk), + }; + + if (lastId) { + config.sinceId = bytesFromString(lastId); + } else { + config.untilNow = true; + config.reverseOrder = true; + } + + try { + await weshClient.client.ActivateGroup({ + groupPk: bytesFromString(groupPk), + }); + const messages = await weshClient.client.GroupMessageList(config); + let isLastIdSet = false; + + const observer = { + next: (data: GroupMessageEvent) => { + try { + const id = stringFromBytes(data.eventContext?.id); + + if (lastId === id) { + return; + } + if (!lastId && !isLastIdSet) { + store.dispatch( + setLastId({ + key: groupPk, + value: id, + }), + ); + isLastIdSet = true; + } + + if (lastId) { + store.dispatch( + setLastId({ + key: groupPk, + value: id, + }), + ); + } + + processMessage(data, groupPk); + } catch (err) { + console.error("subscribe message next err:", err); + } + }, + error: (e: any) => { + console.error("get message err", e); + }, + complete: async () => { + const lastId = selectLastIdByKey(groupPk)(store.getState()); + if (Platform.OS === "web" && lastId) { + subscribeMessages(groupPk); + } else { + setTimeout(() => subscribeMessages(groupPk), 3500); + } + }, + }; + return messages.subscribe(observer); + } catch (err) { + console.error("get messages err", err); + } + } catch (err) { + console.error("subscribe message", err); + } +}; + +export const subscribeMetadata = async ( + groupPk: Uint8Array | undefined, + ignoreLastId = false, +) => { + if (!groupPk) { + return; + } + let lastId: undefined | string = selectLastIdByKey("metadata")( + store.getState(), + ); + const config: Partial = { + groupPk, + }; + if (ignoreLastId) { + lastId = undefined; + } + + if (lastId) { + config.sinceId = bytesFromString(lastId); + } else { + config.untilNow = true; + config.reverseOrder = true; + } + + try { + const metadata = await weshClient.client.GroupMetadataList(config); + let isLastIdSet = false; + + const myObserver = { + next: (data: GroupMetadataEvent) => { + const id = stringFromBytes(data.eventContext?.id); + if (lastId === id) { + return; + } + if (!lastId && !isLastIdSet) { + store.dispatch( + setLastId({ + key: "metadata", + value: id, + }), + ); + isLastIdSet = true; + } + + if (lastId) { + store.dispatch( + setLastId({ + key: "metadata", + value: id, + }), + ); + } + + processMetadata(data); + }, + error(e: Error) { + if (e.message.includes("since ID not found")) { + subscribeMetadata(groupPk, true); + } + }, + complete: () => { + subscribeMetadata(groupPk); + }, + }; + metadata.subscribe(myObserver); + } catch (err) { + console.error("get metadata err", err); + } +}; diff --git a/packages/weshnet/services.ts b/packages/weshnet/services.ts new file mode 100644 index 0000000000..8be25e8e66 --- /dev/null +++ b/packages/weshnet/services.ts @@ -0,0 +1,291 @@ +import { Platform } from "react-native"; + +import { weshClient } from "./client"; +import { weshConfig } from "./config"; +import { subscribeMessages } from "./message/subscriber"; +import { subscribeMetadata } from "./metadata/subscriber"; +import { bytesFromString, encodeJSON, stringFromBytes } from "./utils"; +import { + Group, + GroupInfo_Request, + GroupType, +} from "../api/weshnet/protocoltypes"; +import { + MessageState, + selectConversationList, + setIsWeshConnected, + setContactInfo, + setPeerList, +} from "../store/slices/message"; +import { store } from "../store/store"; +import { isElectron } from "../utils/isElectron"; +import { CONVERSATION_TYPES, Message } from "../utils/types/message"; + +let getPeerListIntervalId: ReturnType; + +export const getAndUpdatePeerList = async () => { + const peerList = await weshClient.client.PeerList({}); + + store.dispatch( + setPeerList( + peerList.peers.map((item) => ({ + id: item.id, + isActive: item.isActive, + })), + ), + ); +}; + +export const bootWeshModule = async () => { + try { + if (Platform.OS === "web" && !isElectron()) { + return; + } + + if (isElectron()) { + weshClient.watchPort(); + } else { + const WeshnetModule = require("../../../modules/weshd"); + const port = await WeshnetModule.getPort(); + WeshnetModule.boot(); + setTimeout(() => { + weshClient.createClient(port); + }, 15 * 1000); + } + } catch (err) { + console.error("bootWeshModule", err); + } +}; + +export const bootWeshnet = async () => { + try { + store.dispatch(setIsWeshConnected(true)); + await weshClient.client.ContactRequestEnable({}); + const contactRef = await weshClient.client.ContactRequestReference({}); + + if (contactRef.publicRendezvousSeed.length === 0) { + const resetRef = await weshClient.client.ContactRequestResetReference({}); + contactRef.publicRendezvousSeed = resetRef.publicRendezvousSeed; + } + + store.dispatch( + setContactInfo({ + publicRendezvousSeed: stringFromBytes(contactRef.publicRendezvousSeed), + }), + ); + + subscribeMetadata(weshConfig.config?.accountGroupPk); + + getAndUpdatePeerList(); + if (getPeerListIntervalId) { + clearInterval(getPeerListIntervalId); + } + getPeerListIntervalId = setInterval(() => { + getAndUpdatePeerList(); + }, 30 * 1000); + } catch (err) { + console.error("create config err", err); + } + bootSubscribeMessages(); +}; + +const bootSubscribeMessages = () => { + const conversations = selectConversationList(CONVERSATION_TYPES.ACTIVE)( + store.getState(), + ); + + conversations.forEach((item) => { + subscribeMessages(item.id); + }); +}; + +export const createSharableLink = ( + contactInfo: MessageState["contactInfo"], +) => { + if (!weshConfig?.config?.accountPk || !contactInfo.publicRendezvousSeed) { + return ""; + } + return `https://app.teritori.com/contact?accountPk=${encodeURIComponent( + stringFromBytes(weshConfig.config?.accountPk), + )}&rdvSeed=${encodeURIComponent( + contactInfo.publicRendezvousSeed, + )}&name=${encodeURIComponent(contactInfo.name)}&avatar=${encodeURIComponent( + contactInfo.avatar, + )}&peerId=${encodeURIComponent(weshConfig.config?.peerId)}`; +}; + +export const createMultiMemberShareableLink = ( + group: Group, + groupName: string, +) => { + // construct URL + return `https://app.teritori.com/group?publicKey=${encodeURIComponent( + stringFromBytes(group.publicKey), + )}&secret=${encodeURIComponent( + stringFromBytes(group.secret), + )}&secretSig=${encodeURIComponent( + stringFromBytes(group.secretSig), + )}&signPub=${encodeURIComponent( + stringFromBytes(group.signPub), + )}&linkKey=${encodeURIComponent( + stringFromBytes(group.linkKey), + )}&linkKeySig=${encodeURIComponent( + stringFromBytes(group.linkKeySig), + )}&groupName=${groupName}`; +}; + +export const multiMemberGroupJoin = async ( + multiMemberSharedLink: string, + contactInfo: MessageState["contactInfo"], +) => { + // create URL from string + const url = new URL(multiMemberSharedLink); + + // get all params from URL + const publicKey = bytesFromString( + decodeURIComponent(url?.searchParams.get("publicKey") || ""), + ); + const secret = bytesFromString( + decodeURIComponent(url?.searchParams.get("secret") || ""), + ); + const secretSig = bytesFromString( + decodeURIComponent(url?.searchParams.get("secretSig") || ""), + ); + const signPub = bytesFromString( + decodeURIComponent(url?.searchParams.get("signPub") || ""), + ); + const linkKey = bytesFromString( + decodeURIComponent(url?.searchParams.get("linkKey") || ""), + ); + const linkKeySig = bytesFromString( + decodeURIComponent(url?.searchParams.get("linkKeySig") || ""), + ); + const groupName = url?.searchParams.get("groupName") || ""; + + // construct group object + const group: Group = { + publicKey, + secret, + secretSig, + groupType: GroupType.GroupTypeMultiMember, + signPub, + linkKey, + linkKeySig, + }; + + // join multiMember conversation + await weshClient.client.MultiMemberGroupJoin({ group }); + + // active conversation + await weshClient.client.ActivateGroup({ groupPk: publicKey }); + + // send group join message + await sendMessage({ + groupPk: publicKey, + message: { + type: "group-join", + payload: { + message: "", + files: [], + metadata: { + contact: { + id: stringFromBytes(weshConfig.config?.accountPk), + rdvSeed: stringFromBytes(weshConfig.metadata.rdvSeed), + tokenId: weshConfig.metadata.tokenId, + name: contactInfo.name, + avatar: contactInfo.avatar, + peerId: weshConfig.config?.peerId, + }, + groupName, + }, + }, + }, + }); +}; + +export const addContact = async ( + shareLink: string, + contactInfo: MessageState["contactInfo"], +) => { + const url = new URL(shareLink); + + if ( + !url?.searchParams.has("accountPk") || + !url?.searchParams.has("rdvSeed") + ) { + throw new Error("Share link is invalid"); + } + + const contactPk = bytesFromString( + decodeURIComponent(url?.searchParams.get("accountPk") || ""), + ); + try { + await weshClient.client.ContactRequestSend({ + contact: { + pk: contactPk, + publicRendezvousSeed: bytesFromString( + decodeURIComponent(url?.searchParams.get("rdvSeed") || ""), + ), + }, + ownMetadata: encodeJSON({ + name: contactInfo.name, + avatar: contactInfo.avatar, + peerId: weshConfig.config?.peerId, + timestamp: new Date().toISOString(), + contact: { + name: decodeURIComponent(url?.searchParams.get("name") || ""), + avatar: decodeURIComponent(url?.searchParams.get("avatar") || ""), + peerId: decodeURIComponent(url?.searchParams.get("peerId") || ""), + }, + }), + }); + } catch (err: any) { + if (!err?.message?.includes("ErrContactRequestContactAlreadyAdded")) { + throw err; + } + } +}; + +export const acceptFriendRequest = async (contactPk: Uint8Array) => { + await weshClient.client.ContactRequestAccept({ + contactPk, + }); + return await activateGroup({ contactPk }); +}; + +export const activateGroup = async (params: Partial) => { + try { + const contactGroup = await weshClient.client.GroupInfo(params); + await weshClient.client.ActivateGroup({ + groupPk: contactGroup.group?.publicKey, + }); + + return contactGroup; + } catch (err) { + console.error("activateGroup", err); + } +}; + +export const sendMessage = async ({ + groupPk, + message, +}: { + groupPk?: Uint8Array; + message: Omit; +}) => { + if (!groupPk) { + return; + } + try { + await weshClient.client.AppMessageSend({ + groupPk, + payload: encodeJSON({ + ...message, + timestamp: new Date().toISOString(), + senderId: stringFromBytes(weshConfig.config?.accountPk), + }), + }); + } catch (err) { + console.error("send message err", err); + } +}; diff --git a/packages/weshnet/utils.ts b/packages/weshnet/utils.ts new file mode 100644 index 0000000000..777cc44370 --- /dev/null +++ b/packages/weshnet/utils.ts @@ -0,0 +1,39 @@ +export const bytesFromString = (str: string = ""): Uint8Array => { + const bin = atob(decodeURIComponent(str)); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; ++i) { + arr[i] = bin.charCodeAt(i); + } + return arr; +}; + +export const stringFromBytes = ( + arr: Uint8Array = new Uint8Array([]), +): string => { + if (!arr) { + return ""; + } + const bin: string[] = []; + arr.forEach((byte) => { + bin.push(String.fromCharCode(byte)); + }); + return btoa(bin.join("")); +}; + +export const encode = (str: string) => { + return new TextEncoder().encode(encodeURIComponent(str)); +}; + +export const decode = (arr: Uint8Array) => { + return decodeURIComponent( + new TextDecoder().decode(arr).replace(/(\r\n|\n|\r)/gm, ""), + ); +}; + +export const decodeJSON = (arr: Uint8Array) => { + return JSON.parse(decode(arr)); +}; + +export const encodeJSON = (obj: object) => { + return encode(JSON.stringify(obj)); +};