feat: notifications in the app and, optionally, by e-mail
Derived from events and kept per person: whoever a ticket is assigned to, whoever reports or works on it when it moves, fails QA or gets a comment, and whoever joins a project. Never about what you did yourself, once per event. They arrive live through a subscription to a bell in the header, and are copied by e-mail to whoever turns it on when SMTP_URL is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,2 @@
|
||||
export * from './lib/tracker.routes';
|
||||
export * from './lib/ui/notifications-bell';
|
||||
|
||||
223
libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts
Normal file
223
libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiBadgeComponent,
|
||||
PiIconButtonComponent,
|
||||
PiIconComponent,
|
||||
PiSlideToggleComponent,
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import { InboxFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
Notification,
|
||||
NotificationKind,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { timeAgo } from './copy';
|
||||
|
||||
const ICON: Record<NotificationKind, string> = {
|
||||
[NotificationKind.ASSIGNED]: 'user-profile',
|
||||
[NotificationKind.STATUS_CHANGED]: 'arrow-forward',
|
||||
[NotificationKind.QA_FAILED]: 'exclamation',
|
||||
[NotificationKind.COMMENTED]: 'chat',
|
||||
[NotificationKind.MENTIONED]: 'chat',
|
||||
[NotificationKind.WATCHED]: 'focus',
|
||||
[NotificationKind.ADDED_TO_PROJECT]: 'briefcase',
|
||||
[NotificationKind.REMINDER]: 'calendar',
|
||||
};
|
||||
|
||||
/**
|
||||
* The bell in the header: how many unread, and the list in a side panel.
|
||||
* Opening one goes to its ticket (or project) and marks it read.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-notifications-bell',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiActionButtonComponent,
|
||||
PiBadgeComponent,
|
||||
PiIconButtonComponent,
|
||||
PiIconComponent,
|
||||
PiSlideToggleComponent,
|
||||
],
|
||||
template: `
|
||||
<pi-badge
|
||||
[value]="inbox.unread()"
|
||||
[max]="99"
|
||||
color="warn"
|
||||
size="small"
|
||||
[isHidden]="!inbox.hasUnread()"
|
||||
>
|
||||
<pi-icon-button
|
||||
icon="bell"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
[attr.aria-label]="
|
||||
inbox.unread()
|
||||
? 'Notificaciones: ' + inbox.unread() + ' sin leer'
|
||||
: 'Notificaciones'
|
||||
"
|
||||
(execute)="open()"
|
||||
/>
|
||||
</pi-badge>
|
||||
|
||||
<ng-template #panel>
|
||||
<div class="ts-inbox">
|
||||
<div class="ts-inbox__actions">
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Marcar todo como leído"
|
||||
[disabled]="!inbox.hasUnread()"
|
||||
(execute)="inbox.markRead()"
|
||||
/>
|
||||
@if (inbox.settings()?.emailAvailable) {
|
||||
<pi-slide-toggle
|
||||
label="Copia por correo"
|
||||
[checked]="inbox.settings()?.email ?? false"
|
||||
(checkedChange)="inbox.setEmail($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@if (inbox.items().length) {
|
||||
<ul class="ts-inbox__list">
|
||||
@for (item of inbox.items(); track item.id) {
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-inbox__item"
|
||||
[class.is-unread]="!item.readAt"
|
||||
(click)="go(item)"
|
||||
>
|
||||
<span class="ts-inbox__icon" aria-hidden="true">
|
||||
<pi-icon [icon]="icon(item)" size="small" />
|
||||
</span>
|
||||
<span class="ts-inbox__text">
|
||||
<span class="ts-inbox__summary">{{ item.summary }}</span>
|
||||
<span class="ts-inbox__meta"
|
||||
>{{ item.projectKey }} · {{ ago(item.createdAt) }}</span
|
||||
>
|
||||
</span>
|
||||
@if (!item.readAt) {
|
||||
<span class="ts-inbox__dot" aria-label="Sin leer"></span>
|
||||
}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="ts-inbox__empty">
|
||||
Nada nuevo. Te avisaremos cuando te asignen algo, comenten en tus
|
||||
tickets o los muevan.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-inbox {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.ts-inbox__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.ts-inbox__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.ts-inbox__item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ts-inbox__item:hover,
|
||||
.ts-inbox__item:focus-visible {
|
||||
background: rgba(127, 127, 127, 0.12);
|
||||
}
|
||||
.ts-inbox__item.is-unread .ts-inbox__summary {
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-inbox__icon {
|
||||
flex: none;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(127, 127, 127, 0.14);
|
||||
}
|
||||
.ts-inbox__text {
|
||||
display: grid;
|
||||
gap: 0.125rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ts-inbox__meta {
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ts-inbox__dot {
|
||||
flex: none;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: #a347e1;
|
||||
}
|
||||
.ts-inbox__empty {
|
||||
opacity: 0.7;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class NotificationsBell {
|
||||
protected readonly inbox = inject(InboxFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly panel = viewChild.required<TemplateRef<unknown>>('panel');
|
||||
|
||||
protected readonly ago = timeAgo;
|
||||
protected icon = (item: Notification) => ICON[item.kind] ?? 'info';
|
||||
|
||||
protected open(): void {
|
||||
this.drawer.open('Notificaciones', this.panel(), '28rem');
|
||||
}
|
||||
|
||||
protected go(item: Notification): void {
|
||||
if (!item.readAt) this.inbox.markRead([item.id]);
|
||||
this.drawer.closeAll();
|
||||
const project = encodeURIComponent(item.projectKey);
|
||||
void this.router.navigateByUrl(
|
||||
item.issueKey
|
||||
? `/p/${project}/issues/${encodeURIComponent(item.issueKey)}`
|
||||
: `/p/${project}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user