feature: add chat search

- first check if the chat is available locally, if not then hit the server
This commit is contained in:
kushal-saha 2026-05-05 09:15:22 +00:00
parent 2cecc4aceb
commit 7f80d661bd
8 changed files with 78 additions and 24 deletions

View File

@ -9,10 +9,14 @@
final readonly class GetUserChatsAction final readonly class GetUserChatsAction
{ {
public function chats(User $user): LengthAwarePaginator|AbstractPaginator public function chats(User $user, ?string $searchKeyword = null): LengthAwarePaginator|AbstractPaginator
{ {
$chats = $user->chats()->latest()->paginate(); $chats = $user->chats()->getQuery();
if ($searchKeyword) {
$chats = $chats->where('title', 'LIKE', "%{$searchKeyword}%");
}
$results = $chats->latest()->paginate();
return $chats->through(fn ($chat) => ChatResponseDto::fromModel($chat)); return $results->through(fn ($chat) => ChatResponseDto::fromModel($chat));
} }
} }

View File

@ -15,13 +15,11 @@ class ChatController extends Controller
{ {
public function index(Request $request, GetUserChatsAction $getUserChatsAction) public function index(Request $request, GetUserChatsAction $getUserChatsAction)
{ {
return ChatResponseResource::collection($getUserChatsAction->chats($request->user())); return ChatResponseResource::collection($getUserChatsAction->chats($request->user(), $request->input('search', null)));
} }
public function store(CreateChatRequest $request, CreateChatAction $createChatAction) public function store(CreateChatRequest $request, CreateChatAction $createChatAction)
{ {
return new ChatResponseResource( return new ChatResponseResource($createChatAction->create($request->user(), $request->input('title', 'New Chat')));
$createChatAction->create($request->user(), $request->input('title', 'New Chat'))
);
} }
} }

View File

@ -1,11 +1,9 @@
import { Component, signal } from '@angular/core'; import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { Header } from './core/layout/header/header';
import { Sidebar } from './core/layout/sidebar/sidebar';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, Header, Sidebar], imports: [RouterOutlet],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.css', styleUrl: './app.css',
}) })

View File

@ -27,4 +27,8 @@ export class ChatService {
public getChats() { public getChats() {
return this.http.get<ChatCollection>(`${this.apiUrl}/chats`); return this.http.get<ChatCollection>(`${this.apiUrl}/chats`);
} }
public searchChat(keyword: string) {
return this.http.get<ChatCollection>(`${this.apiUrl}/chats?search=${keyword}`);
}
} }

View File

