feat: Add emoji and alot of other fixes
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
|
||||
<div class="relative">
|
||||
@if (compact()) {
|
||||
<div class="flex gap-1 rounded-lg border border-border bg-card p-2 shadow-lg">
|
||||
@for (entry of shortcuts(); track entry.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-10 w-10 place-items-center rounded transition-colors hover:bg-secondary"
|
||||
[attr.aria-label]="entry.label"
|
||||
[title]="entry.label"
|
||||
(click)="selectShortcut(entry)"
|
||||
>
|
||||
@if (entry.kind === 'custom') {
|
||||
<img
|
||||
[src]="entry.emoji.dataUrl"
|
||||
[alt]="entry.emoji.name"
|
||||
class="h-10 w-10 object-contain"
|
||||
/>
|
||||
} @else {
|
||||
<span class="text-[2rem] leading-none">{{ entry.emoji }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-10 w-10 place-items-center rounded text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
aria-label="Open emoji selector"
|
||||
title="Open emoji selector"
|
||||
(click)="openModal()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSmile"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!compact() || modalOpen()) {
|
||||
<div class="absolute bottom-full right-0 z-20 mb-2 w-72 rounded-lg border border-border bg-card p-3 shadow-xl">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-semibold text-foreground">Emoji</p>
|
||||
@if (compact()) {
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-7 w-7 place-items-center rounded hover:bg-secondary"
|
||||
aria-label="Close emoji selector"
|
||||
(click)="closeModal()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<label class="relative mb-3 block">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
class="w-full rounded-md border border-border bg-background py-2 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Search emoji"
|
||||
aria-label="Search emoji"
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchInput($event)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="mb-3 flex cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground">
|
||||
<ng-icon
|
||||
name="lucideUpload"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span>{{ uploading() ? 'Uploading...' : 'Upload emoji' }}</span>
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
[accept]="acceptAttribute"
|
||||
[disabled]="uploading()"
|
||||
(change)="uploadEmoji($event)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@if (uploadError()) {
|
||||
<div class="mb-3 rounded-md border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
|
||||
{{ uploadError() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showEmptySearchState()) {
|
||||
<div class="mb-3 rounded-md border border-border/70 bg-secondary/20 px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
No emoji match your search.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filteredUnicodeEntries().length > 0) {
|
||||
<div class="mb-3 grid grid-cols-6 gap-1">
|
||||
@for (entry of filteredUnicodeEntries(); track entry.emoji) {
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-10 w-10 place-items-center rounded transition-colors hover:bg-secondary"
|
||||
(click)="selectUnicode(entry.emoji)"
|
||||
>
|
||||
<span class="text-[2rem] leading-none">{{ entry.emoji }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filteredCustomEmojis().length > 0) {
|
||||
<div class="grid max-h-44 grid-cols-6 gap-1 overflow-y-auto pr-1">
|
||||
@for (emoji of filteredCustomEmojis(); track emoji.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-10 w-10 place-items-center rounded transition-colors hover:bg-secondary"
|
||||
[title]="emoji.name"
|
||||
data-custom-emoji-library
|
||||
[attr.data-custom-emoji-id]="emoji.id"
|
||||
(click)="selectCustom(emoji)"
|
||||
>
|
||||
<img
|
||||
[src]="emoji.dataUrl"
|
||||
[alt]="emoji.name"
|
||||
class="h-10 w-10 object-contain"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
ElementRef,
|
||||
Injector,
|
||||
computed,
|
||||
runInInjectionContext,
|
||||
ɵChangeDetectionScheduler as ChangeDetectionScheduler,
|
||||
ɵEffectScheduler as EffectScheduler
|
||||
} from '@angular/core';
|
||||
import { CustomEmoji } from '../../../../shared-kernel';
|
||||
import { CustomEmojiService } from '../../application/custom-emoji.service';
|
||||
import { CustomEmojiPickerComponent } from './custom-emoji-picker.component';
|
||||
|
||||
const savedEmojis: CustomEmoji[] = [
|
||||
{
|
||||
id: 'party-id',
|
||||
name: 'party-parrot',
|
||||
creatorUserId: 'user-1',
|
||||
dataUrl: 'data:image/webp;base64,party',
|
||||
hash: 'party-hash',
|
||||
mime: 'image/webp',
|
||||
size: 128,
|
||||
createdAt: 100,
|
||||
updatedAt: 100,
|
||||
savedByUser: true
|
||||
},
|
||||
{
|
||||
id: 'wave-id',
|
||||
name: 'wave',
|
||||
creatorUserId: 'user-1',
|
||||
dataUrl: 'data:image/webp;base64,wave',
|
||||
hash: 'wave-hash',
|
||||
mime: 'image/webp',
|
||||
size: 128,
|
||||
createdAt: 100,
|
||||
updatedAt: 100,
|
||||
savedByUser: true
|
||||
}
|
||||
];
|
||||
|
||||
function createEffectSchedulerMock() {
|
||||
const scheduledEffects = new Set<{ dirty: boolean; run: () => void }>();
|
||||
|
||||
return {
|
||||
add: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
|
||||
scheduledEffects.add(scheduledEffect);
|
||||
}),
|
||||
flush: vi.fn(() => {
|
||||
for (const scheduledEffect of scheduledEffects) {
|
||||
if (scheduledEffect.dirty) {
|
||||
scheduledEffect.run();
|
||||
}
|
||||
}
|
||||
}),
|
||||
remove: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
|
||||
scheduledEffects.delete(scheduledEffect);
|
||||
}),
|
||||
schedule: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
|
||||
scheduledEffects.add(scheduledEffect);
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
describe('CustomEmojiPickerComponent', () => {
|
||||
function createComponent(
|
||||
hostContainsTarget: boolean,
|
||||
emojis: CustomEmoji[] = []
|
||||
): CustomEmojiPickerComponent {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
CustomEmojiPickerComponent,
|
||||
{
|
||||
provide: ChangeDetectionScheduler,
|
||||
useValue: { notify: vi.fn() }
|
||||
},
|
||||
{
|
||||
provide: EffectScheduler,
|
||||
useValue: createEffectSchedulerMock()
|
||||
},
|
||||
{
|
||||
provide: ElementRef,
|
||||
useValue: new ElementRef({
|
||||
contains: () => hostContainsTarget
|
||||
})
|
||||
},
|
||||
{
|
||||
provide: CustomEmojiService,
|
||||
useValue: {
|
||||
loadForUser: vi.fn(async () => undefined),
|
||||
shortcutEntries: computed(() => []),
|
||||
emojis: computed(() => emojis)
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return runInInjectionContext(injector, () => injector.get(CustomEmojiPickerComponent));
|
||||
}
|
||||
|
||||
it('emits dismissed when clicking outside the picker', () => {
|
||||
const component = createComponent(false);
|
||||
const dismissed = vi.fn();
|
||||
|
||||
component.dismissed.subscribe(() => dismissed());
|
||||
component.onDocumentClick({ target: {} } as MouseEvent);
|
||||
|
||||
expect(dismissed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not emit dismissed when clicking inside the picker', () => {
|
||||
const component = createComponent(true);
|
||||
const dismissed = vi.fn();
|
||||
|
||||
component.dismissed.subscribe(() => dismissed());
|
||||
component.onDocumentClick({ target: {} } as MouseEvent);
|
||||
|
||||
expect(dismissed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters unicode and custom emoji when the picker search query changes', () => {
|
||||
const component = createComponent(true, savedEmojis);
|
||||
|
||||
component.setSearchQuery('heart');
|
||||
|
||||
expect(component.filteredUnicodeEntries().map((entry) => entry.emoji)).toEqual(['❤️']);
|
||||
expect(component.filteredCustomEmojis()).toEqual([]);
|
||||
expect(component.showEmptySearchState()).toBe(false);
|
||||
|
||||
component.setSearchQuery('par');
|
||||
|
||||
expect(component.filteredUnicodeEntries().map((entry) => entry.emoji)).toEqual(['🎉']);
|
||||
expect(component.filteredCustomEmojis().map((emoji) => emoji.id)).toEqual(['party-id']);
|
||||
});
|
||||
|
||||
it('shows an empty search state when no emoji matches the query', () => {
|
||||
const component = createComponent(true, savedEmojis);
|
||||
|
||||
component.setSearchQuery('missing-emoji');
|
||||
|
||||
expect(component.showEmptySearchState()).toBe(true);
|
||||
});
|
||||
|
||||
it('clears the search query when the picker is dismissed', () => {
|
||||
const component = createComponent(true, savedEmojis);
|
||||
|
||||
component.setSearchQuery('party');
|
||||
component.dismiss();
|
||||
|
||||
expect(component.searchQuery()).toBe('');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucidePlus,
|
||||
lucideSearch,
|
||||
lucideSmile,
|
||||
lucideUpload,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { CustomEmoji, EmojiShortcutEntry } from '../../../../shared-kernel';
|
||||
import { CustomEmojiService } from '../../application/custom-emoji.service';
|
||||
import {
|
||||
CUSTOM_EMOJI_ACCEPT_ATTRIBUTE,
|
||||
UNICODE_EMOJI_PICKER_ENTRIES,
|
||||
buildCustomEmojiMessageToken,
|
||||
filterCustomEmojisForPicker,
|
||||
filterUnicodeEmojiPickerEntries,
|
||||
normalizeEmojiPickerSearchQuery
|
||||
} from '../../domain/custom-emoji.rules';
|
||||
|
||||
@Component({
|
||||
selector: 'app-custom-emoji-picker',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucidePlus, lucideSearch, lucideSmile, lucideUpload, lucideX })],
|
||||
templateUrl: './custom-emoji-picker.component.html'
|
||||
})
|
||||
export class CustomEmojiPickerComponent {
|
||||
private readonly customEmoji = inject(CustomEmojiService);
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
readonly currentUserId = input<string | null>(null);
|
||||
readonly compact = input(true);
|
||||
|
||||
readonly emojiSelected = output<string>();
|
||||
readonly dismissed = output();
|
||||
|
||||
readonly acceptAttribute = CUSTOM_EMOJI_ACCEPT_ATTRIBUTE;
|
||||
readonly modalOpen = signal(false);
|
||||
readonly uploadError = signal<string | null>(null);
|
||||
readonly uploading = signal(false);
|
||||
readonly shortcuts = this.customEmoji.shortcutEntries;
|
||||
readonly customEmojis = this.customEmoji.emojis;
|
||||
readonly searchQuery = signal('');
|
||||
readonly filteredUnicodeEntries = computed(() => filterUnicodeEmojiPickerEntries(
|
||||
UNICODE_EMOJI_PICKER_ENTRIES,
|
||||
this.searchQuery()
|
||||
));
|
||||
readonly filteredCustomEmojis = computed(() => filterCustomEmojisForPicker(
|
||||
this.customEmojis(),
|
||||
this.searchQuery()
|
||||
));
|
||||
readonly hasActiveSearch = computed(() => normalizeEmojiPickerSearchQuery(this.searchQuery()).length > 0);
|
||||
readonly showEmptySearchState = computed(() => this.hasActiveSearch()
|
||||
&& this.filteredUnicodeEntries().length === 0
|
||||
&& this.filteredCustomEmojis().length === 0);
|
||||
private readonly loadForUser = effect(() => {
|
||||
void this.customEmoji.loadForUser(this.currentUserId());
|
||||
});
|
||||
|
||||
setSearchQuery(query: string): void {
|
||||
this.searchQuery.set(query);
|
||||
}
|
||||
|
||||
onSearchInput(event: Event): void {
|
||||
this.setSearchQuery((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
selectShortcut(entry: EmojiShortcutEntry): void {
|
||||
this.customEmoji.recordUsage(entry, this.currentUserId());
|
||||
this.emojiSelected.emit(entry.kind === 'custom' ? buildCustomEmojiMessageToken(entry.emoji) : entry.emoji);
|
||||
}
|
||||
|
||||
selectUnicode(emoji: string): void {
|
||||
this.customEmoji.recordUsage({ kind: 'unicode',
|
||||
key: `unicode:${emoji}`,
|
||||
emoji,
|
||||
label: emoji }, this.currentUserId());
|
||||
|
||||
this.emojiSelected.emit(emoji);
|
||||
this.modalOpen.set(false);
|
||||
}
|
||||
|
||||
selectCustom(emoji: CustomEmoji): void {
|
||||
this.customEmoji.recordUsage({ kind: 'custom',
|
||||
key: `custom:${emoji.id}`,
|
||||
emoji,
|
||||
label: emoji.name }, this.currentUserId());
|
||||
|
||||
this.emojiSelected.emit(buildCustomEmojiMessageToken(emoji));
|
||||
this.modalOpen.set(false);
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onDocumentClick(event: MouseEvent): void {
|
||||
const target = event.target;
|
||||
|
||||
if (target == null || this.host.nativeElement.contains(target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dismiss();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.dismiss();
|
||||
}
|
||||
|
||||
openModal(): void {
|
||||
this.modalOpen.set(true);
|
||||
}
|
||||
|
||||
closeModal(): void {
|
||||
this.dismiss();
|
||||
}
|
||||
|
||||
dismiss(): void {
|
||||
this.modalOpen.set(false);
|
||||
this.searchQuery.set('');
|
||||
this.dismissed.emit();
|
||||
}
|
||||
|
||||
async uploadEmoji(event: Event): Promise<void> {
|
||||
const inputElement = event.target as HTMLInputElement;
|
||||
const file = inputElement.files?.[0];
|
||||
const userId = this.currentUserId();
|
||||
|
||||
inputElement.value = '';
|
||||
|
||||
if (!file || !userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadError.set(null);
|
||||
this.uploading.set(true);
|
||||
|
||||
try {
|
||||
const emoji = await this.customEmoji.createFromFile(file, userId);
|
||||
|
||||
this.selectCustom(emoji);
|
||||
} catch (error) {
|
||||
this.uploadError.set(error instanceof Error ? error.message : 'Unable to upload emoji.');
|
||||
} finally {
|
||||
this.uploading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user