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
{
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)
{
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)
{
return new ChatResponseResource(
$createChatAction->create($request->user(), $request->input('title', 'New Chat'))
);
return new ChatResponseResource($createChatAction->create($request->user(), $request->input('title', 'New Chat')));
}
}

View File

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

View File

@ -27,4 +27,8 @@ export class ChatService {
public getChats() {
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 { inject } from '@angular/core';
import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { ChatService } from './chat-service';
import { MessageState, Message, MessageResponse, ChatResponse, ChatState } from './chat.types';
import { lastValueFrom } from 'rxjs';
import {
MessageState,
Message,
MessageResponse,
ChatResponse,
ChatState,
Chat,
} from './chat.types';
import { debounceTime, distinctUntilChanged, lastValueFrom, of, pipe, switchMap } from 'rxjs';
import { Router } from '@angular/router';
import { JsonApiResource } from '../core/types/api';
import { environment } from '../../environments/environment.development';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
const initialMessageState: MessageState = {
messages: [
@ -30,8 +38,11 @@ const initialMessageState: MessageState = {
};
const initalChatState: ChatState = {
chats: [],
_chats: [],
_serverSearchResults: [],
_filteredChats: [],
isLoading: true,
searchQuery: null,
};
export const MessageStore = signalStore(
@ -216,6 +227,12 @@ export const MessageStore = signalStore(
export const ChatStore = signalStore(
{ providedIn: 'root' },
withState(initalChatState),
withComputed((store) => ({
displayedChats: computed(() => {
if (!store.searchQuery()) return store._chats();
return store._filteredChats();
}),
})),
withMethods((store) => {
const chatService = inject(ChatService);
return {
@ -225,12 +242,44 @@ export const ChatStore = signalStore(
const chats = await lastValueFrom(chatService.getChats());
patchState(store, {
isLoading: false,
chats: chats.data,
_chats: chats.data,
});
} catch (error: any) {
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;
};
export type ChatState = {
chats: JsonApiResource<Chat>[];
_chats: JsonApiResource<Chat>[];
_serverSearchResults: JsonApiResource<Chat>[];
_filteredChats: JsonApiResource<Chat>[];
isLoading: boolean;
searchQuery: string | null;
};

View File

@ -103,11 +103,11 @@
</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">
<!-- Chat Items -->
<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)">
<!-- Active indicator -->
@if(activeChatId() && activeChatId() === chat.id){
@ -158,7 +158,7 @@
</div>
}
<!-- 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="w-10 h-10 rounded-full bg-[#111a2e] flex items-center justify-center mb-3">
<svg

View File

@ -7,8 +7,7 @@ import { InitialsPipe } from './initials-pipe';
import { JsonApiResource } from '../../types/api';
import { Chat } from '../../../chat/chat.types';
import { Login } from '../../../auth/login/login';
import { Loader } from "../../../shared/loader/loader";
import { Loader } from '../../../shared/loader/loader';
@Component({
selector: 'app-sidebar',
standalone: true,
@ -24,7 +23,6 @@ export class Sidebar implements OnInit {
protected isOpen = signal(true);
protected isCogMenuOpen = signal(false);
protected searchQuery = signal('');
protected activeChatId = signal<string | null>(null);
ngOnInit() {
@ -78,7 +76,7 @@ export class Sidebar implements OnInit {
}
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 {