[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 threads

Florian (May 24, 2025, 4:06 AM +0200) b4bbc1bc ba9bdc77

+547 -146
+1 -1
src/app.html
··· 44 44 data-domains="flo-bit.dev" 45 45 ></script> 46 46 </head> 47 - <body data-sveltekit-preload-data="hover" class="bg-base-50 dark:bg-base-950 touch-none select-none"> 47 + <body data-sveltekit-preload-data="hover" class="bg-base-50 dark:bg-base-950"> 48 48 <div style="display: contents">%sveltekit.body%</div> 49 49 </body> 50 50 </html>
+7 -6
src/lib/components/ChannelButton.svelte
··· 2 2 import { base } from '$app/paths'; 3 3 import { page } from '$app/state'; 4 4 import { Channel } from '$lib/schema'; 5 - import { Badge, Button } from '@fuxui/base'; 5 + import { Button } from '@fuxui/base'; 6 6 import type { Loaded } from 'jazz-tools'; 7 + import { useCurrentRoute } from '$lib/context'; 8 + 9 + const route = useCurrentRoute(); 7 10 8 11 let { 9 12 channel, 10 - spaceId, 11 13 lastReadDate, 12 14 onclick 13 15 }: { 14 16 channel: Loaded<typeof Channel>; 15 - spaceId: string; 16 17 lastReadDate?: Date; 17 18 onclick?: () => void; 18 19 } = $props(); 19 20 20 21 let isNew = $derived.by(() => { 21 - if (!lastReadDate) return true; 22 + if (!lastReadDate) return channel.mainThread?.timeline?.length !== 0; 22 23 if (!channel.mainThread?.timeline) return false; 23 24 let date = channel.mainThread.timeline[channel.mainThread.timeline.length - 1]?.createdAt; 24 25 if (!date) return false; ··· 28 29 </script> 29 30 30 31 <Button 31 - data-current={page.url.pathname === `${base}/${spaceId}/channel/${channel.id}`} 32 + data-current={page.url.pathname === `${base}/${route.spaceId}/channel/${channel.id}`} 32 33 variant="ghost" 33 - href="{base}/{spaceId}/channel/{channel.id}" 34 + href="{base}/{route.spaceId}/channel/{channel.id}" 34 35 class="relative w-full justify-start backdrop-blur-none" 35 36 {onclick} 36 37 >
+4 -3
src/lib/components/ChatInput.svelte
··· 4 4 import type { Loaded } from 'jazz-tools'; 5 5 import { MyAppProfile, type Message, type MyAppAccount } from '$lib/schema'; 6 6 import { CoState } from 'jazz-svelte'; 7 + import { useCurrentRoute } from '$lib/context'; 8 + 9 + const route = useCurrentRoute(); 7 10 8 11 let { 9 12 value = $bindable(''), 10 13 handleSubmit, 11 14 me, 12 - spaceId, 13 15 clickJoinSpace, 14 16 replyTo = $bindable(null) 15 17 }: { 16 18 value: string; 17 19 handleSubmit: () => void; 18 20 me: Loaded<typeof MyAppAccount>; 19 - spaceId: string; 20 21 clickJoinSpace: () => void; 21 22 replyTo: Loaded<typeof Message> | null; 22 23 } = $props(); ··· 27 28 <div 28 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" 29 30 > 30 - {#if me?.root?.joinedSpaces?.some((space) => space?.id === spaceId)} 31 + {#if me?.root?.joinedSpaces?.some((space) => space?.id === route.spaceId)} 31 32 {#if replyTo} 32 33 <div 33 34 class="text-base-600 dark:text-base-400 bg-base-100 dark:bg-base-800 relative mt-1 flex items-center justify-between gap-1 rounded-2xl px-3 py-1 text-xs"
+41 -15
src/lib/components/ChatMessage.svelte
··· 8 8 import ChatMessageMenu from './ChatMessageMenu.svelte'; 9 9 import ChatReactions from './ChatReactions.svelte'; 10 10 import ReplyMessage from './ReplyMessage.svelte'; 11 + import ChatMessageThread from './ChatMessageThread.svelte'; 11 12 12 13 let { 13 14 message, 14 15 previousMessage, 15 16 me, 16 - setReplyTo 17 + setReplyTo, 18 + createThread, 19 + showMenu = true, 20 + showReactions = true, 21 + showDivider = true, 22 + showReply = true, 23 + showThread = true, 24 + allowThreadCreation = true 17 25 }: { 18 26 message: Loaded<typeof Message>; 19 27 previousMessage?: Loaded<typeof Message>; 20 28 me: Loaded<typeof MyAppAccount>; 21 29 setReplyTo: (message: Loaded<typeof Message>) => void; 30 + createThread: (message: Loaded<typeof Message>) => void; 31 + showMenu?: boolean; 32 + showReactions?: boolean; 33 + showDivider?: boolean; 34 + showReply?: boolean; 35 + showThread?: boolean; 36 + allowThreadCreation?: boolean; 22 37 } = $props(); 23 38 24 39 let profile = $derived(new CoState(MyAppProfile, message._edits.content?.by?.profile?.id)); 25 40 26 41 // if the same user and the message was created in the last minute, don't show the border, username or avatar 27 42 let isSameUser = $derived( 28 - previousMessage?._edits.content?.by?.profile?.id === message._edits.content?.by?.profile?.id && 43 + previousMessage?._edits?.content?.by?.profile?.id === 44 + message._edits?.content?.by?.profile?.id && 29 45 (message.createdAt?.getTime() ?? 0) - (previousMessage?.createdAt?.getTime() ?? 0) < 30 46 1000 * 60 && 31 47 !message.replyTo ··· 89 105 90 106 <div 91 107 class={cn( 92 - 'flex w-full flex-col gap-2', 93 - !isSameUser ? 'border-base-200/70 dark:border-base-900/50 border-t pt-2 pb-2' : '-my-1.5' 108 + 'flex w-full flex-col gap-2 select-text py-1 max-w-full', 109 + !isSameUser && showDivider ? 'border-base-200/70 dark:border-base-900/50 border-t pt-2 pb-2' : '-my-1.5' 94 110 )} 95 111 > 96 - {#if message.replyTo} 97 - <ReplyMessage replyTo={message.replyTo} /> 112 + {#if message.replyTo && showReply} 113 + <ReplyMessage replyToId={message.replyTo} /> 98 114 {/if} 99 115 <div 100 116 class={cn('group relative flex w-full justify-start gap-3')} ··· 124 140 {@html message.content} 125 141 </Prose> 126 142 127 - <ChatReactions {reactions} {addReaction} {removeReaction} /> 143 + {#if showReactions} 144 + <ChatReactions {reactions} {addReaction} {removeReaction} /> 145 + {/if} 128 146 </div> 129 147 130 - <ChatMessageMenu 131 - setAsReplyTo={() => setReplyTo(message)} 132 - {hovering} 133 - open={pickerOpen} 134 - {reactions} 135 - {addReaction} 136 - {removeReaction} 137 - /> 148 + {#if showMenu} 149 + <ChatMessageMenu 150 + setAsReplyTo={() => setReplyTo(message)} 151 + {hovering} 152 + open={pickerOpen} 153 + {reactions} 154 + {addReaction} 155 + {removeReaction} 156 + {allowThreadCreation} 157 + createThread={() => createThread(message)} 158 + /> 159 + {/if} 138 160 </div> 161 + 162 + {#if showThread && message.thread} 163 + <ChatMessageThread threadId={message.thread} /> 164 + {/if} 139 165 </div>
+23 -2
src/lib/components/ChatMessageMenu.svelte
··· 8 8 reactions, 9 9 addReaction, 10 10 removeReaction, 11 - setAsReplyTo 11 + setAsReplyTo, 12 + createThread, 13 + allowThreadCreation = true 12 14 }: { 13 15 hovering: boolean; 14 16 open: boolean; ··· 20 22 addReaction: (emoji: string) => void; 21 23 removeReaction: (emoji: string) => void; 22 24 setAsReplyTo: () => void; 25 + createThread: () => void; 26 + allowThreadCreation?: boolean; 23 27 } = $props(); 24 28 </script> 25 29 26 30 {#if hovering || open} 27 31 <div class={cn('absolute -top-3 right-0')}> 28 32 <div 29 - class="bg-base-200/50 dark:bg-base-900/50 backdrop-blur-sm border-base-300 dark:border-base-800 flex gap-2 rounded-2xl border p-1" 33 + class="bg-base-200/50 dark:bg-base-900/50 border-base-300 dark:border-base-800 flex gap-2 rounded-2xl border p-1 backdrop-blur-sm" 30 34 > 31 35 <Button variant="ghost" size="iconSm" onclick={setAsReplyTo} 32 36 ><svg ··· 43 47 /> 44 48 </svg> 45 49 </Button> 50 + 51 + {#if allowThreadCreation} 52 + <Button variant="ghost" size="iconSm" onclick={createThread}> 53 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" 54 + ><path 55 + fill="none" 56 + stroke="currentColor" 57 + stroke-linecap="round" 58 + stroke-linejoin="round" 59 + stroke-width="1.5" 60 + d="M4.5 7.5h12c1.886 0 2.828 0 3.414.586s.586 1.528.586 3.414v1m-4-2h-12m12 3h-12m12 3h-12m11 3h-10a2 2 0 0 0-2 2h14a2 2 0 0 0-2-2m-10-15h10a2 2 0 0 0 2-2h-14a2 2 0 0 0 2 2" 61 + color="currentColor" 62 + /></svg 63 + > 64 + </Button> 65 + {/if} 66 + 46 67 <PopoverEmojiPicker 47 68 onpicked={(emoji) => { 48 69 open = false;
+48
src/lib/components/ChatMessageThread.svelte
··· 1 + <script lang="ts"> 2 + import { base } from '$app/paths'; 3 + import { Thread } from '$lib/schema'; 4 + import { CoState } from 'jazz-svelte'; 5 + import { useCurrentRoute } from '$lib/context'; 6 + let { threadId }: { threadId: string } = $props(); 7 + 8 + let thread = $derived(new CoState(Thread, threadId)); 9 + 10 + const route = useCurrentRoute(); 11 + </script> 12 + 13 + <div 14 + class="text-base-600 dark:text-base-400 bg-base-200/50 dark:bg-base-900/40 border-base-300/50 dark:border-base-800/30 relative ml-10 inline-flex items-center justify-between gap-2 rounded-2xl border px-4 py-3 text-sm sm:ml-12" 15 + > 16 + <div class="flex items-center gap-2"> 17 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-4" 18 + ><path 19 + fill="none" 20 + stroke="currentColor" 21 + stroke-linecap="round" 22 + stroke-linejoin="round" 23 + stroke-width="1.5" 24 + d="M4.5 7.5h12c1.886 0 2.828 0 3.414.586s.586 1.528.586 3.414v1m-4-2h-12m12 3h-12m12 3h-12m11 3h-10a2 2 0 0 0-2 2h14a2 2 0 0 0-2-2m-10-15h10a2 2 0 0 0 2-2h-14a2 2 0 0 0 2 2" 25 + color="currentColor" 26 + /></svg 27 + > 28 + Thread 29 + <a 30 + href={`${base}/${route.spaceId}/channel/${route.channelId}/thread/${threadId}`} 31 + class="text-accent-600 dark:text-accent-400 font-medium" 32 + > 33 + <span class="absolute inset-0"></span> 34 + {thread.current?.name}</a 35 + > 36 + ({thread.current?.timeline?.length} messages) 37 + </div> 38 + <svg 39 + xmlns="http://www.w3.org/2000/svg" 40 + fill="none" 41 + viewBox="0 0 24 24" 42 + stroke-width="1.5" 43 + stroke="currentColor" 44 + class="size-5" 45 + > 46 + <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /> 47 + </svg> 48 + </div>
+4 -4
src/lib/components/ReplyMessage.svelte
··· 4 4 import { CoState } from 'jazz-svelte'; 5 5 6 6 let { 7 - replyTo 7 + replyToId 8 8 }: { 9 - replyTo: string; 9 + replyToId: string; 10 10 } = $props(); 11 11 12 - let message = $derived(new CoState(Message, replyTo)); 12 + let message = $derived(new CoState(Message, replyToId)); 13 13 let profile = $derived( 14 14 new CoState(MyAppProfile, message.current?._edits.content?.by?.profile?.id) 15 15 ); 16 16 </script> 17 17 18 18 {#if message.current} 19 - <div class={cn('group relative flex w-full justify-start gap-3 bg-base-200/50 dark:bg-base-900/40 border border-base-300/50 dark:border-base-800/30 items-center text-xs rounded-2xl text-base-700 dark:text-base-300 px-3 py-0.5')}> 19 + <div class={cn('group relative inline-flex w-full max-w-full overflow-x-hidden min-w-0 justify-start gap-3 bg-base-200/50 dark:bg-base-900/40 border border-base-300/50 dark:border-base-800/30 items-center text-xs rounded-2xl text-base-700 dark:text-base-300 px-3 py-0.5')}> 20 20 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3.5"> 21 21 <path stroke-linecap="round" stroke-linejoin="round" d="m15 15 6-6m0 0-6-6m6 6H9a6 6 0 0 0 0 12h3" /> 22 22 </svg>
+75
src/lib/components/ThreadButton.svelte
··· 1 + <script lang="ts"> 2 + import { base } from '$app/paths'; 3 + import { page } from '$app/state'; 4 + import { Thread } from '$lib/schema'; 5 + import { Button } from '@fuxui/base'; 6 + import { useCurrentRoute } from '$lib/context'; 7 + import { CoState } from 'jazz-svelte'; 8 + 9 + const route = useCurrentRoute(); 10 + 11 + let { 12 + threadId, 13 + lastReadDate, 14 + onclick, 15 + onlyShowIfRecent = false, 16 + channelId 17 + }: { 18 + threadId: string; 19 + lastReadDate?: Date; 20 + onclick?: () => void; 21 + onlyShowIfRecent?: boolean; 22 + channelId?: string; 23 + } = $props(); 24 + 25 + let thread = $derived(new CoState(Thread, threadId)); 26 + 27 + let isNew = $derived.by(() => { 28 + if (!lastReadDate) return thread.current?.timeline?.length !== 0; 29 + if (!thread.current?.timeline) return false; 30 + let date = thread.current.timeline[thread.current.timeline.length - 1]?.createdAt; 31 + if (!date) return false; 32 + 33 + return new Date(lastReadDate) < date; 34 + }); 35 + 36 + // is latest message less tha 24 hours old 37 + let isRecent = $derived.by(() => { 38 + if (!thread.current?.timeline) return false; 39 + let date = thread.current.timeline[thread.current.timeline.length - 1]?.createdAt; 40 + if (!date) return false; 41 + return date.getTime() > Date.now() - 1000 * 60 * 60 * 24; 42 + }); 43 + </script> 44 + 45 + {#if isNew || (!onlyShowIfRecent || isRecent)} 46 + <div class="w-full pl-4"> 47 + <Button 48 + data-current={page.url.pathname === 49 + `${base}/${route.spaceId}/channel/${channelId ?? route.channelId}/thread/${threadId}`} 50 + variant="ghost" 51 + href="{base}/{route.spaceId}/channel/{channelId ?? route.channelId}/thread/{threadId}" 52 + class="relative w-full justify-start backdrop-blur-none" 53 + {onclick} 54 + > 55 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" 56 + ><path 57 + fill="none" 58 + stroke="currentColor" 59 + stroke-linecap="round" 60 + stroke-linejoin="round" 61 + stroke-width="1.5" 62 + d="M4.5 7.5h12c1.886 0 2.828 0 3.414.586s.586 1.528.586 3.414v1m-4-2h-12m12 3h-12m12 3h-12m11 3h-10a2 2 0 0 0-2 2h14a2 2 0 0 0-2-2m-10-15h10a2 2 0 0 0 2-2h-14a2 2 0 0 0 2 2" 63 + color="currentColor" 64 + /></svg 65 + > 66 + 67 + {thread.current?.name} 68 + {#if isNew} 69 + <div class="absolute top-1 left-1"> 70 + <div class="bg-accent-500 dark:bg-accent-600 size-2 rounded-full"></div> 71 + </div> 72 + {/if} 73 + </Button> 74 + </div> 75 + {/if}
+3 -15
src/lib/context.ts
··· 1 - import type { Loaded } from 'jazz-tools'; 2 1 import { getContext } from 'svelte'; 3 - import type { MyAppAccount, Space } from './schema'; 4 2 5 - export function useMe(): { current: Loaded<typeof MyAppAccount> } | undefined { 6 - const me = getContext('me'); 7 - return me as { current: Loaded<typeof MyAppAccount> }; 8 - } 9 - 10 - export function useSpace(): { current: Loaded<typeof Space> } | undefined { 11 - const space = getContext('space'); 12 - return space as { current: Loaded<typeof Space> }; 13 - } 14 - 15 - export function useSpaceId(): string { 16 - const spaceId = getContext('spaceId'); 17 - return spaceId as string; 3 + export function useCurrentRoute(): { spaceId?: string; channelId?: string; threadId?: string } { 4 + const route = getContext('route'); 5 + return route as { spaceId?: string; channelId?: string; threadId?: string }; 18 6 }
+3 -1
src/lib/schema.ts
··· 14 14 15 15 replyTo: z.string().optional(), 16 16 reactions: co.list(Reaction), 17 - type: z.enum(['message', 'announcement']) 17 + type: z.enum(['message', 'announcement']), 18 + 19 + thread: z.string().optional() 18 20 }); 19 21 20 22 export const Timeline = co.list(Message);
+17
src/routes/+layout.svelte
··· 5 5 import { JazzProvider } from 'jazz-svelte'; 6 6 import { MyAppAccount } from '$lib/schema'; 7 7 import { dev } from '$app/environment'; 8 + import { setContext } from 'svelte'; 9 + import { page } from '$app/state'; 10 + import { afterNavigate } from '$app/navigation'; 8 11 9 12 let { children } = $props(); 13 + 14 + let route = $state({ 15 + spaceId: page.params.spaceId, 16 + channelId: page.params.channelId, 17 + threadId: page.params.threadId 18 + }); 19 + 20 + setContext('route', route); 21 + 22 + afterNavigate(() => { 23 + route.spaceId = page.params.spaceId; 24 + route.channelId = page.params.channelId; 25 + route.threadId = page.params.threadId; 26 + }); 10 27 </script> 11 28 12 29 <JazzProvider
+12 -19
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { 3 - Avatar, 4 - Button, 5 - cn, 6 - Heading, 7 - Input, 8 - Modal, 9 - Subheading, 10 - Textarea, 11 - ThemeToggle 12 - } from '@fuxui/base'; 13 - 2 + import { Avatar, Button, cn, Heading, Input, Modal, Subheading, ThemeToggle } from '@fuxui/base'; 14 3 import { AccountCoState, CoState } from 'jazz-svelte'; 15 4 import { LastReadList, MyAppAccount, SpaceList } from '$lib/schema'; 16 - import { createPublicSpacesList, createSpace } from '$lib/utils'; 17 - import { getRandomUsername } from '$lib/username'; 5 + import { createSpace } from '$lib/utils'; 18 6 import { getProfile, resolveHandle } from '$lib/bluesky'; 19 7 import { ImageDefinition } from 'jazz-tools'; 20 8 import { onDestroy } from 'svelte'; ··· 116 104 </script> 117 105 118 106 <div class={cn('h-[100dvh] px-4')}> 119 - <div class="mx-auto flex w-full max-w-2xl flex-col items-start justify-center gap-2 py-24 relative"> 120 - <div class="flex flex-col items-end gap-2 justify-center w-full"> 107 + <div 108 + class="relative mx-auto flex w-full max-w-2xl flex-col items-start justify-center gap-2 py-24" 109 + > 110 + <div class="flex w-full flex-col items-end justify-center gap-2"> 121 111 <div class="mx-auto flex items-center gap-2"> 122 112 <Avatar src={me.current?.profile.imageUrl} /> 123 113 <Heading> ··· 127 117 </div> 128 118 </div> 129 119 130 - 131 - <Button variant="secondary" onclick={() => (editProfileModalOpen = true)} size="sm" class="absolute top-16 right-0"> 120 + <Button 121 + variant="secondary" 122 + onclick={() => (editProfileModalOpen = true)} 123 + size="sm" 124 + class="absolute top-16 right-0" 125 + > 132 126 <svg 133 127 xmlns="http://www.w3.org/2000/svg" 134 128 fill="none" ··· 224 218 <CreateSpaceModal bind:open={createNewSpaceModalOpen} {createNewSpace} /> 225 219 226 220 <ThemeToggle class="absolute top-4 right-4" /> 227 - 228 221 229 222 <svelte:window 230 223 onkeydown={(e) => {
+24 -27
src/routes/[spaceId]/+layout.svelte
··· 1 1 <script lang="ts"> 2 - import { afterNavigate } from '$app/navigation'; 3 2 import { base } from '$app/paths'; 4 - import { page } from '$app/state'; 5 3 import { MyAppAccount, Space } from '$lib/schema'; 6 4 import { createChannel, isSpaceAdmin } from '$lib/utils'; 7 5 import { ··· 22 20 import SpaceSelection from '$lib/SpaceSelection.svelte'; 23 21 import ThemeSelectDropdown from '$lib/components/ThemeSelectDropdown.svelte'; 24 22 import ChannelButton from '$lib/components/ChannelButton.svelte'; 25 - import { setContext } from 'svelte'; 23 + import { useCurrentRoute } from '$lib/context'; 24 + import ThreadButton from '$lib/components/ThreadButton.svelte'; 26 25 27 - let spaceId: string = $state(page.params.spaceId); 26 + let route = useCurrentRoute(); 28 27 29 28 let space = $derived( 30 - new CoState(Space, spaceId, { 29 + new CoState(Space, route.spaceId, { 31 30 resolve: { 32 31 channels: { 33 32 $each: true, ··· 42 41 profile: true, 43 42 root: true 44 43 } 45 - }); 46 - 47 - setContext('me', me); 48 - setContext('space', space); 49 - setContext('spaceId', spaceId); 50 - 51 - $effect(() => { 52 - setContext('space', space); 53 - setContext('spaceId', spaceId); 54 - setContext('me', me); 55 - }); 56 - 57 - afterNavigate(() => { 58 - spaceId = page.params.spaceId; 59 44 }); 60 45 61 46 let { children } = $props(); ··· 133 118 <Button 134 119 variant="ghost" 135 120 class="mb-2 w-full justify-start backdrop-blur-none" 136 - href={`${base}/${spaceId}`} 121 + href={`${base}/${route.spaceId}`} 137 122 onclick={hideSidebar} 138 123 > 139 124 <span>{space.current?.emoji}</span>{space.current?.name} ··· 150 135 {#each space.current?.channels ?? [] as channel} 151 136 {#if channel} 152 137 <ChannelButton 153 - channel={channel} 154 - spaceId={spaceId} 138 + {channel} 155 139 lastReadDate={me.current?.root.lastRead?.[channel.id]} 156 - onclick={() => { 157 - hideSidebar(); 158 - }} 140 + onclick={hideSidebar} 159 141 /> 142 + 143 + 144 + {#each channel.subThreads ?? [] as thread} 145 + {#if thread} 146 + <ThreadButton 147 + threadId={thread.id} 148 + lastReadDate={me.current?.root.lastRead?.[thread.id]} 149 + onclick={hideSidebar} 150 + onlyShowIfRecent={thread.id !== route.threadId} 151 + channelId={channel.id} 152 + /> 153 + {/if} 154 + {/each} 160 155 {/if} 161 156 {/each} 162 157 ··· 185 180 </AccordionItem> 186 181 </Accordion> 187 182 188 - 189 183 <Button 190 184 variant="ghost" 191 185 class="mt-16 w-full justify-start backdrop-blur-none" ··· 205 199 onsubmit={() => { 206 200 createNewChannel(space.current, newChannelName); 207 201 isNewChannelModalOpen = false; 202 + newChannelName = ''; 208 203 }} 209 204 class="flex flex-col gap-2" 210 205 > ··· 222 217 }} 223 218 /> 224 219 225 - <SpaceSelection spaces={me.current?.root?.joinedSpaces ?? []} bind:open={showSpaceSelection} /> 220 + {#if me.current?.root?.joinedSpaces} 221 + <SpaceSelection spaces={me.current?.root?.joinedSpaces} bind:open={showSpaceSelection} /> 222 + {/if} 226 223 227 224 <Container> 228 225 {@render children?.()}
+20 -24
src/routes/[spaceId]/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { afterNavigate } from '$app/navigation'; 3 - import { base } from '$app/paths'; 4 - import { page } from '$app/state'; 5 2 import ChannelButton from '$lib/components/ChannelButton.svelte'; 6 - import { useMe, useSpace, useSpaceId } from '$lib/context'; 3 + import { useCurrentRoute } from '$lib/context'; 7 4 import { MyAppAccount, Space } from '$lib/schema'; 8 - import { Button, Heading, Paragraph, Subheading } from '@fuxui/base'; 5 + import { Heading, Paragraph, Subheading } from '@fuxui/base'; 9 6 import { AccountCoState, CoState } from 'jazz-svelte'; 10 - import { getContext } from 'svelte'; 11 7 12 - // let spaceId: string = $state(page.params.spaceId); 8 + const route = useCurrentRoute(); 13 9 14 - const me = useMe(); 15 - const space = useSpace(); 16 - const spaceId = useSpaceId(); 10 + let space = $derived( 11 + new CoState(Space, route.spaceId, { 12 + resolve: { 13 + channels: { 14 + $each: true, 15 + $onError: null 16 + } 17 + } 18 + }) 19 + ); 17 20 18 - // let space = $derived( 19 - // new CoState(Space, spaceId, { 20 - // resolve: { 21 - // channels: { 22 - // $each: true, 23 - // $onError: null 24 - // } 25 - // } 26 - // }) 27 - // ); 21 + const me = new AccountCoState(MyAppAccount, { 22 + resolve: { 23 + profile: true, 24 + root: true 25 + } 26 + }); 28 27 29 - // afterNavigate(() => { 30 - // spaceId = page.params.spaceId; 31 - // }); 32 28 </script> 33 29 34 30 <Heading ··· 49 45 <div class="flex flex-col items-start justify-start gap-2"> 50 46 {#each space?.current?.channels ?? [] as channel} 51 47 {#if channel} 52 - <ChannelButton channel={channel} spaceId={spaceId} lastReadDate={me?.current?.root?.lastRead?.[channel.id]} /> 48 + <ChannelButton channel={channel} lastReadDate={me?.current?.root?.lastRead?.[channel.id]} /> 53 49 {/if} 54 50 {/each} 55 51 </div>
+103 -29
src/routes/[spaceId]/channel/[channelId]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { afterNavigate } from '$app/navigation'; 3 - import { base } from '$app/paths'; 4 - import { page } from '$app/state'; 5 3 import ChatInput from '$lib/components/ChatInput.svelte'; 6 4 import ChatMessage from '$lib/components/ChatMessage.svelte'; 7 - import Container from '$lib/components/Container.svelte'; 8 - import { RichTextEditor } from '$lib/components/rich-text-editor'; 9 - import { useMe, useSpace, useSpaceId } from '$lib/context'; 10 - import { Channel, LastReadList, Message, MyAppAccount, Reaction, Space } from '$lib/schema'; 5 + import { useCurrentRoute } from '$lib/context'; 6 + import { Channel, LastReadList, Message, MyAppAccount, Reaction, Space, Thread } from '$lib/schema'; 11 7 import { joinSpace, publicGroup } from '$lib/utils'; 12 - import { Button, Prose } from '@fuxui/base'; 8 + import { Button, Heading, Input, Modal } from '@fuxui/base'; 13 9 import { AccountCoState, CoState } from 'jazz-svelte'; 14 10 import { co, CoRichText, type Loaded } from 'jazz-tools'; 15 - import { onMount } from 'svelte'; 11 + 12 + const route = useCurrentRoute(); 16 13 17 - let channelId = $state(page.params.channelId); 14 + let space = $derived( 15 + new CoState(Space, route.spaceId, { 16 + resolve: { 17 + channels: { 18 + $each: true, 19 + $onError: null 20 + } 21 + } 22 + }) 23 + ); 18 24 19 - const me = useMe(); 20 - const space = useSpace(); 21 - const spaceId = useSpaceId(); 25 + const me = new AccountCoState(MyAppAccount, { 26 + resolve: { 27 + profile: true, 28 + root: true 29 + } 30 + }); 22 31 23 32 let channel = $derived( 24 - new CoState(Channel, channelId, { 33 + new CoState(Channel, route.channelId, { 25 34 resolve: { 26 35 mainThread: { 27 36 timeline: { ··· 38 47 ); 39 48 40 49 afterNavigate(() => { 41 - channelId = page.params.channelId; 42 - 43 50 setLastRead(); 44 51 }); 45 52 46 - 47 53 function setLastRead() { 48 - if(!me?.current?.root) { 54 + if (!route.channelId) { 55 + console.log('no channel id'); 56 + return; 57 + } 58 + if (!me?.current?.root) { 49 59 console.log('no account'); 50 60 return; 51 61 } 52 62 if (me?.current?.root?.lastRead) { 53 - me.current.root.lastRead[channelId] = new Date(); 63 + me.current.root.lastRead[route.channelId] = new Date(); 54 64 } else { 55 65 me.current.root.lastRead = LastReadList.create({ 56 - [channelId]: new Date() 66 + [route.channelId]: new Date() 57 67 }); 58 68 console.log('no last read'); 59 69 } ··· 75 85 text: input, 76 86 owner: publicGroup() 77 87 }); 78 - 88 + 79 89 // add message to channel 80 90 const message = Message.create( 81 91 { ··· 105 115 console.log('clickJoinSpace', space?.current); 106 116 let toJoinSpace = space?.current; 107 117 if (!toJoinSpace) { 108 - toJoinSpace = await Space.load(spaceId); 118 + if (!route.spaceId) { 119 + console.log('no space id'); 120 + return; 121 + } 122 + toJoinSpace = await Space.load(route.spaceId); 109 123 } 110 124 111 125 if (!toJoinSpace) { ··· 125 139 function setReplyTo(message: Loaded<typeof Message>) { 126 140 replyTo = message; 127 141 } 142 + 143 + let createThreadModalOpen = $state(false); 144 + 145 + let threadMessage = $state<Loaded<typeof Message> | null>(null); 146 + let threadName = $state(''); 147 + 148 + function createThread() { 149 + if (!threadMessage) { 150 + console.error('threadMessage is null'); 151 + return; 152 + } 153 + 154 + const thread = Thread.create( 155 + { 156 + name: threadName || 'Unnamed Thread', 157 + timeline: co.list(Message).create([threadMessage], { 158 + owner: publicGroup() 159 + }) 160 + }, 161 + { 162 + owner: publicGroup() 163 + } 164 + ); 165 + 166 + threadMessage.thread = thread.id; 167 + 168 + channel.current?.subThreads?.push(thread); 169 + 170 + createThreadModalOpen = false; 171 + } 128 172 </script> 129 173 130 174 <div class="flex w-full flex-col gap-1"> 131 175 {#each channel.current?.mainThread?.timeline ?? [] as message, index} 132 176 {#if message} 133 - <ChatMessage setReplyTo={setReplyTo} {message} previousMessage={channel.current?.mainThread?.timeline?.[index - 1]} me={me?.current} /> 177 + <ChatMessage 178 + {setReplyTo} 179 + {message} 180 + previousMessage={channel.current?.mainThread?.timeline?.[index - 1]} 181 + me={me?.current} 182 + createThread={(message) => { 183 + threadMessage = message; 184 + createThreadModalOpen = true; 185 + }} 186 + /> 134 187 {/if} 135 188 {/each} 136 189 {#if channel.current?.mainThread?.timeline?.length === 0} 137 - <div class="text-base-600 dark:text-base-400 text-sm h-30"> 190 + <div class="text-base-600 dark:text-base-400 h-30 text-sm"> 138 191 No messages yet. Be the first to send a message! 139 192 </div> 140 193 {/if} 141 194 </div> 142 195 143 - 144 196 <ChatInput 145 197 bind:replyTo 146 - spaceId={spaceId} 147 - me={me.current} 148 - handleSubmit={handleSubmit} 149 - clickJoinSpace={clickJoinSpace} 198 + me={me?.current} 199 + {handleSubmit} 200 + {clickJoinSpace} 150 201 bind:value={input} 151 - /> 202 + /> 203 + 204 + <Modal bind:open={createThreadModalOpen}> 205 + <div class="flex flex-col gap-4"> 206 + <Heading>create new thread</Heading> 207 + {#if threadMessage} 208 + <div class="bg-base-200/50 dark:bg-base-800/50 rounded-2xl p-2"> 209 + <ChatMessage 210 + {setReplyTo} 211 + message={threadMessage} 212 + me={me?.current} 213 + showMenu={false} 214 + showReactions={false} 215 + showDivider={false} 216 + showReply={false} 217 + /> 218 + </div> 219 + {/if} 220 + <form class="flex flex-col gap-2" onsubmit={createThread}> 221 + <Input type="text" placeholder="Thread name" bind:value={threadName} /> 222 + <Button type="submit">create</Button> 223 + </form> 224 + </div> 225 + </Modal>
+162
src/routes/[spaceId]/channel/[channelId]/thread/[threadId]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { afterNavigate } from '$app/navigation'; 3 + import ChatInput from '$lib/components/ChatInput.svelte'; 4 + import ChatMessage from '$lib/components/ChatMessage.svelte'; 5 + import { useCurrentRoute } from '$lib/context'; 6 + import { LastReadList, Message, MyAppAccount, Reaction, Space, Thread } from '$lib/schema'; 7 + import { joinSpace, publicGroup } from '$lib/utils'; 8 + import { AccountCoState, CoState } from 'jazz-svelte'; 9 + import { co, CoRichText, type Loaded } from 'jazz-tools'; 10 + 11 + const route = useCurrentRoute(); 12 + 13 + 14 + let space = $derived( 15 + new CoState(Space, route.spaceId, { 16 + resolve: { 17 + channels: { 18 + $each: true, 19 + $onError: null 20 + } 21 + } 22 + }) 23 + ); 24 + 25 + const me = new AccountCoState(MyAppAccount, { 26 + resolve: { 27 + profile: true, 28 + root: true 29 + } 30 + }); 31 + 32 + 33 + let thread = $derived( 34 + new CoState(Thread, route.threadId, { 35 + resolve: { 36 + timeline: { 37 + $each: { 38 + reactions: { 39 + $each: true 40 + } 41 + } 42 + } 43 + } 44 + }) 45 + ); 46 + 47 + afterNavigate(() => { 48 + setLastRead(); 49 + }); 50 + 51 + function setLastRead() { 52 + if (!route.threadId) { 53 + console.log('no channel id'); 54 + return; 55 + } 56 + if (!me?.current?.root) { 57 + console.log('no account'); 58 + return; 59 + } 60 + if (me?.current?.root?.lastRead) { 61 + me.current.root.lastRead[route.threadId] = new Date(); 62 + } else { 63 + me.current.root.lastRead = LastReadList.create({ 64 + [route.threadId]: new Date() 65 + }); 66 + console.log('no last read'); 67 + } 68 + } 69 + // svelte-ignore state_referenced_locally 70 + let count = $state(thread.current?.timeline?.length ?? 0); 71 + 72 + $effect(() => { 73 + let newCount = thread.current?.timeline?.length ?? 0; 74 + if (count < newCount) { 75 + count = newCount; 76 + 77 + setLastRead(); 78 + } 79 + }); 80 + 81 + function handleSubmit() { 82 + let newContent = new CoRichText({ 83 + text: input, 84 + owner: publicGroup() 85 + }); 86 + 87 + // add message to channel 88 + const message = Message.create( 89 + { 90 + content: newContent, 91 + createdAt: new Date(), 92 + updatedAt: new Date(), 93 + images: co.list(co.image()).create([], { 94 + owner: publicGroup() 95 + }), 96 + reactions: co.list(Reaction).create([], { 97 + owner: publicGroup() 98 + }), 99 + replyTo: replyTo?.id, 100 + type: 'message' 101 + }, 102 + { 103 + owner: publicGroup() 104 + } 105 + ); 106 + 107 + replyTo = null; 108 + 109 + thread.current?.timeline?.push(message); 110 + } 111 + 112 + async function clickJoinSpace() { 113 + console.log('clickJoinSpace', space?.current); 114 + let toJoinSpace = space?.current; 115 + if (!toJoinSpace) { 116 + if (!route.spaceId) { 117 + console.log('no space id'); 118 + return; 119 + } 120 + toJoinSpace = await Space.load(route.spaceId); 121 + } 122 + 123 + if (!toJoinSpace) { 124 + console.error('space not found'); 125 + return; 126 + } 127 + 128 + joinSpace(toJoinSpace); 129 + me?.current?.root?.joinedSpaces?.unshift(toJoinSpace); 130 + 131 + console.log('joined space', toJoinSpace); 132 + } 133 + 134 + let input = $state(''); 135 + let replyTo = $state<Loaded<typeof Message> | null>(null); 136 + 137 + function setReplyTo(message: Loaded<typeof Message>) { 138 + replyTo = message; 139 + } 140 + </script> 141 + 142 + <div class="flex w-full flex-col gap-1"> 143 + {#each thread.current?.timeline ?? [] as message, index} 144 + {#if message} 145 + <ChatMessage 146 + {setReplyTo} 147 + {message} 148 + previousMessage={thread.current?.timeline?.[index - 1]} 149 + me={me?.current} 150 + allowThreadCreation={false} 151 + showThread={false} 152 + /> 153 + {/if} 154 + {/each} 155 + {#if thread.current?.timeline?.length === 0} 156 + <div class="text-base-600 dark:text-base-400 h-30 text-sm"> 157 + No messages yet. Be the first to send a message! 158 + </div> 159 + {/if} 160 + </div> 161 + 162 + <ChatInput bind:replyTo me={me?.current} {handleSubmit} {clickJoinSpace} bind:value={input} />