diff --git a/src/lib/adapters/index.ts b/src/lib/adapters/index.ts index b6325018..c4ed7da0 100644 --- a/src/lib/adapters/index.ts +++ b/src/lib/adapters/index.ts @@ -36,6 +36,7 @@ export interface Adapter { data: JSONSerializable, ): Promise sendGroupChatInvite(wallet: BaseWallet, chatId: string, users: string[]): Promise + sendInstall(chatId: string, objectId: string, command: 'invite' | 'accept'): Promise updateStore( address: string, diff --git a/src/lib/adapters/waku/crypto.ts b/src/lib/adapters/waku/crypto.ts index 6816c1ad..b4a0be7d 100644 --- a/src/lib/adapters/waku/crypto.ts +++ b/src/lib/adapters/waku/crypto.ts @@ -38,6 +38,11 @@ export function hash(data: Uint8Array | Hex): Hex { return keccak256(bytes) } +export function hashString(s: string): Hex { + const data = new TextEncoder().encode(s) + return hash(data) +} + export function encrypt( plaintext: Uint8Array, key: Uint8Array, diff --git a/src/lib/adapters/waku/index.ts b/src/lib/adapters/waku/index.ts index 7bc173c6..21fed29b 100644 --- a/src/lib/adapters/waku/index.ts +++ b/src/lib/adapters/waku/index.ts @@ -13,6 +13,8 @@ import { type WithoutMeta, type ChatMessage, type BabbleMessage, + type WithMeta, + type InstallMessage, } from '$lib/stores/chat' import type { User } from '$lib/types' import type { TimeFilter } from '@waku/interfaces' @@ -38,6 +40,7 @@ import { makeWakustore, type Wakustore } from './wakustore' import type { StorageChat, StorageChatEntry, + StorageInstalledObject, StorageInstalledObjectEntry, StorageObjectEntry, StorageProfile, @@ -62,6 +65,7 @@ import { } from './codec' import type { DecodedMessage } from '@waku/message-encryption' import { utils } from '@noble/secp256k1' +import { getObjectSpec } from '$lib/objects/external/lib' const MAX_MESSAGES = 100 @@ -148,6 +152,10 @@ async function addMessageToChat( await executeOnDataMessage(ownPublicKey, blockchainAdapter, chatId, message, send) } + if (message.type === 'install') { + await executeOnInstallMessage(ownPublicKey, chatId, message) + } + const unread = message.type === 'user' && message.senderPublicKey !== ownPublicKey ? 1 : 0 chats.updateChat(chatId, (chat) => ({ ...chat, @@ -160,7 +168,7 @@ async function executeOnDataMessage( publicKey: string, blockchainAdapter: WakuObjectAdapter, chatId: string, - dataMessage: DataMessage, + dataMessage: WithMeta, send: (data: JSONValue) => Promise, ) { const descriptor = lookup(dataMessage.objectId) @@ -218,6 +226,85 @@ async function executeOnDataMessage( } } +async function executeOnInstallMessage( + publicKey: string, + chatId: string, + message: WithMeta, +) { + if (message.senderPublicKey === publicKey) { + if (message.command === 'accept') { + const installedObjects = get(installedObjectStore).objects + if (!installedObjects.has(message.objectId)) { + return + } + + installedObjectStore.updateInstalledObject(message.objectId, (object) => ({ + ...object, + installed: true, + })) + + // add to chat objects + chats.updateChat(chatId, (chat) => { + if (!chat.objects) { + chat.objects = [] + } + if (!chat.objects.includes(message.objectId)) { + chat.objects.push(message.objectId) + } + return chat + }) + } + + return + } + + if (message.command === 'invite') { + // add to installed objects + const installedObjects = get(installedObjectStore).objects + if (installedObjects.has(message.objectId)) { + return + } + + // TODO fix WakuScriptType + const objectSpec = await getObjectSpec(message.objectId, 'chat') + if (!objectSpec) { + return + } + + const installedObject: StorageInstalledObject = { + objectId: message.objectId, + name: objectSpec.object.name, + description: objectSpec.object.description, + // TODO fix relative path to absolute + logo: objectSpec.object.files.logo.path, + installed: false, + } + + installedObjectStore.addInstalledObject(installedObject) + } else if (message.command === 'accept') { + const installedObjects = get(installedObjectStore).objects + if (!installedObjects.has(message.objectId)) { + return + } + + installedObjectStore.updateInstalledObject(message.objectId, (object) => ({ + ...object, + installed: true, + })) + + // add to chat objects + chats.updateChat(chatId, (chat) => { + if (!chat.objects) { + chat.objects = [] + } + if (!chat.objects.includes(message.objectId)) { + chat.objects.push(message.objectId) + } + return chat + }) + } +} + function decryptHexToString(h: string, symKey: Uint8Array): string { const encrypted = hexToBytes(h) const decrypted = decrypt(encrypted, symKey) @@ -677,6 +764,23 @@ export default class WakuAdapter implements Adapter { } } + async sendInstall(chatId: string, objectId: string, command: 'invite' | 'accept'): Promise { + const wallet = get(walletStore).wallet + if (!wallet) { + return + } + + const senderPrivateKey = wallet.privateKey + const message: WithoutMeta = { + type: 'install', + objectId, + command, + } + const encryptionKey = hexToBytes(chatId) + + await this.safeWaku.sendMessage(message, encryptionKey, hexToBytes(senderPrivateKey)) + } + async updateStore( // eslint-disable-next-line @typescript-eslint/no-unused-vars _address: string, @@ -928,7 +1032,14 @@ export default class WakuAdapter implements Adapter { adapter: WakuObjectAdapter, ) { // only handle certain types of messages - if (!(message.type === 'invite' || message.type === 'data' || message.type === 'user')) { + if ( + !( + message.type === 'invite' || + message.type === 'data' || + message.type === 'user' || + message.type === 'install' + ) + ) { return } diff --git a/src/lib/adapters/waku/types.ts b/src/lib/adapters/waku/types.ts index ca83dcd2..dc28e6c0 100644 --- a/src/lib/adapters/waku/types.ts +++ b/src/lib/adapters/waku/types.ts @@ -21,5 +21,6 @@ export interface StorageInstalledObject { name: string description: string logo: string + installed: boolean } export type StorageInstalledObjectEntry = [objectId: string, object: StorageInstalledObject] diff --git a/src/lib/components/chat-object-invite.svelte b/src/lib/components/chat-object-invite.svelte new file mode 100644 index 00000000..3dd8aa84 --- /dev/null +++ b/src/lib/components/chat-object-invite.svelte @@ -0,0 +1,94 @@ + + + + {#if !object} + + {:else} +
+ + {#if message.command === 'invite'} + {senderName} invited {recipientName} to use "{object.name}" in this chat. + {:else if message.command === 'accept'} + {senderName} accepted the invite. You can now use "{object.name}" in this chat. + {/if} + + + + + + {#if message.command === 'invite'} + {#if myMessage} +

+ {#if isInstalledInChat} + Invite accepted + {:else} + Invite pending... + {/if} +

+ {:else} + + {/if} + {:else if message.command === 'accept'} +

+ {#if isInstalledInChat} + Invite accepted + {:else} + Invite pending... + {/if} +

+ {/if} +
+
+ {/if} +
+ + diff --git a/src/lib/components/icons/data-blob.svelte b/src/lib/components/icons/data-blob.svelte new file mode 100644 index 00000000..4e24717d --- /dev/null +++ b/src/lib/components/icons/data-blob.svelte @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + diff --git a/src/lib/components/error-modal.svelte b/src/lib/components/modal.svelte similarity index 100% rename from src/lib/components/error-modal.svelte rename to src/lib/components/modal.svelte diff --git a/src/lib/objects/lookup.ts b/src/lib/objects/lookup.ts index c257b06d..f0872d07 100644 --- a/src/lib/objects/lookup.ts +++ b/src/lib/objects/lookup.ts @@ -8,6 +8,11 @@ import { splitDescriptor } from './split' import { installedObjectStore } from '$lib/stores/installed-objects' import { get } from 'svelte/store' +export type InstalledObjectDescriptor = WakuObjectSvelteDescriptor & { + preInstalled: boolean + installed: boolean +} + const preInstalledObjectList: WakuObjectSvelteDescriptor[] = [ helloWorldDescriptor, payggyDescriptor, @@ -20,16 +25,25 @@ const preInstalledObjectList: WakuObjectSvelteDescriptor[] = [ ), ] -export function lookup(objectId: string): WakuObjectSvelteDescriptor | undefined { +export function lookup(objectId: string): InstalledObjectDescriptor | undefined { const installedObjectList = getInstalledObjectList() return installedObjectList.find((object) => object.objectId === objectId) } -export function getInstalledObjectList() { - const installedObjectList = Array.from(get(installedObjectStore).objects) - .map((item) => item[1]) - .map((object) => - getExternalDescriptor(object.objectId, object.name, object.description, object.logo), - ) - return preInstalledObjectList.concat(installedObjectList) +export function getInstalledObjectList(): InstalledObjectDescriptor[] { + const installedObjectList = Array.from(get(installedObjectStore).objects).map((item) => { + const object = item[1] + return { + ...getExternalDescriptor(object.objectId, object.name, object.description, object.logo), + preInstalled: false, + installed: object.installed, + } + }) + return preInstalledObjectList + .map((object) => ({ + ...object, + preInstalled: true, + installed: true, + })) + .concat(installedObjectList) } diff --git a/src/lib/routes.ts b/src/lib/routes.ts index 689eb3bb..1b6d0aa6 100644 --- a/src/lib/routes.ts +++ b/src/lib/routes.ts @@ -20,4 +20,6 @@ export default { BABBLES_CHAT: (id: string, threadId?: string) => `/babbles/chat/${id}${threadId ? `/${threadId}` : ''}`, BABBLES_EDIT: (id: string) => `/babbles/chat/${id}/edit`, + SETTINGS_OBJECTS: '/objects', + SETTINGS_OBJECT: (objectId: string) => `/objects/${objectId}`, } diff --git a/src/lib/stores/chat.ts b/src/lib/stores/chat.ts index a7c1ce62..f66ce284 100644 --- a/src/lib/stores/chat.ts +++ b/src/lib/stores/chat.ts @@ -40,7 +40,13 @@ export interface BabbleMessage { parentId?: string } -export type Message = UserMessage | DataMessage | InviteMessage | BabbleMessage +export interface InstallMessage { + type: 'install' + objectId: string + command: 'invite' | 'accept' +} + +export type Message = UserMessage | DataMessage | InviteMessage | BabbleMessage | InstallMessage export type WithoutMeta = Omit export type WithMeta = T & MessageMetadata @@ -58,6 +64,7 @@ export interface Chat { avatar?: string joined?: boolean inviter?: string + objects?: string[] } export interface ChatData { diff --git a/src/lib/stores/installed-objects.ts b/src/lib/stores/installed-objects.ts index dccacc32..bc0a3147 100644 --- a/src/lib/stores/installed-objects.ts +++ b/src/lib/stores/installed-objects.ts @@ -8,7 +8,14 @@ export interface InstalledObjects { } // eslint-disable-next-line @typescript-eslint/no-empty-interface -interface InstalledObjectStore extends Writable {} +interface InstalledObjectStore extends Writable { + addInstalledObject: (object: StorageInstalledObject) => void + updateInstalledObject: ( + objectId: string, + update: (object: StorageInstalledObject) => StorageInstalledObject, + ) => void + removeInstalledObject: (objectId: string) => void +} function createInstalledObjects(): InstalledObjectStore { const store = writable({ @@ -17,6 +24,52 @@ function createInstalledObjects(): InstalledObjectStore { }) return { ...store, + addInstalledObject(object: StorageInstalledObject) { + store.update((state) => { + if (state.objects.has(object.objectId)) { + return state + } + + state.objects.set(object.objectId, object) + + return { + ...state, + objects: state.objects, + } + }) + }, + updateInstalledObject(objectId, update) { + store.update((state) => { + if (!state.objects.has(objectId)) { + return state + } + const oldObject = state.objects.get(objectId) + if (!oldObject) { + return state + } + const newMap = new Map(state.objects) + const newObject = update(oldObject) + newMap.set(objectId, newObject) + + return { + ...state, + objects: newMap, + } + }) + }, + removeInstalledObject(objectId) { + store.update((state) => { + if (!state.objects.has(objectId)) { + return state + } + state.objects.delete(objectId) + + return { + ...state, + chats: state.objects, + } + }) + }, } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f9695e95..3a63ff95 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -17,7 +17,7 @@ import { defaultBlockchainNetwork, getChainId } from '$lib/adapters/transaction' import Container from '$lib/components/container.svelte' import Loading from '$lib/components/loading.svelte' - import ErrorModal from '$lib/components/error-modal.svelte' + import ErrorModal from '$lib/components/modal.svelte' import Button from '$lib/components/button.svelte' import Renew from '$lib/components/icons/renew.svelte' diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2ed941dc..bfbea352 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -15,7 +15,7 @@ // Stores import { profile } from '$lib/stores/profile' - import { chats, isGroupChat, type Chat, type Message, isBabbles } from '$lib/stores/chat' + import { chats, isGroupChat, type Chat, isBabbles, type ChatMessage } from '$lib/stores/chat' import ROUTES from '$lib/routes' import AuthenticatedOnly from '$lib/components/authenticated-only.svelte' @@ -43,7 +43,7 @@ return lastMessage ? lastMessage.timestamp : 0 } - function lastSenderName(chat: Chat, myMessage?: boolean, lastMessage?: Message) { + function lastSenderName(chat: Chat, myMessage?: boolean, lastMessage?: ChatMessage) { if (myMessage) { return 'You: ' } diff --git a/src/routes/chat/[id]/+page.svelte b/src/routes/chat/[id]/+page.svelte index ad430769..83d0b0cc 100644 --- a/src/routes/chat/[id]/+page.svelte +++ b/src/routes/chat/[id]/+page.svelte @@ -12,6 +12,7 @@ import Button from '$lib/components/button.svelte' import Avatar from '$lib/components/avatar.svelte' import WakuObject from '$lib/objects/chat.svelte' + import ChatObjectInvite from '$lib/components/chat-object-invite.svelte' import { goto } from '$app/navigation' import { chats, isGroupChat } from '$lib/stores/chat' @@ -173,6 +174,15 @@ {:else if message.type === 'data'} + {:else if message.type === 'install'} + {/if} {/each} diff --git a/src/routes/chat/[id]/object/new/+page.svelte b/src/routes/chat/[id]/object/new/+page.svelte index d3da93ee..f237c209 100644 --- a/src/routes/chat/[id]/object/new/+page.svelte +++ b/src/routes/chat/[id]/object/new/+page.svelte @@ -6,6 +6,7 @@ import Header from '$lib/components/header.svelte' import Button from '$lib/components/button.svelte' import Close from '$lib/components/icons/close.svelte' + import Modal from '$lib/components/modal.svelte' import { goto } from '$app/navigation' import { chats } from '$lib/stores/chat' @@ -16,26 +17,34 @@ import ButtonBlock from '$lib/components/button-block.svelte' import AuthenticatedOnly from '$lib/components/authenticated-only.svelte' import Layout from '$lib/components/layout.svelte' - import type { JSONSerializable } from '$lib/objects' + import type { JSONSerializable, WakuObjectSvelteDescriptor } from '$lib/objects' import { genRandomHex } from '$lib/utils' - import { getInstalledObjectList } from '$lib/objects/lookup' + import { getInstalledObjectList, type InstalledObjectDescriptor } from '$lib/objects/lookup' import { errorStore } from '$lib/stores/error' + import ChatLaunch from '$lib/components/icons/chat-launch.svelte' - const objects = getInstalledObjectList().map((object) => ({ - ...object, - onClick: object.standalone - ? () => { - goto(ROUTES.OBJECT($page.params.id, encodeURIComponent(object.objectId), 'new')) - } - : () => { - createObject(object.objectId, { - /* TODO empty */ - }) - goto(ROUTES.CHAT($page.params.id)) - }, - })) + $: otherUser = $chats.chats + .get($page.params.id) + ?.users.find((m) => m.publicKey !== $walletStore.wallet?.signingKey.compressedPublicKey) + + const installedObjects = getInstalledObjectList() + .map((object) => ({ + ...object, + add: () => addObject(object), + showInvite: () => (showInvite = object), + })) + .filter((object) => object.installed) + + const chatObjects = $chats.chats.get($page.params.id)?.objects + const alreadyUsedObjects = installedObjects.filter( + (object) => chatObjects && chatObjects.includes(object.objectId), + ) + const notUsedObjects = installedObjects.filter( + (object) => !chatObjects || !chatObjects.includes(object.objectId), + ) let loading = false let text = '' + let showInvite: InstalledObjectDescriptor | undefined = undefined const createObject = async (objectId: string, t: JSONSerializable) => { const instanceId = genRandomHex(12) @@ -67,12 +76,66 @@ loading = false } - $: otherUser = $chats.chats - .get($page.params.id) - ?.users.find((m) => m.publicKey !== $walletStore.wallet?.signingKey.compressedPublicKey) + function addObject(object: WakuObjectSvelteDescriptor) { + if (object.standalone) { + goto(ROUTES.OBJECT($page.params.id, encodeURIComponent(object.objectId), 'new')) + return + } + + createObject(object.objectId, { + /* TODO empty */ + }) + goto(ROUTES.CHAT($page.params.id)) + } + + async function sendInstallInvite(object: InstalledObjectDescriptor) { + // TODO temporary workaround for preinstalled objects + if (object.preInstalled) { + addObject(object) + return + } + + console.debug('send invite', { object }) + + const wallet = $walletStore.wallet + if (!wallet) { + errorStore.addEnd({ + title: 'Wallet Error', + message: 'No wallet found', + retry: () => sendInstallInvite(object), + reload: true, + }) + return + } + + try { + await adapters.sendInstall($page.params.id, object.objectId, 'invite') + showInvite = undefined + history.back() + } catch (error) { + errorStore.addEnd({ + title: 'Error', + message: `Failed to send invite. ${(error as Error)?.message}`, + retry: () => sendInstallInvite(object), + }) + } + } + {#if showInvite} + + + + + {/if} history.back()}> @@ -90,11 +153,31 @@ + + {#if alreadyUsedObjects.length > 0} +
Already in this chat
+ {/if} +
+ {#each alreadyUsedObjects as object} +
+ +
+ {/each} +
+ + {#if notUsedObjects.length > 0} +
Not yet in this chat
+ {/if}
- {#each objects as object} + {#each notUsedObjects as object}
diff --git a/src/routes/identity/+page.svelte b/src/routes/identity/+page.svelte index 9b285699..ed7da393 100644 --- a/src/routes/identity/+page.svelte +++ b/src/routes/identity/+page.svelte @@ -31,6 +31,7 @@ import { errorStore } from '$lib/stores/error' import { installedObjectStore } from '$lib/stores/installed-objects' import { getObjectSpec } from '$lib/objects/external/lib' + import DataBlob from '$lib/components/icons/data-blob.svelte' let avatar = $profile.avatar let name = $profile.name @@ -97,20 +98,6 @@ }, 1000) } - async function addObject() { - const { object } = await getObjectSpec(objectPath, 'chat') - installedObjectStore.update((state) => { - state.objects.set(objectPath, { - objectId: objectPath, - name: object.name, - description: object.description, - logo: object.files.logo.path, - }) - return { ...state } - }) - objectPath = '' - } - onDestroy(() => { if (timer) { clearTimeout(timer) @@ -162,7 +149,7 @@
- goto(routes.IDENTITY_PREFERENCES)}> + goto(routes.IDENTITY_PREFERENCES)}>
Preferences @@ -174,6 +161,18 @@
+ goto(routes.SETTINGS_OBJECTS)}> + +
+ Waku Objects +
+
+ +
+
+

If you disconnect or need to recover access to your identity you will need your recovery @@ -195,11 +194,7 @@ Disconnect identity from device - - - - - + diff --git a/src/routes/objects/+page.svelte b/src/routes/objects/+page.svelte new file mode 100644 index 00000000..bd594b85 --- /dev/null +++ b/src/routes/objects/+page.svelte @@ -0,0 +1,101 @@ + + +{#if loading} + + + + + +{:else} + + +

+ +
+ + + {#each installedObjects as object} + goto(routes.SETTINGS_OBJECT(hashString(object.objectId)))} + > + +
+ {object.name} + {object.name}{`${object.preInstalled ? '*' : ''}`} +
+
+ +
+
+
+ {/each} + + + + + +
+ +{/if} + + diff --git a/src/routes/objects/[object_id]/+page.svelte b/src/routes/objects/[object_id]/+page.svelte new file mode 100644 index 00000000..49ff6e57 --- /dev/null +++ b/src/routes/objects/[object_id]/+page.svelte @@ -0,0 +1,75 @@ + + +{#if loading || !object} + + + + + +{:else} + + +
+ +
+
+ + +

{object.name}

+

{object.description}

+
+ + {#if !object.preInstalled && object.installed} + + {/if} +
+
+{/if} + +