From 3f7654609739f899da035a6ddbbd6a373dca59a0 Mon Sep 17 00:00:00 2001 From: Flemmli97 <34157027+Flemmli97@users.noreply.github.com> Date: Tue, 17 Dec 2024 20:16:16 +0100 Subject: [PATCH 1/4] feat(community): Add Communities api (#935) --- package.json | 2 +- src/lib/layouts/Chatbar.svelte | 1 - src/lib/wasm/CommunitiesStore.ts | 355 +++++++++++++++++++++++++++++++ src/lib/wasm/RaygunStore.ts | 351 ++++++++++++++++-------------- 4 files changed, 547 insertions(+), 162 deletions(-) create mode 100644 src/lib/wasm/CommunitiesStore.ts diff --git a/package.json b/package.json index e1c5d4a14..a4c025376 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,6 @@ "uuid": "^9.0.1", "vite-plugin-node-polyfills": "^0.21.0", "voice-activity-detection": "^0.0.5", - "warp-wasm": "1.6.2" + "warp-wasm": "^1.7.1" } } diff --git a/src/lib/layouts/Chatbar.svelte b/src/lib/layouts/Chatbar.svelte index 38138e935..8486e5f49 100644 --- a/src/lib/layouts/Chatbar.svelte +++ b/src/lib/layouts/Chatbar.svelte @@ -98,7 +98,6 @@ UIStore.mutateChat(chat.id, c => { c.last_view_date = new Date() }) - ConversationStore.addPendingMessages(chat.id, res.message, txt) }) if (!isStickerOrGif) { chatMessages.update(messages => { diff --git a/src/lib/wasm/CommunitiesStore.ts b/src/lib/wasm/CommunitiesStore.ts new file mode 100644 index 000000000..cd8cd920f --- /dev/null +++ b/src/lib/wasm/CommunitiesStore.ts @@ -0,0 +1,355 @@ +import { get, type Writable } from "svelte/store" +import * as wasm from "warp-wasm" +import { WarpStore } from "./WarpStore" +import { failure, success, type Result } from "$lib/utils/Result" +import { handleErrors, WarpError } from "./HandleWarpErrors" +import { MessageOptions } from "warp-wasm" +import { convertWarpAttachment, createFileAttachHandler, createFileDownloadHandler, createFileDownloadHandlerRaw, type FetchMessageResponse, type FetchMessagesConfig, type FileAttachment, type SendMessageResult } from "./RaygunStore" +import { messageTypeFromTexts, type Message, type Reaction } from "$lib/types" +import { MultipassStoreInstance } from "./MultipassStore" +import { Store } from "$lib/state/Store" +import { Appearance } from "$lib/enums" + +class CommunitiesStore { + private raygunWritable: Writable + + constructor(raygun: Writable) { + this.raygunWritable = raygun + } + + async createCommunity(name: string) { + return this.get(r => r.create_community(name), `Error creating a community with name ${name}`) + } + async deleteCommunity(community_id: string) { + return this.get(r => r.delete_community(community_id), `Error deleting community ${community_id}`) + } + async getCommunity(community_id: string) { + return this.get(r => r.get_community(community_id), `Error getting community ${community_id}`) + } + async listCommunitiesJoined() { + return this.get(r => r.list_communities_joined(), `Error listing joined communities`) + } + async listCommunitiesInvitedTo() { + return this.get(r => r.list_communities_invited_to(), `Error listing community invitations`) + } + async leaveCommunity(community_id: string) { + return this.get(r => r.leave_community(community_id), `Error leaving community ${community_id}`) + } + + async getCommunityIcon(community_id: string) { + return this.get(r => r.get_community_icon(community_id), `Error getting icon for community ${community_id}`) + } + async getCommunityBanner(community_id: string) { + return this.get(r => r.get_community_banner(community_id), `Error getting banner for community ${community_id}`) + } + async editCommunityPicture(community_id: string, picture: string, banner?: boolean) { + return this.get(r => { + const data = picture.startsWith("data:") ? picture.split(",")[1] : picture + const buffer = Buffer.from(data, "base64") + const len = buffer.length + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(buffer) + controller.close() + }, + }) + let file = new wasm.AttachmentFile("", new wasm.AttachmentStream(len, stream)) + return banner ? r.edit_community_banner(community_id, file) : r.edit_community_icon(community_id, file) + }, `Error editing picture for community ${community_id}`) + } + + async createCommunityInvite(community_id: string, target_user?: string, expiry?: Date) { + return this.get(r => r.create_community_invite(community_id, target_user, expiry), `Error creating invite for community ${community_id}`) + } + async deleteCommunityInvite(community_id: string, invite_id: string) { + return this.get(r => r.delete_community_invite(community_id, invite_id), `Error deleting invite ${invite_id} for community ${community_id}`) + } + async getCommunityInvite(community_id: string, invite_id: string) { + return this.get(r => r.get_community_invite(community_id, invite_id), `Error getting invite ${invite_id} for community ${community_id}`) + } + async acceptCommunityInvite(community_id: string, invite_id: string) { + return this.get(r => r.accept_community_invite(community_id, invite_id), `Error accepting invite ${invite_id} for community ${community_id}`) + } + async editCommunityInvite(community_id: string, invite_id: string, updater: (inv: wasm.CommunityInvite) => void) { + return this.get(async r => { + let invite = await r.get_community_invite(community_id, invite_id) + updater(invite) + return r.edit_community_invite(community_id, invite_id, invite) + }, `Error editing invite ${invite_id} for community ${community_id}`) + } + + async createCommunityRole(community_id: string, name: string) { + return this.get(r => r.create_community_role(community_id, name), `Error creating role for community ${community_id}`) + } + async deleteCommunityRole(community_id: string, role_id: string) { + return this.get(r => r.delete_community_role(community_id, role_id), `Error deleting role for community ${community_id}`) + } + async getCommunityRole(community_id: string, role_id: string) { + return this.get(r => r.get_community_role(community_id, role_id), `Error getting role for community ${community_id}`) + } + async editCommunityRoleName(community_id: string, role_id: string, new_name: string) { + return this.get(r => r.edit_community_role_name(community_id, role_id, new_name), `Error editing role ${role_id} for community ${community_id}`) + } + async grantCommunityRole(community_id: string, role_id: string, user: string) { + return this.get(r => r.grant_community_role(community_id, role_id, user), `Error granting role ${role_id} in community ${community_id} for user ${user}`) + } + async revokeCommunityRole(community_id: string, role_id: string, user: string) { + return this.get(r => r.revoke_community_role(community_id, role_id, user), `Error revoking role ${role_id} in community ${community_id} from user ${user}`) + } + + async createCommunityChannel(community_id: string, channel_name: string, channel_type: wasm.CommunityChannelType) { + return this.get(r => r.create_community_channel(community_id, channel_name, channel_type), `Error creating channel for community ${community_id}`) + } + async deleteCommunityChannel(community_id: string, channel_id: string) { + return this.get(r => r.delete_community_channel(community_id, channel_id), `Error deleting channel ${channel_id} for community ${community_id}`) + } + async getCommunityChannel(community_id: string, channel_id: string) { + return this.get(r => r.get_community_channel(community_id, channel_id), `Error getting channel for community ${community_id}`) + } + + async editCommunityName(community_id: string, name: string) { + return this.get(r => r.edit_community_name(community_id, name), `Error editing name for community ${community_id}`) + } + async editCommunityDescription(community_id: string, description?: string) { + return this.get(r => r.edit_community_description(community_id, description), `Error editing description for community ${community_id}`) + } + + async grantCommunityPermission(community_id: string, permission: CommunityPermission, role_id: string) { + return this.get(r => r.grant_community_permission(community_id, permission, role_id), `Error granting permission to role ${role_id} for community ${community_id}`) + } + async revokeCommunityPermission(community_id: string, permission: CommunityPermission, role_id: string) { + return this.get(r => r.revoke_community_permission(community_id, permission, role_id), `Error revoking permission for role ${role_id} in community ${community_id}`) + } + async grantCommunityPermissionForAll(community_id: string, permission: CommunityPermission) { + return this.get(r => r.grant_community_permission_for_all(community_id, permission), `Error granting permission for all in community ${community_id}`) + } + async revokeCommunityPermissionForAll(community_id: string, permission: CommunityPermission) { + return this.get(r => r.revoke_community_permission_for_all(community_id, permission), `Error revoking permission for all in community ${community_id}`) + } + + async removeCommunityMember(community_id: string, member: string) { + return this.get(r => r.remove_community_member(community_id, member), `Error removing member ${member} from community ${community_id}`) + } + + async editCommunityChannelName(community_id: string, channel_id: string, name: string) { + return this.get(r => r.edit_community_channel_name(community_id, channel_id, name), `Error editing channel ${channel_id} name for community ${community_id}`) + } + async editCommunityChannelDescription(community_id: string, channel_id: string, description?: string) { + return this.get(r => r.edit_community_channel_description(community_id, channel_id, description), `Error editing channel ${channel_id} description for community ${community_id}`) + } + + async grantCommunityChannelPermission(community_id: string, channel_id: string, permission: CommunityChannelPermission, role_id: string) { + return this.get(r => r.grant_community_channel_permission(community_id, channel_id, permission, role_id), `Error granting channel permission for role ${role_id} in community ${community_id}`) + } + async revokeCommunityChannelPermission(community_id: string, channel_id: string, permission: CommunityChannelPermission, role_id: string) { + return this.get(r => r.revoke_community_channel_permission(community_id, channel_id, permission, role_id), `Error revoking channel permission for role ${role_id} in for community ${community_id}`) + } + async grantCommunityChannelPermissionForAll(community_id: string, channel_id: string, permission: CommunityChannelPermission) { + return this.get(r => r.grant_community_channel_permission_for_all(community_id, channel_id, permission), `Error granting channel permission for community ${community_id}`) + } + async revokeCommunityChannelPermissionForAll(community_id: string, channel_id: string, permission: CommunityChannelPermission) { + return this.get(r => r.revoke_community_channel_permission_for_all(community_id, channel_id, permission), `Error revoking channel permission for community ${community_id}`) + } + + async getCommunityChannelMessage(community_id: string, channel_id: string, message_id: string) { + return this.get(r => r.get_community_channel_message(community_id, channel_id, message_id), `Error revoking channel permission for community ${community_id}`) + } + + async fetchCommunityMessages(community_id: string, channel_id: string, config: FetchMessagesConfig): Promise> { + return this.get(async r => { + let message_options = new MessageOptions() + switch (config.type) { + case "Between": { + message_options.set_date_range(config.from, config.to) //TODO verify that js Date can be parsed to rust DateTime:: + break + } + case "MostRecent": { + let total_messages = await r.get_community_channel_message_count(community_id, channel_id) + message_options.set_range(Math.min(0, total_messages - config.amount), total_messages) + break + } + case "Earlier": { + message_options.set_date_range(new Date(), config.start_date) + message_options.set_reverse() + message_options.set_limit(config.limit) + break + } + case "Later": { + message_options.set_date_range(config.start_date, new Date()) + message_options.set_limit(config.limit) + break + } + } + + let messages = await this.getMessages(r, community_id, channel_id, message_options) + if (config.type === "Earlier") { + messages = messages.reverse() + } + let has_more = "limit" in config ? messages.length >= config.limit : false + + let opt = new MessageOptions() + opt.set_limit(1) + opt.set_last_message() + let most_recent = await this.getMessages(r, community_id, channel_id, opt) + return { + messages: messages, + has_more: has_more, + most_recent: most_recent[most_recent.length].id, + } + }, "Error fetching messages") + } + + private async getMessages(raygun: wasm.RayGunBox, community_id: string, channel_id: string, options: MessageOptions) { + let msgs = await raygun.get_community_channel_messages(community_id, channel_id, options) + let messages: Message[] = [] + if (msgs.variant() === wasm.MessagesEnum.List) { + let warpMsgs = msgs.messages()! + messages = (await Promise.all(warpMsgs.map(async msg => await this.convertCommunityMessage(community_id, channel_id, msg)))).filter((m: Message | null): m is Message => m !== null) + } + return messages + } + + async communityChannelMessageStatus(community_id: string, channel_id: string, message_id: string) { + return this.get(r => r.community_channel_message_status(community_id, channel_id, message_id), `Error fetching message status for message ${message_id} in community ${community_id}`) + } + async sendCommunityChannelMessage(community_id: string, channel_id: string, message: string[], attachments?: FileAttachment[]) { + return await this.get(async r => { + return await this.sendTo(r, community_id, channel_id, message, { attachments }) + }, `Error sending message in channel ${channel_id} for community ${community_id}`) + } + async editCommunityChannelMessage(community_id: string, channel_id: string, message_id: string, message: string[]) { + return this.get(r => r.edit_community_channel_message(community_id, channel_id, message_id, message), `Error revoking channel permission for community ${community_id}`) + } + async replyToCommunityChannelMessage(community_id: string, channel_id: string, message_id: string, message: string[], attachments?: FileAttachment[]) { + return await this.get(async r => { + return await this.sendTo(r, community_id, channel_id, message, { attachments, replyTo: message_id }) + }, `Error replying to message ${message_id} in channel ${channel_id} for community ${community_id}`) + } + async deleteCommunityChannelMessage(community_id: string, channel_id: string, message_id: string) { + return this.get(r => r.delete_community_channel_message(community_id, channel_id, message_id), `Error deleting message ${message_id} for channel ${channel_id} in community ${community_id}`) + } + async pinCommunityChannelMessage(community_id: string, channel_id: string, message_id: string, pin: boolean) { + return this.get(r => r.pin_community_channel_message(community_id, channel_id, message_id, pin ? wasm.PinState.Pin : wasm.PinState.Unpin), `Error pinning message for community ${community_id}`) + } + async reactToCommunityChannelMessage(community_id: string, channel_id: string, message_id: string, state: wasm.ReactionState, emoji: string) { + let result = await this.get(r => r.react_to_community_channel_message(community_id, channel_id, message_id, state, emoji), "Error reacting to community message") + return result.map(_ => { + // TODO: Requires Store changes + // ConversationStore.editReaction(get(Store.state.activeChat).id, message_id, emoji, state == wasm.ReactionState.Add) + }) + } + + async downloadFromCommunityChannelMessage(community_id: string, channel_id: string, message_id: string, file: string, size?: number) { + return await this.get(async r => { + let result = await r.download_stream_from_community_channel_message(community_id, channel_id, message_id, file) + return createFileDownloadHandler(file, result, size) + }, `Error downloading community attachment from community ${community_id} for message ${message_id}`) + } + + async getCommunityMessageAttachmentRaw(community_id: string, channel_id: string, message_id: string, file: string, options?: { size?: number; type?: string }) { + return await this.get(async r => { + let result = await r.download_stream_from_community_channel_message(community_id, channel_id, message_id, file) + return createFileDownloadHandlerRaw(file, result, options) + }, `Error downloading community attachment from community ${community_id} for message ${message_id}`) + } + + async sendCommunityChannelMesssageEvent(community_id: string, channel_id: string, event: wasm.MessageEvent) { + return await this.get(r => r.send_community_channel_messsage_event(community_id, channel_id, event), `Error sending event ${event}`) + } + async cancelCommunityChannelMesssageEvent(community_id: string, channel_id: string, event: wasm.MessageEvent) { + return await this.get(r => r.cancel_community_channel_messsage_event(community_id, channel_id, event), `Error sending event ${event}`) + } + + private async sendTo(raygun: wasm.RayGunBox, community_id: string, channel_id: string, message: string[], settings?: { attachments?: FileAttachment[]; replyTo?: string }): Promise { + if (settings?.attachments && settings?.attachments.length > 0) { + let result = await raygun + .attach_to_community_channel_message( + community_id, + channel_id, + settings?.replyTo, + settings?.attachments.map(f => new wasm.AttachmentFile(f.file, f.attachment ? new wasm.AttachmentStream(f.attachment[1], f.attachment[0]) : undefined)), + message + ) + .then(res => { + // message_sent event gets fired AFTER this returns + // TODO: Requires Store changes + // ConversationStore.addPendingMessages(conversation_id, res.get_message_id(), message) + createFileAttachHandler(res, (file, updater) => { + //TODO: Update file on store + }) + return res + }) + return { + message: result.get_message_id(), + progress: result, + } + } + return { + message: await (settings?.replyTo ? raygun.reply_to_community_channel_message(community_id, channel_id, settings.replyTo, message) : raygun.send_community_channel_message(community_id, channel_id, message)).then(messageId => { + // message_sent event gets fired BEFORE this returns + // So to + // 1. unify this system + // 2. keep it roughly the same as native (as on native due to some channel delays it handles message_sent after #send returns) + // We add the pending msg here and remove it in message_sent which has a short delay + // TODO: Requires Store changes + // ConversationStore.addPendingMessages(conversation_id, messageId, message) + return messageId + }), + } + } + + /** + * Convenient helper method to get data from raygun + */ + private async get(handler: (raygun: wasm.RayGunBox) => Promise | T, err: string): Promise> { + let raygun = get(this.raygunWritable) + if (raygun) { + try { + return success(await handler(raygun)) + } catch (error) { + return failure(handleErrors(`${err}: ${error}`)) + } + } + return failure(WarpError.RAYGUN_NOT_FOUND) + } + + private async convertCommunityMessage(community_id: string, channel_id: string, message: wasm.Message | undefined): Promise { + if (!message) return null + let user = get(Store.state.user) + let remote = message.sender() !== user.key + if (remote) { + let sender = await MultipassStoreInstance.identity_from_did(message.sender()) + if (sender) Store.updateUser(sender) + } + let attachments = message.attachments() + let reactions: { [key: string]: Reaction } = {} + message.reactions().forEach((dids, emoji) => { + reactions[emoji] = { + reactors: new Set(dids), + emoji: emoji, + highlight: Appearance.Default, //TODO + description: "", //TODO + } + }) + return { + id: message.id(), + details: { + at: message.date(), + origin: message.sender(), + remote: remote, + }, + text: message.lines(), + inReplyTo: message.replied() ? null : null, // fetch from ConversationStore + reactions: reactions, + attachments: attachments.map(f => convertWarpAttachment(f)), + pinned: message.pinned(), + type: messageTypeFromTexts(message.lines()), + } + } +} + +export type CommunityPermission = wasm.CommunityPermission +export const CommunityPermission = wasm.CommunityPermission +export type CommunityChannelPermission = wasm.CommunityChannelPermission +export const CommunityChannelPermission = wasm.CommunityChannelPermission +export const CommunitiesStoreInstance = new CommunitiesStore(WarpStore.warp.raygun) diff --git a/src/lib/wasm/RaygunStore.ts b/src/lib/wasm/RaygunStore.ts index a6658ec8a..26e6d24b4 100644 --- a/src/lib/wasm/RaygunStore.ts +++ b/src/lib/wasm/RaygunStore.ts @@ -6,7 +6,7 @@ import { UIStore } from "../state/ui" import { ConversationStore } from "../state/conversation" import { MessageOptions } from "warp-wasm" import { Appearance, ChatType, MessageAttachmentKind, Route } from "$lib/enums" -import { type User, type Chat, defaultChat, type Message, mentions_user, type Attachment, messageTypeFromTexts, type Reaction } from "$lib/types" +import { type User, type Chat, defaultChat, type Message, mentions_user, type Attachment, messageTypeFromTexts, type Reaction, type FileProgress } from "$lib/types" import { WarpError, handleErrors } from "./HandleWarpErrors" import { failure, success, type Result } from "$lib/utils/Result" import { create_cancellable_handler, type Cancellable } from "$lib/utils/CancellablePromise" @@ -212,7 +212,7 @@ class RaygunStore { }, }) let file = new wasm.AttachmentFile("", new wasm.AttachmentStream(len, stream)) - banner ? r.update_conversation_banner(conversation_id, file) : r.update_conversation_icon(conversation_id, file) + return banner ? r.update_conversation_banner(conversation_id, file) : r.update_conversation_icon(conversation_id, file) }, "Error updating conversation icon") } @@ -306,7 +306,7 @@ class RaygunStore { async send(conversation_id: string, message: string[], attachments?: FileAttachment[]): Promise> { return await this.get(async r => { - return await this.sendTo(r, conversation_id, message, attachments) + return await this.sendTo(r, conversation_id, message, { attachments }) }, "Error sending message") } @@ -314,26 +314,28 @@ class RaygunStore { return await this.get(async r => { let sent = [] for (let conversation_id of conversation_ids) { - let res: MultiSendMessageResult = { chat: conversation_id, result: await this.sendTo(r, conversation_id, message, attachments) } + let res: MultiSendMessageResult = { chat: conversation_id, result: await this.sendTo(r, conversation_id, message, { attachments }) } sent.push(res) } return sent }, "Error sending message") } - private async sendTo(raygun: wasm.RayGunBox, conversation_id: string, message: string[], attachments?: FileAttachment[]): Promise { - if (attachments && attachments.length > 0) { + private async sendTo(raygun: wasm.RayGunBox, conversation_id: string, message: string[], settings?: { attachments?: FileAttachment[]; replyTo?: string }): Promise { + if (settings?.attachments && settings?.attachments.length > 0) { + // Check for empty messages + message = message.filter(m => m.length !== 0).length === 0 ? [] : message let result = await raygun .attach( conversation_id, - undefined, - attachments.map(f => new wasm.AttachmentFile(f.file, f.attachment ? new wasm.AttachmentStream(f.attachment[1], f.attachment[0]) : undefined)), + settings?.replyTo, + settings?.attachments.map(f => new wasm.AttachmentFile(f.file, f.attachment ? new wasm.AttachmentStream(f.attachment[1], f.attachment[0]) : undefined)), message ) .then(res => { // message_sent event gets fired AFTER this returns ConversationStore.addPendingMessages(conversation_id, res.get_message_id(), message) - this.createFileAttachHandler(conversation_id, res) + createFileAttachHandler(res, (file, updater) => ConversationStore.updatePendingMessages(conversation_id, res.get_message_id(), file, updater)) return res }) return { @@ -342,7 +344,7 @@ class RaygunStore { } } return { - message: await raygun.send(conversation_id, message).then(messageId => { + message: await (settings?.replyTo ? raygun.reply(conversation_id, settings.replyTo, message) : raygun.send(conversation_id, message)).then(messageId => { // message_sent event gets fired BEFORE this returns // So to // 1. unify this system @@ -361,14 +363,14 @@ class RaygunStore { async downloadAttachment(conversation_id: string, message_id: string, file: string, size?: number) { return await this.get(async r => { let result = await r.download_stream(conversation_id, message_id, file) - return this.createFileDownloadHandler(file, result, size) + return createFileDownloadHandler(file, result, size) }, `Error downloading attachment from ${conversation_id} for message ${message_id}`) } async getAttachmentRaw(conversation_id: string, message_id: string, file: string, options?: { size?: number; type?: string }) { return await this.get(async r => { let result = await r.download_stream(conversation_id, message_id, file) - return this.createFileDownloadHandlerRaw(file, result, options) + return createFileDownloadHandlerRaw(file, result, options) }, `Error downloading attachment from ${conversation_id} for message ${message_id}`) } @@ -385,28 +387,18 @@ class RaygunStore { async reply(conversation_id: string, message_id: string, message: string[], attachments?: FileAttachment[]): Promise> { return await this.get(async r => { - if (attachments && attachments.length > 0) { - let result = await r.attach( - conversation_id, - message_id, - attachments.map(f => new wasm.AttachmentFile(f.file, f.attachment ? new wasm.AttachmentStream(f.attachment[1], f.attachment[0]) : undefined)), - message - ) - return { - message: result.get_message_id(), - progress: result, - } - } - return { - message: await r.reply(conversation_id, message_id, message), - } - }, "Error replying to message") + return await this.sendTo(r, conversation_id, message, { attachments, replyTo: message_id }) + }, "Error sending message") } async sendEvent(conversation_id: string, event: wasm.MessageEvent) { return await this.get(r => r.send_event(conversation_id, event), `Error sending event ${event}`) } + async cancelEvent(conversation_id: string, event: wasm.MessageEvent) { + return await this.get(r => r.cancel_event(conversation_id, event), `Error cancelling event ${event}`) + } + private async handleRaygunEvent(raygun: wasm.RayGunBox) { let events: wasm.AsyncIterator | undefined while (!events) { @@ -436,7 +428,7 @@ class RaygunStore { let conv = await raygun.get_conversation(conversationId) let chat = await this.convertWarpConversation(conv, raygun) let listeners = get(this.messageListeners) - let handler = await this.createConversationEventHandler(raygun, conversationId) + let handler = await this.createMessageEventHandler(raygun, conversationId) listeners[conversationId] = handler this.messageListeners.set(listeners) @@ -464,6 +456,35 @@ class RaygunStore { } break } + case "conversation_archived": + case "conversation_unarchived": { + //TODO + break + } + case "community_created": { + //TODO UI stuff + let communityId: string = event.values["community_id"] + let listeners = get(this.messageListeners) + let handler = await this.createMessageEventHandler(raygun, communityId, true) + listeners[communityId] = handler + this.messageListeners.set(listeners) + + break + } + case "community_deleted": { + let communityId: string = event.values["community_id"] + let listeners = get(this.messageListeners) + if (communityId in listeners) { + listeners[communityId].cancel() + delete listeners[communityId] + this.messageListeners.set(listeners) + } + //TODO UI stuff + break + } + case "community_invited": { + break + } } } } @@ -487,14 +508,19 @@ class RaygunStore { } let handlers: { [key: string]: Cancellable } = {} for (let conversation of conversations.convs()) { - let handler = await this.createConversationEventHandler(raygun, conversation.id()) + let handler = await this.createMessageEventHandler(raygun, conversation.id()) handlers[conversation.id()] = handler } + let communities = await raygun.list_communities_joined() + for (let communitiy of communities) { + let handler = await this.createMessageEventHandler(raygun, communitiy, true) + handlers[communitiy] = handler + } this.messageListeners.set(handlers) } - private async createConversationEventHandler(raygun: wasm.RayGunBox, conversation_id: string) { - let stream = await raygun.get_conversation_stream(conversation_id) + private async createMessageEventHandler(raygun: wasm.RayGunBox, identifier: string, community?: boolean) { + let stream = community ? await raygun.get_community_stream(identifier) : await raygun.get_conversation_stream(identifier) return create_cancellable_handler(async isCancelled => { let listener = { [Symbol.asyncIterator]() { @@ -504,8 +530,12 @@ class RaygunStore { streamLoop: for await (const value of listener) { let event = parseJSValue(value) log.info(`Handling message event: ${JSON.stringify(event)}`) + if (community) { + //TODO UI Hookup etc not implemented + continue + } if (isCancelled()) { - log.debug(`Breaking stream loop not necessary anymore from: ${conversation_id}`) + log.debug(`Breaking stream loop not necessary anymore from: ${identifier}`) break streamLoop } switch (event.type) { @@ -702,109 +732,6 @@ class RaygunStore { return messages } - private async createFileDownloadHandlerRaw(name: string, it: wasm.AsyncIterator, options?: { size?: number; type?: string }): Promise { - let listener = { - [Symbol.asyncIterator]() { - return it - }, - } - let data: any[] = [] - try { - for await (const value of listener) { - data = [...data, ...value] - } - } catch (_) {} - return new File([new Uint8Array(data)], name, { type: options?.type }) - } - - private async createFileDownloadHandler(name: string, it: wasm.AsyncIterator, size?: number) { - let blob = await this.createFileDownloadHandlerRaw(name, it, { size }) - const elem = window.document.createElement("a") - elem.href = window.URL.createObjectURL(blob) - elem.download = name - document.body.appendChild(elem) - elem.click() - document.body.removeChild(elem) - } - - /** - * Create a handler for attachment results that uploads the file to chat and updates pending message attachments - * TODO: verify it works as we dont have a way to upload files yet - */ - private async createFileAttachHandler(conversationId: string, upload: wasm.AttachmentResult) { - let listener = { - [Symbol.asyncIterator]() { - return upload - }, - } - let cancelled = false - try { - for await (const value of listener) { - let event = parseJSValue(value) - log.info(`Handling file progress event: ${JSON.stringify(event)}`) - switch (event.type) { - case "AttachedProgress": { - let locationKind = parseJSValue(event.values[0]) - // Only streams need progress update - if (locationKind.type === "Stream") { - let progress = parseJSValue(event.values[1]) - let file = progress.values["name"] - ConversationStore.updatePendingMessages(conversationId, upload.get_message_id(), file, current => { - if (current) { - let copy = { ...current } - switch (progress.type) { - case "CurrentProgress": { - copy.size = progress.values["current"] - copy.total = progress.values["total"] - break - } - case "ProgressComplete": { - copy.size = progress.values["total"] - copy.total = progress.values["total"] - copy.done = true - break - } - case "ProgressFailed": { - copy.size = progress.values["last_size"] - copy.error = `Error: ${progress.values["error"]}` - break - } - } - return copy - } else if (progress.type === "CurrentProgress") { - return { - name: file, - size: progress.values["current"], - total: progress.values["total"], - cancellation: { - cancel: () => { - cancelled = true - }, - }, - } - } - return undefined - }) - } - break - } - case "Pending": { - if (Object.keys(event.values).length > 0) { - let res = parseJSValue(event.values) - if (res.type === "Err") { - log.error(`Error uploading file ${res.values}`) - } - } - break - } - } - if (cancelled) break - } - } catch (e) { - if (!`${e}`.includes(`Error: returned None`)) throw e - } - } - /** * Convenient helper method to get data from raygun */ @@ -851,35 +778,12 @@ class RaygunStore { text: message.lines(), inReplyTo: message.replied() ? ConversationStore.getMessage(conversation_id, message.replied()!) : null, reactions: reactions, - attachments: attachments.map(f => this.convertWarpAttachment(f)), + attachments: attachments.map(f => convertWarpAttachment(f)), pinned: message.pinned(), type: messageTypeFromTexts(message.lines()), } } - private convertWarpAttachment(attachment: wasm.File): Attachment { - let kind: MessageAttachmentKind = MessageAttachmentKind.File - let type = attachment.file_type() - let mime = "application/octet-stream" - if (type !== "Generic") { - mime = type.Mime - } - if (mime.startsWith("image")) { - kind = MessageAttachmentKind.Image - } else if (mime.startsWith("video")) { - kind = MessageAttachmentKind.Video - } - let thumbnail = attachment.thumbnail() - let location = thumbnail.length > 0 ? imageFromData(thumbnail, type) : "" - return { - kind: kind, - name: attachment.name(), - size: attachment.size(), - location: location, - mime: mime, - } - } - /** * Converts warp message to ui message */ @@ -916,6 +820,133 @@ class RaygunStore { } } +export async function createFileDownloadHandlerRaw(name: string, it: wasm.AsyncIterator, options?: { size?: number; type?: string }): Promise { + let listener = { + [Symbol.asyncIterator]() { + return it + }, + } + let data: any[] = [] + try { + for await (const value of listener) { + data = [...data, ...value] + } + } catch (_) {} + return new File([new Uint8Array(data)], name, { type: options?.type }) +} + +export async function createFileDownloadHandler(name: string, it: wasm.AsyncIterator, size?: number) { + let blob = await createFileDownloadHandlerRaw(name, it, { size }) + const elem = window.document.createElement("a") + elem.href = window.URL.createObjectURL(blob) + elem.download = name + document.body.appendChild(elem) + elem.click() + document.body.removeChild(elem) +} + +/** + * Create a handler for attachment results that uploads the file to chat and updates pending message attachments + * TODO: verify it works as we dont have a way to upload files yet + */ +export type ProgressHandler = (progress: FileProgress | undefined) => FileProgress | undefined +export async function createFileAttachHandler(upload: wasm.AttachmentResult, updater: (name: string, handler: ProgressHandler) => void) { + let listener = { + [Symbol.asyncIterator]() { + return upload + }, + } + let cancelled = false + try { + for await (const value of listener) { + let event = parseJSValue(value) + log.info(`Handling file progress event: ${JSON.stringify(event)}`) + switch (event.type) { + case "AttachedProgress": { + let locationKind = parseJSValue(event.values[0]) + // Only streams need progress update + if (locationKind.type === "Stream") { + let progress = parseJSValue(event.values[1]) + let file = progress.values["name"] + updater(file, current => { + if (current) { + let copy = { ...current } + switch (progress.type) { + case "CurrentProgress": { + copy.size = progress.values["current"] + copy.total = progress.values["total"] + break + } + case "ProgressComplete": { + copy.size = progress.values["total"] + copy.total = progress.values["total"] + copy.done = true + break + } + case "ProgressFailed": { + copy.size = progress.values["last_size"] + copy.error = `Error: ${progress.values["error"]}` + break + } + } + return copy + } else if (progress.type === "CurrentProgress") { + return { + name: file, + size: progress.values["current"], + total: progress.values["total"], + cancellation: { + cancel: () => { + cancelled = true + }, + }, + } + } + return undefined + }) + } + break + } + case "Pending": { + if (Object.keys(event.values).length > 0) { + let res = parseJSValue(event.values) + if (res.type === "Err") { + log.error(`Error uploading file ${res.values}`) + } + } + break + } + } + if (cancelled) break + } + } catch (e) { + if (!`${e}`.includes(`Error: returned None`)) throw e + } +} + +export function convertWarpAttachment(attachment: wasm.File): Attachment { + let kind: MessageAttachmentKind = MessageAttachmentKind.File + let type = attachment.file_type() + let mime = "application/octet-stream" + if (type !== "Generic") { + mime = type.Mime + } + if (mime.startsWith("image")) { + kind = MessageAttachmentKind.Image + } else if (mime.startsWith("video")) { + kind = MessageAttachmentKind.Video + } + let thumbnail = attachment.thumbnail() + let location = thumbnail.length > 0 ? imageFromData(thumbnail, type) : "" + return { + kind: kind, + name: attachment.name(), + size: attachment.size(), + location: location, + mime: mime, + } +} + export type GroupPermission = wasm.GroupPermission export const GroupPermission = wasm.GroupPermission export const RaygunStoreInstance = new RaygunStore(WarpStore.warp.raygun) From 047471f068583f13f110d6013cad27b974d774ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Gon=C3=A7alves=20Marchi?= Date: Tue, 17 Dec 2024 16:20:32 -0300 Subject: [PATCH 2/4] feat(share): add share functionality for backup seed phrase on mobile (#942) --- android/app/capacitor.build.gradle | 1 + android/capacitor.settings.gradle | 3 +++ ios/App/Podfile | 1 + ios/App/Podfile.lock | 8 ++++++- package.json | 3 ++- src/lib/lang/en.json | 1 + src/lib/layouts/login/RecoveryCopy.svelte | 27 ++++++++++++++++++++--- 7 files changed, 39 insertions(+), 5 deletions(-) diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 43c29cf84..549fb4e30 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -15,6 +15,7 @@ dependencies { implementation project(':capacitor-filesystem') implementation project(':capacitor-keyboard') implementation project(':capacitor-screen-orientation') + implementation project(':capacitor-share') implementation project(':capacitor-splash-screen') implementation project(':capacitor-status-bar') } diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 7f6f655ac..16ae80873 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -20,6 +20,9 @@ project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor include ':capacitor-screen-orientation' project(':capacitor-screen-orientation').projectDir = new File('../node_modules/@capacitor/screen-orientation/android') +include ':capacitor-share' +project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android') + include ':capacitor-splash-screen' project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android') diff --git a/ios/App/Podfile b/ios/App/Podfile index 9d8a50a01..1d119b985 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -17,6 +17,7 @@ def capacitor_pods pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem' pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard' pod 'CapacitorScreenOrientation', :path => '../../node_modules/@capacitor/screen-orientation' + pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share' pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen' pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' end diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index 488b84c88..25e270a26 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -14,6 +14,8 @@ PODS: - Capacitor - CapacitorScreenOrientation (6.0.3): - Capacitor + - CapacitorShare (6.0.3): + - Capacitor - CapacitorSplashScreen (6.0.3): - Capacitor - CapacitorStatusBar (6.0.2): @@ -28,6 +30,7 @@ DEPENDENCIES: - "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)" - "CapacitorKeyboard (from `../../node_modules/@capacitor/keyboard`)" - "CapacitorScreenOrientation (from `../../node_modules/@capacitor/screen-orientation`)" + - "CapacitorShare (from `../../node_modules/@capacitor/share`)" - "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)" - "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)" @@ -48,6 +51,8 @@ EXTERNAL SOURCES: :path: "../../node_modules/@capacitor/keyboard" CapacitorScreenOrientation: :path: "../../node_modules/@capacitor/screen-orientation" + CapacitorShare: + :path: "../../node_modules/@capacitor/share" CapacitorSplashScreen: :path: "../../node_modules/@capacitor/splash-screen" CapacitorStatusBar: @@ -62,9 +67,10 @@ SPEC CHECKSUMS: CapacitorFilesystem: c832a3f6d4870c3872688e782ae8e33665e6ecbf CapacitorKeyboard: 460c6f9ec5e52c84f2742d5ce2e67bbc7ab0ebb0 CapacitorScreenOrientation: 3bb823f5d265190301cdc5d58a568a287d98972a + CapacitorShare: 7af6ca761ce62030e8e9fbd2eb82416f5ceced38 CapacitorSplashScreen: 68893659d77b5f82d753b3a70475082845e3039c CapacitorStatusBar: 3b9ac7d0684770522c532d1158a1434512ab1477 -PODFILE CHECKSUM: 97c46b79f9ec807c302bf24e1511e3e277306740 +PODFILE CHECKSUM: cecd7e9afdf00b54ffac291bfc0edab8a2ebdc11 COCOAPODS: 1.16.2 diff --git a/package.json b/package.json index a4c025376..969d08918 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "@capacitor/ios": "^6.2.0", "@capacitor/keyboard": "^6.0.3", "@capacitor/screen-orientation": "^6.0.3", - "@capacitor/splash-screen": "^6.0.2", + "@capacitor/share": "^6.0.3", + "@capacitor/splash-screen": "^6.0.3", "@capacitor/status-bar": "^6.0.2", "@dicebear/collection": "^9.0.1", "@dicebear/core": "^9.0.1", diff --git a/src/lib/lang/en.json b/src/lib/lang/en.json index 0ce862b24..923d5e7c8 100644 --- a/src/lib/lang/en.json +++ b/src/lib/lang/en.json @@ -256,6 +256,7 @@ "title": "Backup your seed!", "save_warning": "Please ensure you write down this message with all words recorded in the order they appear. It can be helpful to write down the numbers along with the words.", "download": "Download Backup", + "share": "Share Backup Seed Phrase", "next_step": "Next Step" }, "new_account": { diff --git a/src/lib/layouts/login/RecoveryCopy.svelte b/src/lib/layouts/login/RecoveryCopy.svelte index 312172b05..7dba89847 100644 --- a/src/lib/layouts/login/RecoveryCopy.svelte +++ b/src/lib/layouts/login/RecoveryCopy.svelte @@ -1,13 +1,15 @@
From dba53eb8075b028f11c364208de2fbc3e495fe6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Gon=C3=A7alves=20Marchi?= Date: Tue, 17 Dec 2024 19:23:35 -0300 Subject: [PATCH 3/4] fix(ContextMenu): Fix context menu on iOS with long press (#937) --- src/lib/components/ui/ContextMenu.svelte | 84 ++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/src/lib/components/ui/ContextMenu.svelte b/src/lib/components/ui/ContextMenu.svelte index 42b09ac61..3ac4a2742 100644 --- a/src/lib/components/ui/ContextMenu.svelte +++ b/src/lib/components/ui/ContextMenu.svelte @@ -10,7 +10,7 @@ import { clickoutside } from "@svelte-put/clickoutside" import { Appearance } from "$lib/enums" import type { ContextItem } from "$lib/types" - import { createEventDispatcher, onMount, tick } from "svelte" + import { createEventDispatcher, onDestroy, onMount, tick } from "svelte" import { log } from "$lib/utils/Logger" import type { PluginListenerHandle } from "@capacitor/core" import { isAndroidOriOS } from "$lib/utils/Mobile" @@ -18,12 +18,16 @@ let visible: boolean = false let coords: [number, number] = [0, 0] let context: HTMLElement + let slotContainer: HTMLElement export let items: ContextItem[] = [] export let hook: string = "" const dispatch = createEventDispatcher() function onClose(event: CustomEvent | MouseEvent) { + if (isLongPress) { + return + } visible = false dispatch("close", event) close_context = undefined @@ -34,9 +38,9 @@ const { width, height } = context.getBoundingClientRect() const offsetX = evt.pageX - const offsetY = evt.pageY - keyboardHeight / 2.5 + const offsetY = evt.pageY const screenWidth = evt.view!.innerWidth - const screenHeight = evt.view!.innerHeight + const screenHeight = evt.view!.innerHeight - keyboardHeight const overFlowX = screenWidth < width + offsetX const overFlowY = screenHeight < height + offsetY @@ -54,13 +58,18 @@ if (close_context !== undefined) { close_context() } - close_context = () => (visible = false) + close_context = () => { + if (!isLongPress) { + visible = false + } + } evt.preventDefault() - visible = true coords = [evt.clientX, evt.clientY] + visible = true await tick() coords = calculatePos(evt) } + let keyboardHeight = 0 onMount(() => { let mobileKeyboardListener01: PluginListenerHandle | undefined @@ -85,6 +94,46 @@ } }) + let touchTimer: number | undefined + let isLongPress: boolean = false + + function handleTouchStart(evt: TouchEvent) { + if (evt.touches.length === 1) { + isLongPress = false + let longPressElement = evt.target as HTMLElement + longPressElement.style.pointerEvents = "none" + touchTimer = window.setTimeout(() => { + const touch = evt.touches[0] + const mouseEvent = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + view: window, + clientX: touch.clientX, + clientY: touch.clientY, + }) + isLongPress = true + openContext(mouseEvent) + }, 500) + } + } + + function handleTouchEnd(evt: TouchEvent) { + clearTimeout(touchTimer) + let longPressElement = evt.target as HTMLElement + longPressElement.style.pointerEvents = "" + if (isLongPress) { + evt.preventDefault() + } + setTimeout(() => { + isLongPress = false + }, 100) + } + + function handleTouchMove(evt: TouchEvent) { + clearTimeout(touchTimer) + isLongPress = false + } + function handleItemClick(e: MouseEvent, item: ContextItem) { e.stopPropagation() log.info(`Clicked ${item.text}`) @@ -94,9 +143,23 @@ }) onClose(customEvent) } + + onMount(() => { + slotContainer.addEventListener("touchstart", handleTouchStart) + slotContainer.addEventListener("touchend", handleTouchEnd) + slotContainer.addEventListener("touchmove", handleTouchMove) + }) + + onDestroy(() => { + slotContainer.removeEventListener("touchstart", handleTouchStart) + slotContainer.removeEventListener("touchend", handleTouchEnd) + slotContainer.removeEventListener("touchmove", handleTouchMove) + }) - +
+ +
{#if visible}
@@ -115,6 +178,15 @@ {/if}