Repository: muni-town/roomy
Pull request #265: search feature
Author: login: codegod100
Created: 2025-05-16T19:01:53Z | Merged: 2025-05-19T17:29:04Z

Initial stab at search #119 


Diff:
diff --git a/package.json b/package.json
index a5eb8b10d..82bfcd006 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
     "husky": "^9.1.7",
     "pkgroll": "^2.12.2",
     "turbo": "^2.5.3",
-    "typescript": "^5.8.3"
+    "typescript": "^5.8.3",
+    "flexsearch": "^0.8.164"
   }
 }
diff --git a/packages/app/src/lib/components/ChatArea.svelte b/packages/app/src/lib/components/ChatArea.svelte
index c4d7a538c..582cdbaff 100644
--- a/packages/app/src/lib/components/ChatArea.svelte
+++ b/packages/app/src/lib/components/ChatArea.svelte
@@ -14,12 +14,13 @@
 
   let {
     timeline,
+    virtualizer = $bindable(),
   }: {
     timeline: Timeline;
+    virtualizer?: Virtualizer<string>;
   } = $props();
 
   let viewport: HTMLDivElement = $state(null!);
-  let virtualizer: Virtualizer<string> | undefined = $state();
   let messagesLoaded = $state(false);
 
   setContext("scrollToMessage", (id: EntityIdStr) => {
diff --git a/packages/app/src/lib/components/SearchResults.svelte b/packages/app/src/lib/components/SearchResults.svelte
new file mode 100644
index 000000000..14d5bd07f
--- /dev/null
+++ b/packages/app/src/lib/components/SearchResults.svelte
@@ -0,0 +1,102 @@
+<script lang="ts">
+  import Icon from "@iconify/svelte";
+  import AvatarImage from "$lib/components/AvatarImage.svelte";
+  import { getProfile } from "$lib/profile.svelte";
+  import { getContentHtml } from "$lib/tiptap/editor";
+  import type { Message } from "@roomy-chat/sdk";
+  import { formatDistanceToNow } from "date-fns";
+
+  let {
+    messages = [],
+    query = "",
+    onMessageClick,
+    onClose,
+  }: {
+    messages: Message[];
+    query: string;
+    onMessageClick: (messageId: string) => void;
+    onClose: () => void;
+  } = $props();
+
+  // Highlight the search term in the message content
+  function highlightSearchTerm(content: string, searchTerm: string): string {
+    if (!searchTerm) return content;
+    
+    const regex = new RegExp(`(${searchTerm})`, 'gi');
+    return content.replace(regex, '<mark class="bg-primary/30 text-base-content">$1</mark>');
+  }
+
+  // Format the message preview with highlighted search term
+  function formatMessagePreview(message: Message): string {
+    try {
+      const bodyContent = JSON.parse(message.bodyJson);
+      const htmlContent = getContentHtml(bodyContent);
+      return highlightSearchTerm(htmlContent, query);
+    } catch (error) {
+      return "Unable to display message content";
+    }
+  }
+</script>
+
+<div class="search-results bg-base-100 border border-base-300 rounded-lg shadow-lg w-full max-h-[60vh] overflow-auto">
+  <div class="sticky top-0 bg-base-100 p-3 border-b border-base-300 flex justify-between items-center z-10">
+    <h3 class="font-bold text-base-content">
+      Search Results {#if messages.length > 0}<span class="text-sm font-normal">({messages.length})</span>{/if}
+    </h3>
+    <button class="btn btn-ghost btn-sm btn-circle" onclick={onClose}>
+      <Icon icon="tabler:x" />
+    </button>
+  </div>
+
+  {#if messages.length === 0}
+    <div class="p-6 text-center text-base-content/70">
+      <Icon icon="tabler:search-off" class="text-4xl mb-2" />
+      <p>No messages found matching "{query}"</p>
+    </div>
+  {:else}
+    <ul class="divide-y divide-base-300">
+      {#each messages as message}
+        <li 
+          class="p-3 hover:bg-base-200 cursor-pointer transition-colors"
+          onclick={() => {
+            // Just call onMessageClick and don't try to scroll directly from here
+            // This will avoid the postMessage error
+            onMessageClick(message.id);
+          }}
+        >
+          <div class="flex items-start gap-2">
+            {#await getProfile(message.authors((x) => x.get(0))) then profile}
+              <AvatarImage
+                handle={profile.handle || ""}
+                avatarUrl={profile.avatarUrl}
+                className="w-8 h-8"
+              />
+              <div class="flex-1 min-w-0">
+                <div class="flex justify-between items-center mb-1">
+                  <span class="font-medium text-base-content">{profile.handle || "Unknown"}</span>
+                  <span class="text-xs text-base-content/60">
+                    {message.createdDate ? formatDistanceToNow(message.createdDate, { addSuffix: true }) : ""}
+                  </span>
+                </div>
+                <div class="text-sm text-base-content/80 line-clamp-2 break-words">
+                  {@html formatMessagePreview(message)}
+                </div>
+              </div>
+            {/await}
+          </div>
+        </li>
+      {/each}
+    </ul>
+  {/if}
+</div>
+
+<style>
+  .search-results {
+    max-width: 100%;
+  }
+  
+  :global(.search-results mark) {
+    padding: 0 2px;
+    border-radius: 2px;
+  }
+</style>
diff --git a/packages/app/src/lib/components/TimelineView.svelte b/packages/app/src/lib/components/TimelineView.svelte
index 651388682..b3921adcc 100644
--- a/packages/app/src/lib/components/TimelineView.svelte
+++ b/packages/app/src/lib/components/TimelineView.svelte
@@ -19,6 +19,7 @@
     Message,
     Thread,
     Timeline,
+    type EntityIdStr,
   } from "@roomy-chat/sdk";
   import type { JSONContent } from "@tiptap/core";
   import { getProfile } from "$lib/profile.svelte";
@@ -26,6 +27,43 @@
   import CreatePageDialog from "$lib/components/CreatePageDialog.svelte";
   import BoardList from "./BoardList.svelte";
   import ToggleNavigation from "./ToggleNavigation.svelte";
+  import { Index } from 'flexsearch';
+  import SearchResults from "./SearchResults.svelte";
+  import type { Virtualizer } from "virtua/svelte";
+
+  
+  // Helper function to extract text content from TipTap JSON content
+  function extractTextContent(parsedBody: Record<string, unknown>): string {
+    if (!parsedBody || typeof parsedBody !== 'object' || !('content' in parsedBody)) return '';
+    
+    let text = '';
+    
+    // Process the content recursively to extract text
+    function processNode(node: Record<string, unknown>): void {
+      if ('text' in node && typeof node.text === 'string') {
+        text = `${text}${node.text} `;
+      }
+      
+      if ('content' in node && Array.isArray(node.content)) {
+        for (const item of node.content) {
+          if (typeof item === 'object' && item !== null) {
+            processNode(item as Record<string, unknown>);
+          }
+        }
+      }
+    }
+    
+    // Start processing from the root content
+    if ('content' in parsedBody && Array.isArray(parsedBody.content)) {
+      for (const node of parsedBody.content) {
+        if (typeof node === 'object' && node !== null) {
+          processNode(node as Record<string, unknown>);
+        }
+      }
+    }
+    
+    return text.trim();
+  }
 
   let isMobile = $derived((outerWidth.current ?? 0) < 640);
 
@@ -63,8 +101,8 @@
   setContext("selectMessage", (message: Message) => {
     selectedMessages.push(message);
   });
-  setContext("removeSelectedMessage", (message: Message) => {
-    selectedMessages = selectedMessages.filter((m) => m != message);
+  setContext("removeSelectedMessage", (msg: Message) => {
+    selectedMessages = selectedMessages.filter((m) => m !== msg);
   });
 
   $effect(() => {
@@ -79,6 +117,74 @@
     replyingTo = message;
   });
 
+  // Initialize FlexSearch with appropriate options for message content
+  let searchIndex = new Index({
+    tokenize: "forward",
+    preset: "performance"
+  });
+  let searchQuery = $state('');
+  let showSearchInput = $state(false);
+  let searchResults = $state<Message[]>([]);
+  let showSearchResults = $state(false);
+  let virtualizer = $state<Virtualizer<string> | undefined>(undefined);
+
+  const scrollToMessage = getContext("scrollToMessage") as (
+    id: EntityIdStr,
+  ) => void;
+
+  console.dir("scroll to messages", scrollToMessage)
+
+
+  // Function to handle search result click
+  function handleSearchResultClick(messageId: string) {
+    console.dir("handle result click");
+    // Hide search results
+    showSearchResults = false;
+    
+    // Find the message in the timeline to get its index
+    if (globalState.channel) {
+      // Get the timeline IDs - this returns an array, not a Promise
+      const ids = globalState.channel.timeline.ids();
+      const messageIndex = ids.indexOf(messageId);
+      
+      if (messageIndex !== -1) {
+        virtualizer.scrollToIndex(messageIndex);
+      } else {
+        console.error("Message not found in timeline:", messageId);
+      }
+    } else {
+      console.error("No active channel");
+    }
+  }
+
+  // Index existing messages when timeline items are loaded
+  $effect(() => {
+    console.dir("effect for indexing")
+    if (searchIndex && globalState.channel?.timeline) {
+      // items() returns a Promise, unlike ids() which returns an array directly
+      globalState.channel.timeline.items().then(items => {
+        // Clear index before re-indexing to avoid duplicates
+        searchIndex.clear();
+        
+        for (const item of items) {
+          const message = item.tryCast(Message);
+          if (message) {
+            // Try parsing the message body
+            const parsedBody= JSON.parse(message.bodyJson);
+           
+            // Extract text content from the parsed body
+            const textContent = extractTextContent(parsedBody);
+            
+            if (textContent) {
+              console.dir(`adding ${textContent}`)
+              searchIndex.add(message.id, textContent);
+            }
+          }
+        }
+      });
+    }
+  });
+
   async function createThread(e: SubmitEvent) {
     e.preventDefault();
     if (!globalState.roomy || !globalState.space || !globalState.channel)
@@ -156,6 +262,19 @@
     message.commit();
     if (replyingTo) message.replyTo = replyingTo;
 
+    // Add new message to search index
+    if (searchIndex) {
+        const parsedBody = JSON.parse(message.bodyJson);
+        
+        // Extract text content from the parsed body
+        const textContent = extractTextContent(parsedBody);
+        
+        if (textContent) {
+          searchIndex.add(message.id, textContent);
+          console.dir(`added ${textContent}`)
+        }
+    }
+
     // Images are now handled by TipTap in the message content
     // Limit image size in message input to 300x300
 
@@ -166,6 +285,37 @@
     replyingTo = undefined;
   }
 
+  // Handle search input
+  $effect(() => {
+    if (searchIndex && searchQuery) {
+      // Perform synchronous search
+      const results = searchIndex.search(searchQuery);
+      
+      if (results.length > 0) {
+        showSearchResults = true;
+        
+        // Get the actual Message objects for the search results
+        if (globalState.channel?.timeline) {
+          globalState.channel.timeline.items().then(items => {
+            searchResults = items
+              .map(x => x.tryCast(Message))
+              .filter((msg): msg is Message => 
+                msg !== null && 
+                msg !== undefined && 
+                results.includes(msg.id)
+              );
+          });
+        }
+      } else {
+        searchResults = [];
+        showSearchResults = searchQuery.length > 0;
+      }
+    } else {
+      searchResults = [];
+      showSearchResults = false;
+    }
+  });
+
   let relatedThreads = derivePromise([], async () =>
     globalState.channel && globalState.channel instanceof Channel
       ? await globalState.channel.threads.items()
@@ -181,6 +331,32 @@
   });
 </script>
 
+<style>
+  .reply-highlight {
+    animation: pulse-highlight 2s ease-in-out;
+  }
+
+  @keyframes pulse-highlight {
+    0% {
+      background-color: rgba(59, 130, 246, 0.1);
+      box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
+    }
+    50% {
+      background-color: rgba(59, 130, 246, 0.2);
+      box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.3);
+    }
+    100% {
+      background-color: transparent;
+      box-shadow: none;
+    }
+  }
+
+  /* Same style for search result highlight for consistency */
+  .search-result-highlight {
+    animation: pulse-highlight 2s ease-in-out;
+  }
+</style>
+
 <header class="dz-navbar">
   <div class="dz-navbar-start flex gap-4">
     {#if globalState.channel}
@@ -227,7 +403,16 @@
   {/if}
 
   {#if !isMobile}
-    <div class="dz-navbar-end">
+    <div class="dz-navbar-end flex items-center gap-2">
+      {#if tab === "chat" || globalState.channel instanceof Thread}
+        <button 
+          class="btn btn-ghost btn-sm btn-circle" 
+          onclick={() => showSearchInput = !showSearchInput}
+          title="Toggle search"
+        >
+          <Icon icon="tabler:search" class="text-base-content" />
+        </button>
+      {/if}
       <TimelineToolbar {createThread} bind:threadTitleInput />
     </div>
   {/if}
@@ -246,40 +431,79 @@
   </BoardList>
 {:else if tab === "chat" || globalState.channel instanceof Thread}
   {#if globalState.space && globalState.channel}
-    <ChatArea timeline={globalState.channel.forceCast(Timeline)} />
-    <div class="flex items-center">
-      {#if !isMobile || !isThreading.value}
-        <section class="grow flex flex-col">
-          {#if replyingTo}
-            <div
-              class="flex justify-between bg-secondary text-secondary-content rounded-t-lg px-4 py-2"
-            >
-              <div class="flex flex-col gap-1">
-                <h5 class="flex gap-2 items-center">
-                  Replying to
-                  {#await getProfile(replyingTo.authors( (x) => x.get(0), )) then profile}
-                    <AvatarImage
-                      handle={profile.handle || ""}
-                      avatarUrl={profile.avatarUrl}
-                      className="!w-4"
-                    />
-                    <strong>{profile.handle}</strong>
-                  {/await}
-                </h5>
-                <p class="text-primary-content text-ellipsis italic">
-                  {@html getContentHtml(JSON.parse(replyingTo.bodyJson))}
-                </p>
-              </div>
-              <Button.Root
-                type="button"
-                onclick={() => (replyingTo = undefined)}
-                class="dz-btn dz-btn-circle dz-btn-ghost"
-              >
-                <Icon icon="zondicons:close-solid" />
-              </Button.Root>
-            </div>
-          {/if}
+    <div class="flex h-full flex-col">
+      {#if showSearchInput}
+        <div class="flex items-center border-b border-gray-200 dark:border-gray-700 px-2 py-1">
+          <Icon icon="tabler:search" class="text-base-content/50 mr-2" />
+          <input
+            type="text"
+            placeholder="Search messages..."
+            bind:value={searchQuery}
+            class="input input-sm input-ghost w-full focus:outline-none"
+            autoComplete="off"
+          />
+          <button 
+            class="btn btn-ghost btn-sm btn-circle" 
+            onclick={() => {
+              searchQuery = '';
+              showSearchInput = false;
+              showSearchResults = false;
+            }}
+          >
+            <Icon icon="tabler:x" class="text-base-content/50" />
+          </button>
+        </div>
+        
+        {#if showSearchResults}
           <div class="relative">
+            <div class="absolute z-20 w-full">
+              <SearchResults 
+                messages={searchResults} 
+                query={searchQuery}
+                onMessageClick={handleSearchResultClick}
+                onClose={() => {
+                  showSearchResults = false;
+                }}
+              />
+            </div>
+          </div>
+        {/if}
+      {/if}
+      <div class="flex-grow overflow-auto relative" style="max-height: calc(100vh - 180px);">
+        <ChatArea timeline={globalState.channel.forceCast(Timeline)} bind:virtualizer />
+        
+        {#if replyingTo}
+          <div
+            class="reply-container flex justify-between bg-secondary text-secondary-content rounded-t-lg px-4 py-2 absolute bottom-0 left-0 right-0"
+          >
+            <div class="flex items-center gap-2 overflow-hidden">
+              <span>Replying to</span>
+              {#await getProfile(replyingTo.authors( (x) => x.get(0), )) then profile}
+                <AvatarImage
+                  handle={profile.handle || ""}
+                  avatarUrl={profile.avatarUrl}
+                  className="!w-4"
+                />
+                <strong>{profile.handle}</strong>
+              {/await}
+              <p class="text-primary-content text-ellipsis italic max-h-12 overflow-hidden ml-2">
+                {@html getContentHtml(JSON.parse(replyingTo.bodyJson))}
+              </p>
+            </div>
+            <Button.Root
+              type="button"
+              onclick={() => (replyingTo = undefined)}
+              class="dz-btn dz-btn-circle dz-btn-ghost flex-shrink-0"
+            >
+              <Icon icon="zondicons:close-solid" />
+            </Button.Root>
+          </div>
+        {/if}
+      </div>
+      
+      <div>
+        {#if !isMobile || !isThreading.value}
+          <div class="chat-input-container">
             {#if globalState.roomy && globalState.roomy.spaces
                 .ids()
                 .includes(globalState.space.id)}
@@ -300,15 +524,13 @@
                 }}>Join Space To Chat</Button.Root
               >
             {/if}
-
-            <!-- Image upload button is now in ChatInput.svelte -->
           </div>
-        </section>
-      {/if}
+        {/if}
 
-      {#if isMobile}
-        <TimelineToolbar {createThread} bind:threadTitleInput />
-      {/if}
+        {#if isMobile}
+          <TimelineToolbar {createThread} bind:threadTitleInput />
+        {/if}
+      </div>
     </div>
   {/if}
 {/if}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f5282448a..a0b9088d2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,9 @@ importers:
       beachball:
         specifier: ^2.54.0
         version: 2.54.0(typescript@5.8.3)
+      flexsearch:
+        specifier: ^0.8.164
+        version: 0.8.164
       husky:
         specifier: ^9.1.7
         version: 9.1.7
@@ -2132,6 +2135,9 @@ packages:
     resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
     engines: {node: '>=8'}
 
+  flexsearch@0.8.164:
+    resolution: {integrity: sha512-tauQG+NlwSWW6uL3BIJdHNEXYiei2xTR3H/mrjNadAOwvXgiLbLvLzQWSJvD31Yn0+1lAxG2NVRndywGnDZZiA==}
+
   focus-trap@7.6.4:
     resolution: {integrity: sha512-xx560wGBk7seZ6y933idtjJQc1l+ck+pI3sKvhKozdBV1dRZoKhkW5xoCaFv9tQiX5RH1xfSxjuNu6g+lmN/gw==}
 
@@ -5548,6 +5554,8 @@ snapshots:
     dependencies:
       to-regex-range: 5.0.1
 
+  flexsearch@0.8.164: {}
+
   focus-trap@7.6.4:
     dependencies:
       tabbable: 6.2.0


Reviews:
user: login: michaelwschultz
body: Looks really good. Few minor changes.
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-18T21:40:34Z
user: login: michaelwschultz
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-19T17:28:26Z

Review comments:
path: pnpm-lock.yaml
user: login: michaelwschultz
body: All deps should be devdeps
created_at: 2025-05-18T21:33:40Z
author_association: COLLABORATOR
diff_hunk: @@ -167,6 +287,43 @@
     replyingTo = undefined;
   }
 
+  // Handle search input
+  $effect(() => {
+    if (searchIndex && searchQuery) {
+      // Perform synchronous search
+      const results = searchIndex.search(searchQuery);
+      console.dir(`searching ${searchQuery}`);
+      console.dir({results});
path: packages/app/src/lib/components/TimelineView.svelte
user: login: michaelwschultz
body: Mind removing these debug logs before merging?
created_at: 2025-05-18T21:38:38Z
author_association: COLLABORATOR
diff_hunk: @@ -24,5 +24,8 @@
     "pkgroll": "^2.12.2",
     "turbo": "^2.5.3",
     "typescript": "^5.8.3"
+  },
+  "dependencies": {
+    "flexsearch": "^0.8.164"
   }
path: package.json
user: login: michaelwschultz
body: All deps should be devdeps
created_at: 2025-05-18T21:40:15Z
author_association: COLLABORATOR

PR comments:
user: login: codegod100
created_at: 2025-05-18T22:57:15Z
body: updated with requested fixes and conflicts resolved
author_association: COLLABORATOR
user: login: michaelwschultz
created_at: 2025-05-19T17:28:16Z
body: Really good stuff @codegod100. Thanks for getting us started here. Search function feels really solid. I like that it works across channels too. The only issue I see is the two x buttons. One for clearing the search input and one for closing the results panel. I'll keep thinking about this as I integrate this into the larger design file.

I could see us adding a third column for search similar to Discord. Or perhaps take the Slack approach and replace the current chat with a global search.
author_association: COLLABORATOR