feature: add chat search
- first check if the chat is available locally, if not then hit the server
This commit is contained in:
parent
2cecc4aceb
commit
7f80d661bd
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user