@ -1,12 +1,20 @@
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';
import { inject } from '@angular/core'; import { computed, inject } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { ChatService } from './chat-service'; import { ChatService } from './chat-service';
import { MessageState, Message, MessageResponse, ChatResponse, ChatState } from './chat.types'; import {
import { lastValueFrom } from 'rxjs'; MessageState,
Message,
MessageResponse,
ChatResponse,
ChatState,
Chat,
} from './chat.types';
import { debounceTime, distinctUntilChanged, lastValueFrom, of, pipe, switchMap } from 'rxjs';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { JsonApiResource } from '../core/types/api'; import { JsonApiResource } from '../core/types/api';
import { environment } from '../../environments/environment.development'; import { environment } from '../../environments/environment.development';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
const initialMessageState: MessageState = { const initialMessageState: MessageState = {
messages: [ messages: [
@ -30,8 +38,11 @@ const initialMessageState: MessageState = {
}; };
const initalChatState: ChatState = { const initalChatState: ChatState = {
chats: [], _chats: [],
_serverSearchResults: [],
_filteredChats: [],
isLoading: true, isLoading: true,
searchQuery: null,
}; };
export const MessageStore = signalStore( export const MessageStore = signalStore(
@ -216,6 +227,12 @@ export const MessageStore = signalStore(
export const ChatStore = signalStore( export const ChatStore = signalStore(
{ providedIn: 'root' }, { providedIn: 'root' },
withState(initalChatState), withState(initalChatState),
withComputed((store) => ({
displayedChats: computed(() => {
if (!store.searchQuery()) return store._chats();
return store._filteredChats();
}),
})),
withMethods((store) => { withMethods((store) => {
const chatService = inject(ChatService); const chatService = inject(ChatService);
return { return {
@ -225,12 +242,44 @@ export const ChatStore = signalStore(
const chats = await lastValueFrom(chatService.getChats()); const chats = await lastValueFrom(chatService.getChats());
patchState(store, { patchState(store, {
isLoading: false, isLoading: false,
chats: chats.data, _chats: chats.data,
}); });
} catch (error: any) { } catch (error: any) {
patchState(store, { isLoading: false }); patchState(store, { isLoading: false });
} }
}, },
// This method first searches local chats which are loaded on init.
// If not hit, then it calls backend for results as chats come paginated.
performSearch: rxMethod<string>(
pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(async (query) => {
patchState(store, { isLoading: true });
if (!query) {
patchState(store, { searchQuery: null, _filteredChats: [], isLoading: false });
return of(null);
}
patchState(store, { searchQuery: query });
const localResults = store._chats().filter((chat) => {
return chat.attributes.title?.toLowerCase().includes(query.toLowerCase());
});
if (localResults.length > 0) {
patchState(store, {
_filteredChats: localResults,
});
} else {
const serverResults = await lastValueFrom(chatService.searchChat(query));
patchState(store, { _serverSearchResults: serverResults.data });
}
patchState(store, { isLoading: false });
return of(null);
}),
),
),
}; };
}), }),
); );

View File

@ -30,6 +30,9 @@ export type MessageState = {
id: string | null; id: string | null;
}; };
export type ChatState = { export type ChatState = {
chats: JsonApiResource<Chat>[]; _chats: JsonApiResource<Chat>[];
_serverSearchResults: JsonApiResource<Chat>[];
_filteredChats: JsonApiResource<Chat>[];
isLoading: boolean; isLoading: boolean;
searchQuery: string | null;
}; };

View File

@ -103,11 +103,11 @@
</div> </div>
</div> </div>
@if (chatStore.chats().length !== 0) { @if (chatStore.displayedChats().length !== 0) {
<div class="flex-1 overflow-y-auto px-2 pb-4 scrollbar-thin"> <div class="flex-1 overflow-y-auto px-2 pb-4 scrollbar-thin">
<!-- Chat Items --> <!-- Chat Items -->
<div class="space-y-0.5"> <div class="space-y-0.5">
@for (chat of chatStore.chats(); track chat.id) { @for (chat of chatStore.displayedChats(); track chat.id) {
<button (click)="selectChat(chat.id)" [class]="chatItemClasses(chat)"> <button (click)="selectChat(chat.id)" [class]="chatItemClasses(chat)">
<!-- Active indicator --> <!-- Active indicator -->
@if(activeChatId() && activeChatId() === chat.id){ @if(activeChatId() && activeChatId() === chat.id){
@ -158,7 +158,7 @@
</div> </div>
} }
<!-- Empty state --> <!-- Empty state -->
@if (chatStore.chats().length === 0) { @if (!chatStore.isLoading() && chatStore.displayedChats().length === 0) {
<div class="flex flex-1 flex-col items-center justify-center py-10 px-4 text-center"> <div class="flex flex-1 flex-col items-center justify-center py-10 px-4 text-center">
<div class="w-10 h-10 rounded-full bg-[#111a2e] flex items-center justify-center mb-3"> <div class="w-10 h-10 rounded-full bg-[#111a2e] flex items-center justify-center mb-3">
<svg <svg

View File

@ -7,8 +7,7 @@ import { InitialsPipe } from './initials-pipe';
import { JsonApiResource } from '../../types/api'; import { JsonApiResource } from '../../types/api';
import { Chat } from '../../../chat/chat.types'; import { Chat } from '../../../chat/chat.types';
import { Login } from '../../../auth/login/login'; import { Login } from '../../../auth/login/login';
import { Loader } from "../../../shared/loader/loader"; import { Loader } from '../../../shared/loader/loader';
@Component({ @Component({
selector: 'app-sidebar', selector: 'app-sidebar',
standalone: true, standalone: true,
@ -24,7 +23,6 @@ export class Sidebar implements OnInit {
protected isOpen = signal(true); protected isOpen = signal(true);
protected isCogMenuOpen = signal(false); protected isCogMenuOpen = signal(false);
protected searchQuery = signal('');
protected activeChatId = signal<string | null>(null); protected activeChatId = signal<string | null>(null);
ngOnInit() { ngOnInit() {
@ -78,7 +76,7 @@ export class Sidebar implements OnInit {
} }
protected onSearch(event: Event): void { protected onSearch(event: Event): void {
this.searchQuery.set((event.target as HTMLInputElement).value); this.chatStore.performSearch((event.target as HTMLInputElement).value);
} }
protected formatTime(dateValue: string | Date): string { protected formatTime(dateValue: string | Date): string {