[READ-ONLY] Mirror of https://github.com/flo-bit/jazz-group-chat. jazz-group-chat.vercel.app/
0

Configure Feed

Select the types of activity you want to include in your feed.

add image support

Florian (May 25, 2025, 1:39 AM +0200) cea4e86c 6d419ff8

+233 -101
+1
package-lock.json
··· 23 23 "@tiptap/extension-underline": "^2.12.0", 24 24 "@tiptap/starter-kit": "^2.12.0", 25 25 "@use-gesture/vanilla": "^10.3.1", 26 + "bits-ui": "^1.7.0", 26 27 "jazz-browser-media-images": "^0.14.8", 27 28 "jazz-inspector-element": "^0.14.0", 28 29 "jazz-richtext-tiptap": "^0.1.15",
+1
package.json
··· 51 51 "@tiptap/extension-underline": "^2.12.0", 52 52 "@tiptap/starter-kit": "^2.12.0", 53 53 "@use-gesture/vanilla": "^10.3.1", 54 + "bits-ui": "^1.7.0", 54 55 "jazz-browser-media-images": "^0.14.8", 55 56 "jazz-inspector-element": "^0.14.0", 56 57 "jazz-richtext-tiptap": "^0.1.15",
+60 -5
src/lib/components/ChatInput.svelte
··· 1 1 <script lang="ts"> 2 2 import { Button, Prose } from '@fuxui/base'; 3 3 import { RichTextEditor } from './rich-text-editor'; 4 - import type { Loaded } from 'jazz-tools'; 5 - import { MyAppProfile, type Message, type MyAppAccount } from '$lib/schema'; 4 + import { type Loaded } from 'jazz-tools'; 5 + import { MyAppProfile, type Message, type MyAppAccount, type ImageList } from '$lib/schema'; 6 6 import { CoState } from 'jazz-svelte'; 7 7 import { useCurrentRoute } from '$lib/context'; 8 + import Image from './Image.svelte'; 9 + import { createImage } from 'jazz-browser-media-images'; 10 + import { publicGroup } from '$lib/utils'; 8 11 9 12 const route = useCurrentRoute(); 10 13 ··· 13 16 handleSubmit, 14 17 me, 15 18 clickJoinSpace, 16 - replyTo = $bindable(null) 19 + replyTo = $bindable(null), 20 + images = $bindable([]) 17 21 }: { 18 22 value: string; 19 23 handleSubmit: () => void; 20 24 me: Loaded<typeof MyAppAccount>; 21 25 clickJoinSpace: () => void; 22 26 replyTo: Loaded<typeof Message> | null; 27 + images: Loaded<typeof ImageList>; 23 28 } = $props(); 24 29 25 30 let profile = $derived(new CoState(MyAppProfile, replyTo?._edits.content?.by?.profile?.id)); 31 + 32 + let processingImage = $state(false); 33 + 34 + async function processImageFile(file: File) { 35 + if (!images) return; 36 + processingImage = true; 37 + const image = await createImage(file, { 38 + owner: publicGroup(), 39 + maxSize: 1024 40 + }); 41 + 42 + images.push(image); 43 + processingImage = false; 44 + } 26 45 </script> 27 46 28 47 <div 29 - class="dark:bg-base-900 border-base-200 dark:border-base-800 fixed right-2 bottom-0 left-2 rounded-t-2xl border-x border-t bg-white px-1 shadow-lg lg:left-76" 48 + class="dark:bg-base-900 border-base-200 dark:border-base-800 fixed right-2 bottom-0 left-2 z-20 rounded-t-2xl border-x border-t bg-white px-1 shadow-lg lg:left-76" 30 49 > 31 50 {#if me?.root?.joinedSpaces?.some((space) => space?.id === route.spaceId)} 32 51 {#if replyTo} ··· 69 88 </Button> 70 89 </div> 71 90 {/if} 72 - <Prose class="px-4" size="md"> 91 + 92 + {#if images && images.length > 0} 93 + <div class="flex gap-2 px-2 pt-2 [&_img]:size-24 [&_img]:rounded-2xl"> 94 + {#each images as image} 95 + <div class="relative"> 96 + <Image {image} class="size-24 rounded-2xl object-cover" /> 97 + <Button 98 + size="iconSm" 99 + variant="ghost" 100 + class="absolute top-0 right-0" 101 + onclick={() => { 102 + images = images.filter((i) => i?.id !== image?.id); 103 + }} 104 + > 105 + <svg 106 + xmlns="http://www.w3.org/2000/svg" 107 + fill="none" 108 + viewBox="0 0 24 24" 109 + stroke-width="1.5" 110 + stroke="currentColor" 111 + class="size-4" 112 + > 113 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 114 + </svg> 115 + </Button> 116 + </div> 117 + {/each} 118 + </div> 119 + {/if} 120 + {#if processingImage} 121 + <div class="text-xs text-base-600 dark:text-base-400 px-2 pt-2"> 122 + Processing image... 123 + </div> 124 + {/if} 125 + 126 + <Prose class="px-2" size="md"> 73 127 <RichTextEditor 74 128 bind:htmlContent={value} 75 129 onEnter={handleSubmit} 76 130 class="max-h-[30dvh] overflow-y-auto" 131 + {processImageFile} 77 132 /> 78 133 </Prose> 79 134 {:else}
+13 -3
src/lib/components/ChatMessage.svelte
··· 1 1 <script lang="ts"> 2 - import type { Loaded } from 'jazz-tools'; 2 + import { ImageDefinition, type Loaded } from 'jazz-tools'; 3 3 import { Message, MyAppAccount, MyAppProfile, Reaction, Space } from '$lib/schema'; 4 4 import { Avatar, cn, Prose } from '@fuxui/base'; 5 5 import { CoState } from 'jazz-svelte'; ··· 11 11 import ChatMessageThread from './ChatMessageThread.svelte'; 12 12 import { useCurrentRoute } from '$lib/context'; 13 13 import { view } from '../../routes/[spaceId]/view.svelte'; 14 + import Image from './Image.svelte'; 14 15 15 16 let { 16 17 message, ··· 125 126 126 127 <div 127 128 class={cn( 128 - 'flex w-full max-w-full flex-col gap-2 py-1 select-text relative', 129 + 'relative flex w-full max-w-full flex-col gap-2 py-1 select-text', 129 130 !isSameUser && showDivider 130 131 ? 'border-base-200/70 dark:border-base-900/50 border-t pt-2 pb-2' 131 132 : '-my-1.5' ··· 136 137 {/if} 137 138 138 139 {#if view.highlightedMessage === message.id} 139 - <div class="absolute top-1 -left-1 -right-1 bottom-2 bg-accent-500/10 dark:bg-accent-500/5 rounded-2xl"></div> 140 + <div 141 + class="bg-accent-500/10 dark:bg-accent-500/5 absolute top-1 -right-1 bottom-2 -left-1 rounded-2xl" 142 + ></div> 140 143 {/if} 141 144 142 145 <div ··· 188 191 {/if} 189 192 </div> 190 193 194 + {#if message.images && message.images.length > 0} 195 + <div class="flex flex-wrap gap-2 ml-12 mb-4"> 196 + {#each message.images as image} 197 + <Image {image} class="max-w-36 max-h-36 rounded-2xl border border-base-200/70 dark:border-base-900/50 object-contain" /> 198 + {/each} 199 + </div> 200 + {/if} 191 201 {#if showThread && message.thread} 192 202 <ChatMessageThread 193 203 threadId={message.thread}
+1 -1
src/lib/components/ChatMessageThread.svelte
··· 59 59 <span class="absolute inset-0"></span> 60 60 {thread.current?.name}</a 61 61 > 62 - ({thread.current?.timeline?.filter((m) => !m?.softDeleted).length} messages) 62 + <!-- ({thread.current?.timeline?.filter((m) => !m?.softDeleted).length} messages) --> 63 63 64 64 {#if lastMessage} 65 65 <span class="text-base-600 dark:text-base-400 text-xs">
+38
src/lib/components/Image.svelte
··· 1 + <script lang="ts"> 2 + import { ImageDefinition, type Loaded } from 'jazz-tools'; 3 + import { type Image } from '$lib/schema'; 4 + import { onMount } from 'svelte'; 5 + 6 + let { 7 + image, 8 + class: className, 9 + ...rest 10 + }: { image?: Loaded<typeof Image>; class?: string } = $props(); 11 + 12 + onMount(() => {}); 13 + 14 + let loaded = $state(false); 15 + 16 + $effect(() => { 17 + if (loaded) return; 18 + if (!image) return; 19 + if (!imageRef) return; 20 + 21 + loaded = true; 22 + const highestRes = ImageDefinition.highestResAvailable(image); 23 + 24 + if (!highestRes) return; 25 + 26 + const blob = highestRes.stream.toBlob(); 27 + 28 + if (!blob) return; 29 + 30 + const url = URL.createObjectURL(blob); 31 + imageRef.src = url; 32 + imageRef.onload = () => URL.revokeObjectURL(url); 33 + }); 34 + 35 + let imageRef = $state<HTMLImageElement | null>(null); 36 + </script> 37 + 38 + <img class={className} bind:this={imageRef} {...rest} />
+1 -1
src/lib/components/ReplyMessage.svelte
··· 22 22 {#if message.current} 23 23 <div 24 24 class={cn( 25 - 'group bg-base-200/50 pointer-events-none dark:bg-base-900/40 border-base-300/50 dark:border-base-800/30 text-base-700 dark:text-base-300', 25 + 'group isolate bg-base-200/50 pointer-events-none dark:bg-base-900/40 border-base-300/50 dark:border-base-800/30 text-base-700 dark:text-base-300', 26 26 'relative inline-flex w-full max-w-full min-w-0 items-center justify-start gap-3 overflow-x-hidden rounded-2xl border px-3 py-0.5 text-xs' 27 27 )} 28 28 >
+19 -5
src/lib/components/TimelineView.svelte
··· 6 6 import { page } from '$app/state'; 7 7 import { setContext } from 'svelte'; 8 8 import { view } from '../../routes/[spaceId]/view.svelte'; 9 + import { ScrollArea } from '@fuxui/base'; 9 10 10 11 let viewport: HTMLDivElement = $state(null!); 11 12 let virtualizer: Virtualizer<string> | undefined = $state(); ··· 54 55 }); 55 56 </script> 56 57 57 - <div 58 - class="absolute inset-0 left-0 h-screen overflow-y-scroll px-4 py-24 lg:left-80" 59 - bind:this={viewport} 58 + <ScrollArea 59 + class="absolute inset-0 left-0 h-[100dhv] overflow-y-scroll px-4 lg:left-80" 60 + bind:ref={viewport} 60 61 > 62 + <div class="h-24 w-full"></div> 63 + 61 64 {#if timeline && timeline.filter((m) => !m.softDeleted).length > 0} 62 - <Virtualizer bind:this={virtualizer} data={timeline} getKey={(k, _) => k} overscan={10}> 65 + <Virtualizer 66 + bind:this={virtualizer} 67 + data={timeline} 68 + getKey={(k, _) => k} 69 + overscan={10} 70 + scrollRef={viewport} 71 + startMargin={96} 72 + > 63 73 {#snippet children(message: Loaded<typeof Message>, index: number)} 64 74 {@const previousMessage = index > 0 ? timeline[index - 1] : undefined} 65 75 {#if message && !message.softDeleted} ··· 75 85 {/if} 76 86 {/snippet} 77 87 </Virtualizer> 88 + 89 + <div class="h-32 w-full"></div> 90 + {:else if timeline === undefined} 91 + <div class="text-base-600 dark:text-base-400 h-30 text-sm">Loading chat...</div> 78 92 {:else} 79 93 <div class="text-base-600 dark:text-base-400 h-30 text-sm"> 80 94 No messages yet. Be the first to send a message! 81 95 </div> 82 96 {/if} 83 - </div> 97 + </ScrollArea>
+58 -75
src/lib/components/rich-text-editor/RichTextEditor.svelte
··· 7 7 import Underline from '@tiptap/extension-underline'; 8 8 import Typography from '@tiptap/extension-typography'; 9 9 import { RichTextLink } from './RichTextLink'; 10 - import { cn } from '@fuxui/base'; 10 + import { Button, cn } from '@fuxui/base'; 11 11 import { ImageUploadNode } from './image-upload/ImageUploadNode'; 12 12 import { initKeyboardShortcutHandler } from './onEnter'; 13 + import { ImageList } from '$lib/schema'; 14 + import type { Loaded } from 'jazz-tools'; 15 + import { createImage } from 'jazz-browser-media-images'; 16 + import { publicGroup } from '$lib/utils'; 17 + import { Portal } from 'bits-ui'; 13 18 14 19 let { 15 20 content = $bindable({}), ··· 19 24 class: className, 20 25 onupdate, 21 26 htmlContent = $bindable(''), 22 - onEnter, 27 + processImageFile, 28 + onEnter 23 29 }: { 24 30 content?: Content; 25 31 placeholder?: string; ··· 30 36 ontransaction?: () => void; 31 37 htmlContent?: string; 32 38 onEnter: () => void; 39 + processImageFile: (file: File) => void; 33 40 } = $props(); 34 41 35 42 let hasFocus = true; ··· 57 64 return ''; 58 65 } 59 66 }), 60 - // Image.configure({ 61 - // HTMLAttributes: { 62 - // class: 'max-w-full object-contain relative rounded-2xl' 63 - // }, 64 - // allowBase64: true 65 - // }), 66 67 Underline.configure({}), 67 68 RichTextLink.configure({ 68 69 openOnClick: false, ··· 70 71 defaultProtocol: 'https' 71 72 }), 72 73 Typography.configure(), 73 - // ImageUploadNode.configure({ 74 - // upload: async (file, onProgress, abortSignal) => { 75 - // console.log('uploading image', file); 76 - // // wait 2 seconds 77 - // for(let i = 0; i < 10; i++) { 78 - // await new Promise((resolve) => setTimeout(resolve, 200)); 79 - // onProgress?.({ progress: i / 10 }); 80 - // } 81 - 82 - // return 'https://picsum.photos/200/300'; 83 - // } 84 - // }), 85 - initKeyboardShortcutHandler({ onEnter: onEnter }), 74 + initKeyboardShortcutHandler({ onEnter: onEnter }) 86 75 ]; 87 76 88 77 editor = new Editor({ ··· 106 95 }, 107 96 content: `` 108 97 }); 109 - 110 98 }); 111 99 112 - // Flag to track whether a file is being dragged over the drop area 113 100 let isDragOver = $state(false); 114 101 115 - // Store local image files for later upload 116 - let localImages: Map<string, File> = $state(new Map()); 117 - 118 - // Track which image URLs in the editor are local previews 119 - let localImageUrls: Set<string> = $state(new Set()); 120 - 121 - // Process image file to create a local preview 122 - async function processImageFile(file: File) { 123 - if (!editor) { 124 - console.warn('Tiptap editor not initialized'); 125 - return; 126 - } 127 - 128 - try { 129 - const localUrl = URL.createObjectURL(file); 130 - 131 - localImages.set(localUrl, file); 132 - localImageUrls.add(localUrl); 133 - 134 - //editor.commands.setImageUploadNode(); 135 - editor.chain().focus().setImageUploadNode( 136 - { 137 - preview: localUrl 138 - } 139 - ).run(); 140 - 141 - // wait 2 seconds 142 - // await new Promise((resolve) => setTimeout(resolve, 500)); 143 - 144 - // content = editor.getJSON(); 145 - 146 - // console.log('replacing image url in content'); 147 - // replaceImageUrlInContent(content, localUrl, 'https://picsum.photos/200/300'); 148 - // editor.commands.setContent(content); 149 - } catch (error) { 150 - console.error('Error creating image preview:', error); 151 - } 152 - } 153 - 154 102 const handlePaste = (event: ClipboardEvent) => { 155 103 const items = event.clipboardData?.items; 156 104 if (!items) return; ··· 169 117 event.preventDefault(); 170 118 event.stopPropagation(); 171 119 isDragOver = true; 120 + console.log('drag over'); 172 121 } 173 122 function handleDragLeave(event: DragEvent) { 174 123 event.preventDefault(); 175 124 event.stopPropagation(); 176 125 isDragOver = false; 126 + console.log('drag leave'); 177 127 } 178 128 function handleDrop(event: DragEvent) { 179 129 event.preventDefault(); ··· 184 134 if (file?.type.startsWith('image/')) { 185 135 processImageFile(file); 186 136 } 137 + console.log('drop'); 187 138 } 188 139 189 140 onDestroy(() => { 190 - for (const localUrl of localImageUrls) { 191 - URL.revokeObjectURL(localUrl); 192 - } 193 - 194 141 editor?.destroy(); 195 142 }); 196 143 </script> 197 144 198 - <div 199 - bind:this={ref} 200 - class={cn('relative flex-1', className)} 201 - role="region" 202 - ></div> 145 + <svelte:window ondragover={handleDragOver} ondragleave={handleDragLeave} ondrop={handleDrop} /> 146 + 147 + {#if isDragOver} 148 + <Portal> 149 + <div 150 + class="bg-base-300/50 dark:bg-base-800/50 backdrop-blur-md text-base-900 dark:text-base-100 pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl z-[1000]" 151 + > 152 + Drop image to add it to your message 153 + </div> 154 + </Portal> 155 + {/if} 203 156 204 - <!-- onpaste={handlePaste} 205 - ondragover={handleDragOver} 206 - ondragleave={handleDragLeave} 207 - ondrop={handleDrop} --> 157 + <div class={cn('relative flex items-center', className)}> 158 + <Button 159 + size="icon" 160 + variant="ghost" 161 + class="mr-2" 162 + onclick={() => { 163 + // file upload 164 + const input = document.createElement('input'); 165 + input.type = 'file'; 166 + input.accept = 'image/*'; 167 + input.onchange = (event) => { 168 + const file = (event.target as HTMLInputElement).files?.[0]; 169 + if (!file) return; 170 + processImageFile(file); 171 + input.remove(); 172 + }; 173 + input.click(); 174 + }} 175 + > 176 + <svg 177 + xmlns="http://www.w3.org/2000/svg" 178 + fill="none" 179 + viewBox="0 0 24 24" 180 + stroke-width="1.5" 181 + stroke="currentColor" 182 + class="size-6" 183 + > 184 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 185 + </svg> 186 + </Button> 187 + <div bind:this={ref} onpaste={handlePaste}></div> 188 + </div> 189 + 190 + <!-- --> 208 191 209 192 <style> 210 193 :global(.tiptap) {
+5 -1
src/lib/schema.ts
··· 5 5 emoji: z.string() 6 6 }); 7 7 8 + export const ImageList = co.list(co.image()); 9 + 10 + export const Image = co.image(); 11 + 8 12 export const Message = co.map({ 9 13 content: co.richText(), 10 - images: co.list(co.image()), 14 + images: ImageList, 11 15 12 16 createdAt: z.date(), 13 17 updatedAt: z.date(),
+22 -5
src/routes/[spaceId]/channel/[channelId]/+page.svelte
··· 45 45 const me = new AccountCoState(MyAppAccount, { 46 46 resolve: { 47 47 profile: true, 48 - root: true 48 + root: { 49 + joinedSpaces: true 50 + } 49 51 } 50 52 }); 51 53 ··· 106 108 owner: publicGroup() 107 109 }); 108 110 111 + console.log('submit'); 112 + 113 + console.log('postImages', postImages, postImages.length); 114 + 109 115 // add message to channel 110 116 const message = Message.create( 111 117 { 112 118 content: newContent, 113 119 createdAt: new Date(), 114 120 updatedAt: new Date(), 115 - images: co.list(co.image()).create([], { 121 + images: co.list(co.image()).create([...postImages], { 116 122 owner: publicGroup() 117 123 }), 118 124 reactions: co.list(Reaction).create([], { ··· 125 131 owner: publicGroup() 126 132 } 127 133 ); 134 + 135 + postImages = []; 128 136 129 137 replyTo = null; 130 138 ··· 150 158 joinSpace(toJoinSpace); 151 159 me?.current?.root?.joinedSpaces?.unshift(toJoinSpace); 152 160 153 - console.log('joined space', toJoinSpace); 161 + console.log('joined space', toJoinSpace, me?.current?.root?.joinedSpaces); 154 162 } 155 163 156 164 let input = $state(''); ··· 165 173 let threadMessage = $state<Loaded<typeof Message> | null>(null); 166 174 let threadName = $state(''); 167 175 176 + let postImages = $state([]); 177 + 168 178 function createThread() { 169 179 if (!threadMessage) { 170 180 console.error('threadMessage is null'); ··· 194 204 {#if view.active === 'channel'} 195 205 <TimelineView 196 206 timeline={channel.current?.mainThread?.timeline} 197 - setReplyTo={setReplyTo} 207 + {setReplyTo} 198 208 me={me?.current} 199 209 createThread={(message) => { 200 210 threadMessage = message; ··· 202 212 }} 203 213 /> 204 214 205 - <ChatInput bind:replyTo me={me?.current} {handleSubmit} {clickJoinSpace} bind:value={input} /> 215 + <ChatInput 216 + bind:replyTo 217 + me={me?.current} 218 + {handleSubmit} 219 + {clickJoinSpace} 220 + bind:value={input} 221 + bind:images={postImages} 222 + /> 206 223 {:else} 207 224 <div class="flex w-full flex-col gap-2"> 208 225 {#each channel.current?.subThreads ?? [] as thread}
+14 -5
src/routes/[spaceId]/channel/[channelId]/thread/[threadId]/+page.svelte
··· 11 11 12 12 const route = useCurrentRoute(); 13 13 14 - 15 14 let space = $derived( 16 15 new CoState(Space, route.spaceId, { 17 16 resolve: { ··· 30 29 } 31 30 }); 32 31 33 - 34 32 let thread = $derived( 35 33 new CoState(Thread, route.threadId, { 36 34 resolve: { ··· 91 89 content: newContent, 92 90 createdAt: new Date(), 93 91 updatedAt: new Date(), 94 - images: co.list(co.image()).create([], { 92 + images: co.list(co.image()).create([...postImages], { 95 93 owner: publicGroup() 96 94 }), 97 95 reactions: co.list(Reaction).create([], { ··· 105 103 } 106 104 ); 107 105 106 + postImages = []; 107 + 108 108 replyTo = null; 109 109 110 110 thread.current?.timeline?.push(message); ··· 138 138 function setReplyTo(message: Loaded<typeof Message>) { 139 139 replyTo = message; 140 140 } 141 + 142 + let postImages = $state([]); 141 143 </script> 142 144 143 145 <TimelineView 144 146 timeline={thread.current?.timeline} 145 - setReplyTo={setReplyTo} 147 + {setReplyTo} 146 148 me={me?.current} 147 149 createThread={() => {}} 148 150 allowThreadCreation={false} 149 151 showThread={false} 150 152 /> 151 153 152 - <ChatInput bind:replyTo me={me?.current} {handleSubmit} {clickJoinSpace} bind:value={input} /> 154 + <ChatInput 155 + bind:replyTo 156 + me={me?.current} 157 + {handleSubmit} 158 + {clickJoinSpace} 159 + bind:value={input} 160 + bind:images={postImages} 161 + />