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:
@@ -17,6 +17,7 @@ import { MatIconRegistry } from '@angular/material/icon';
|
||||
export const APP_ICONS: Record<string, string> = {
|
||||
'arrow-forward':
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h15M13 6l6 6-6 6"/></svg>',
|
||||
bell: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15L6 16ZM10 20.5a2 2 0 0 0 4 0"/></svg>',
|
||||
briefcase:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M9 7V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2M3 12h18"/></svg>',
|
||||
buildings:
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './lib/facades/board.facade';
|
||||
export * from './lib/facades/issue.facade';
|
||||
export * from './lib/facades/home.facade';
|
||||
export * from './lib/facades/explore.facade';
|
||||
export * from './lib/facades/inbox.facade';
|
||||
|
||||
37
libs/web/tracker/application/src/lib/events/inbox.events.ts
Normal file
37
libs/web/tracker/application/src/lib/events/inbox.events.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { type } from '@ngrx/signals';
|
||||
import { eventGroup } from '@ngrx/signals/events';
|
||||
import {
|
||||
Notification,
|
||||
NotificationSettings,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
/* Notifications are "the inbox" here, so as not to clash with `Notifications` (the toasts port). */
|
||||
|
||||
export const inboxPageEvents = eventGroup({
|
||||
source: 'Inbox',
|
||||
events: {
|
||||
/** Signed in: load, and follow new ones. */
|
||||
opened: type<void>(),
|
||||
/** Signed out: forget everything. */
|
||||
closed: type<void>(),
|
||||
markReadRequested: type<{ ids?: string[] }>(),
|
||||
emailToggled: type<boolean>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const inboxApiEvents = eventGroup({
|
||||
source: 'Inbox API',
|
||||
events: {
|
||||
loaded: type<{ items: Notification[]; unread: number }>(),
|
||||
settingsLoaded: type<NotificationSettings>(),
|
||||
markedRead: type<{ ids?: string[]; at: string }>(),
|
||||
failed: type<string>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const inboxRealtimeEvents = eventGroup({
|
||||
source: 'Inbox Realtime',
|
||||
events: {
|
||||
arrived: type<Notification>(),
|
||||
},
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AttachmentsGateway,
|
||||
CommentsGateway,
|
||||
IssuesGateway,
|
||||
NotificationsGateway,
|
||||
Project,
|
||||
ProjectRole,
|
||||
ProjectsGateway,
|
||||
@@ -21,6 +22,7 @@ import { projectPageEvents } from '../events/project.events';
|
||||
import { Notifications } from '../notifications';
|
||||
import { BoardFacade, provideBoardFacade } from './board.facade';
|
||||
import { HomeFacade } from './home.facade';
|
||||
import { InboxFacade } from './inbox.facade';
|
||||
import { IssueFacade, provideIssueFacade } from './issue.facade';
|
||||
import { ProjectFacade, provideProjectFacade } from './project.facade';
|
||||
|
||||
@@ -307,3 +309,84 @@ describe('HomeFacade', () => {
|
||||
expect(home.releases().map((r) => r.name)).toEqual(['v1.4.0']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InboxFacade', () => {
|
||||
const note = (id: string, readAt: string | null = null) =>
|
||||
({
|
||||
id,
|
||||
kind: 'ASSIGNED',
|
||||
projectId: 'p1',
|
||||
projectKey: 'WEB',
|
||||
issueId: 'i1',
|
||||
issueKey: 'WEB-1',
|
||||
actor: { id: 'ana', name: 'Ana' },
|
||||
summary: `Aviso ${id}`,
|
||||
readAt,
|
||||
createdAt: '2026-10-08T10:00:00.000Z',
|
||||
}) as never;
|
||||
|
||||
function inbox(signedIn = true) {
|
||||
const arrivals = new Subject<never>();
|
||||
const gateway = {
|
||||
mine: vi.fn(() =>
|
||||
of({ items: [note('n1'), note('n0', 'x')], unread: 1 }),
|
||||
),
|
||||
settings: vi.fn(() => of({ email: false, emailAvailable: true })),
|
||||
markRead: vi.fn(() => of(undefined)),
|
||||
updateSettings: vi.fn((email: boolean) =>
|
||||
of({ email, emailAvailable: true }),
|
||||
),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: NotificationsGateway, useValue: gateway },
|
||||
{
|
||||
provide: TrackerRealtimeGateway,
|
||||
useValue: { notifications: () => arrivals },
|
||||
},
|
||||
{
|
||||
provide: Notifications,
|
||||
useValue: { success: vi.fn(), error: vi.fn() },
|
||||
},
|
||||
{
|
||||
provide: AuthFacade,
|
||||
useValue: { isAuthenticated: signal(signedIn) },
|
||||
},
|
||||
],
|
||||
});
|
||||
const facade = TestBed.inject(InboxFacade);
|
||||
TestBed.tick();
|
||||
return { facade, gateway, arrivals };
|
||||
}
|
||||
|
||||
it('loads once signed in and counts what arrives live', () => {
|
||||
const { facade, arrivals } = inbox();
|
||||
|
||||
expect(facade.unread()).toBe(1);
|
||||
arrivals.next(note('n2'));
|
||||
arrivals.next(note('n2'));
|
||||
|
||||
expect(facade.unread()).toBe(2);
|
||||
expect(facade.items().map((n) => n.id)).toEqual(['n2', 'n1', 'n0']);
|
||||
});
|
||||
|
||||
it('marks one or all as read', () => {
|
||||
const { facade, gateway, arrivals } = inbox();
|
||||
arrivals.next(note('n2'));
|
||||
|
||||
facade.markRead(['n2']);
|
||||
expect(facade.unread()).toBe(1);
|
||||
facade.markRead();
|
||||
|
||||
expect(gateway.markRead).toHaveBeenLastCalledWith(undefined);
|
||||
expect(facade.unread()).toBe(0);
|
||||
expect(facade.items().every((n) => n.readAt)).toBe(true);
|
||||
});
|
||||
|
||||
it('asks nothing without a session', () => {
|
||||
const { gateway, facade } = inbox(false);
|
||||
|
||||
expect(gateway.mine).not.toHaveBeenCalled();
|
||||
expect(facade.items()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
37
libs/web/tracker/application/src/lib/facades/inbox.facade.ts
Normal file
37
libs/web/tracker/application/src/lib/facades/inbox.facade.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { effect, inject, Injectable } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import { inboxPageEvents } from '../events/inbox.events';
|
||||
import { InboxStore } from '../stores/inbox.store';
|
||||
|
||||
/**
|
||||
* The signed-in person's notifications for presenters: loaded and followed
|
||||
* live while there is a session, forgotten when it ends.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class InboxFacade {
|
||||
private readonly store = inject(InboxStore);
|
||||
private readonly dispatch = injectDispatch(inboxPageEvents);
|
||||
private readonly auth = inject(AuthFacade);
|
||||
|
||||
readonly items = this.store.items;
|
||||
readonly unread = this.store.unread;
|
||||
readonly hasUnread = this.store.hasUnread;
|
||||
readonly settings = this.store.settings;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.auth.isAuthenticated()) this.dispatch.opened();
|
||||
else this.dispatch.closed();
|
||||
});
|
||||
}
|
||||
|
||||
/** Without ids: all of them. */
|
||||
markRead(ids?: string[]): void {
|
||||
this.dispatch.markReadRequested({ ids });
|
||||
}
|
||||
|
||||
setEmail(enabled: boolean): void {
|
||||
this.dispatch.emailToggled(enabled);
|
||||
}
|
||||
}
|
||||
143
libs/web/tracker/application/src/lib/stores/inbox.store.ts
Normal file
143
libs/web/tracker/application/src/lib/stores/inbox.store.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { computed, inject } from '@angular/core';
|
||||
import { mapResponse } from '@ngrx/operators';
|
||||
import { signalStore, withComputed, withState } from '@ngrx/signals';
|
||||
import {
|
||||
Events,
|
||||
on,
|
||||
withEventHandlers,
|
||||
withReducer,
|
||||
} from '@ngrx/signals/events';
|
||||
import {
|
||||
Notification,
|
||||
NotificationSettings,
|
||||
NotificationsGateway,
|
||||
TrackerRealtimeGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
catchError,
|
||||
EMPTY,
|
||||
map,
|
||||
merge,
|
||||
mergeMap,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
inboxApiEvents,
|
||||
inboxPageEvents,
|
||||
inboxRealtimeEvents,
|
||||
} from '../events/inbox.events';
|
||||
import { describeError, Notifications } from '../notifications';
|
||||
|
||||
interface InboxState {
|
||||
items: Notification[];
|
||||
unread: number;
|
||||
settings: NotificationSettings | null;
|
||||
}
|
||||
|
||||
const LIMIT = 30;
|
||||
|
||||
/** The signed-in person's notifications, live. One for the whole app. */
|
||||
export const InboxStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState<InboxState>({ items: [], unread: 0, settings: null }),
|
||||
withComputed(({ unread }) => ({
|
||||
hasUnread: computed(() => unread() > 0),
|
||||
})),
|
||||
withReducer(
|
||||
on(inboxPageEvents.closed, () => ({
|
||||
items: [],
|
||||
unread: 0,
|
||||
settings: null,
|
||||
})),
|
||||
on(inboxApiEvents.loaded, ({ payload }) => payload),
|
||||
on(inboxApiEvents.settingsLoaded, ({ payload }) => ({ settings: payload })),
|
||||
on(inboxRealtimeEvents.arrived, ({ payload }, state) =>
|
||||
state.items.some((n) => n.id === payload.id)
|
||||
? {}
|
||||
: {
|
||||
items: [payload, ...state.items].slice(0, LIMIT),
|
||||
unread: state.unread + 1,
|
||||
},
|
||||
),
|
||||
on(inboxApiEvents.markedRead, ({ payload }, state) => {
|
||||
const ids = payload.ids ? new Set(payload.ids) : null;
|
||||
const items = state.items.map((n) =>
|
||||
!n.readAt && (!ids || ids.has(n.id)) ? { ...n, readAt: payload.at } : n,
|
||||
);
|
||||
const marked = state.items.filter(
|
||||
(n) => !n.readAt && (!ids || ids.has(n.id)),
|
||||
).length;
|
||||
return {
|
||||
items,
|
||||
unread: ids ? Math.max(0, state.unread - marked) : 0,
|
||||
};
|
||||
}),
|
||||
),
|
||||
withEventHandlers(
|
||||
(
|
||||
_store,
|
||||
events = inject(Events),
|
||||
gateway = inject(NotificationsGateway),
|
||||
realtime = inject(TrackerRealtimeGateway),
|
||||
toasts = inject(Notifications),
|
||||
) => {
|
||||
const closed$ = events.on(inboxPageEvents.closed);
|
||||
return {
|
||||
load$: events.on(inboxPageEvents.opened).pipe(
|
||||
switchMap(() =>
|
||||
gateway.mine(false, LIMIT).pipe(
|
||||
mapResponse({
|
||||
next: (page) => inboxApiEvents.loaded(page),
|
||||
error: (error) => inboxApiEvents.failed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
settings$: events.on(inboxPageEvents.opened).pipe(
|
||||
switchMap(() =>
|
||||
gateway.settings().pipe(
|
||||
map((settings) => inboxApiEvents.settingsLoaded(settings)),
|
||||
// Without settings the bell still works; only the e-mail switch hides.
|
||||
catchError(() => EMPTY),
|
||||
),
|
||||
),
|
||||
),
|
||||
live$: events.on(inboxPageEvents.opened).pipe(
|
||||
switchMap(() => realtime.notifications().pipe(takeUntil(closed$))),
|
||||
map((notification) => inboxRealtimeEvents.arrived(notification)),
|
||||
),
|
||||
markRead$: events.on(inboxPageEvents.markReadRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
gateway.markRead(payload.ids).pipe(
|
||||
mapResponse({
|
||||
next: () =>
|
||||
inboxApiEvents.markedRead({
|
||||
ids: payload.ids,
|
||||
at: new Date().toISOString(),
|
||||
}),
|
||||
error: (error) => inboxApiEvents.failed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
email$: events.on(inboxPageEvents.emailToggled).pipe(
|
||||
switchMap(({ payload }) =>
|
||||
gateway.updateSettings(payload).pipe(
|
||||
mapResponse({
|
||||
next: (settings) => inboxApiEvents.settingsLoaded(settings),
|
||||
error: (error) => inboxApiEvents.failed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
feedback$: merge(
|
||||
events
|
||||
.on(inboxApiEvents.failed)
|
||||
.pipe(tap(({ payload }) => toasts.error(payload))),
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -38,3 +38,15 @@ export enum ReleaseStatus {
|
||||
RELEASED = 'RELEASED',
|
||||
ARCHIVED = 'ARCHIVED',
|
||||
}
|
||||
|
||||
/** Why someone is told. */
|
||||
export enum NotificationKind {
|
||||
ASSIGNED = 'ASSIGNED',
|
||||
STATUS_CHANGED = 'STATUS_CHANGED',
|
||||
QA_FAILED = 'QA_FAILED',
|
||||
COMMENTED = 'COMMENTED',
|
||||
MENTIONED = 'MENTIONED',
|
||||
WATCHED = 'WATCHED',
|
||||
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
|
||||
REMINDER = 'REMINDER',
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
NotificationKind,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
ProjectRole,
|
||||
@@ -187,3 +188,25 @@ export interface CommandResult {
|
||||
key?: string | null;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** Something the signed-in person should know. */
|
||||
export interface Notification {
|
||||
id: string;
|
||||
kind: NotificationKind;
|
||||
projectId: string;
|
||||
projectKey: string;
|
||||
issueId: string | null;
|
||||
issueKey: string | null;
|
||||
actor: UserRef;
|
||||
summary: string;
|
||||
/** Null: unread. */
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
/** Copies by e-mail. */
|
||||
email: boolean;
|
||||
/** Whether this installation can send e-mail at all. */
|
||||
emailAvailable: boolean;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
Issue,
|
||||
IssuePage,
|
||||
IssueSummary,
|
||||
Notification,
|
||||
NotificationSettings,
|
||||
Project,
|
||||
ProjectAnalytics,
|
||||
ProjectStats,
|
||||
@@ -130,6 +132,18 @@ export abstract class UsersGateway {
|
||||
abstract search(term: string): Observable<UserDirectoryEntry[]>;
|
||||
}
|
||||
|
||||
/** The signed-in person's notifications. */
|
||||
export abstract class NotificationsGateway {
|
||||
abstract mine(
|
||||
unreadOnly: boolean,
|
||||
limit: number,
|
||||
): Observable<{ items: Notification[]; unread: number }>;
|
||||
/** Without ids: all of them. */
|
||||
abstract markRead(ids?: string[]): Observable<void>;
|
||||
abstract settings(): Observable<NotificationSettings>;
|
||||
abstract updateSettings(email: boolean): Observable<NotificationSettings>;
|
||||
}
|
||||
|
||||
/** Realtime (GraphQL subscriptions over WebSocket). */
|
||||
export interface IssueChanged {
|
||||
action: string;
|
||||
@@ -158,4 +172,5 @@ export abstract class TrackerRealtimeGateway {
|
||||
abstract releaseChanges(projectId: string): Observable<Release>;
|
||||
abstract projectChanges(projectId: string): Observable<ProjectChanged>;
|
||||
abstract myProjectsChanges(): Observable<ProjectChanged>;
|
||||
abstract notifications(): Observable<Notification>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
Notification,
|
||||
NotificationSettings,
|
||||
NotificationsGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
MarkNotificationsReadDocument,
|
||||
MyNotificationsDocument,
|
||||
NotificationSettingsDocument,
|
||||
UpdateNotificationSettingsDocument,
|
||||
} from '../graphql/generated';
|
||||
import { GraphqlOperations } from './graphql-operations';
|
||||
|
||||
@Injectable()
|
||||
export class GraphqlNotificationsGateway extends NotificationsGateway {
|
||||
private readonly gql = inject(GraphqlOperations);
|
||||
|
||||
mine(
|
||||
unreadOnly: boolean,
|
||||
limit: number,
|
||||
): Observable<{ items: Notification[]; unread: number }> {
|
||||
return this.gql.query(MyNotificationsDocument, { unreadOnly, limit }).pipe(
|
||||
map(({ myNotifications }) => ({
|
||||
unread: myNotifications.unread,
|
||||
items: myNotifications.items as Notification[],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
markRead(ids?: string[]): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(MarkNotificationsReadDocument, { ids: ids ?? null })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
settings(): Observable<NotificationSettings> {
|
||||
return this.gql
|
||||
.query(NotificationSettingsDocument)
|
||||
.pipe(map((d) => ({ ...d.notificationSettings })));
|
||||
}
|
||||
|
||||
updateSettings(email: boolean): Observable<NotificationSettings> {
|
||||
return this.gql
|
||||
.mutate(UpdateNotificationSettingsDocument, { email })
|
||||
.pipe(map((d) => ({ ...d.updateNotificationSettings })));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Activity,
|
||||
CommentChanged,
|
||||
IssueChanged,
|
||||
Notification,
|
||||
ProjectChanged,
|
||||
Release,
|
||||
TrackerRealtimeGateway,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
CommentChangedDocument,
|
||||
IssueChangedDocument,
|
||||
MyProjectsChangedDocument,
|
||||
NotificationAddedDocument,
|
||||
ProjectChangedDocument,
|
||||
ReleaseChangedDocument,
|
||||
} from '../graphql/generated';
|
||||
@@ -98,6 +100,14 @@ export class GraphqlRealtimeGateway extends TrackerRealtimeGateway {
|
||||
.pipe(map((d) => toProjectChanged(d.myProjectsChanged))),
|
||||
);
|
||||
}
|
||||
|
||||
notifications(): Observable<Notification> {
|
||||
return this.signedIn(() =>
|
||||
this.gql
|
||||
.subscribe(NotificationAddedDocument)
|
||||
.pipe(map((d) => d.notificationAdded as Notification)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const toProjectChanged = (change: {
|
||||
|
||||
@@ -96,6 +96,17 @@ export type LinkIssuesInput = {
|
||||
type: IssueLinkType;
|
||||
};
|
||||
|
||||
/** Why someone is told */
|
||||
export type NotificationKind =
|
||||
| 'ADDED_TO_PROJECT'
|
||||
| 'ASSIGNED'
|
||||
| 'COMMENTED'
|
||||
| 'MENTIONED'
|
||||
| 'QA_FAILED'
|
||||
| 'REMINDER'
|
||||
| 'STATUS_CHANGED'
|
||||
| 'WATCHED';
|
||||
|
||||
export type ProjectMemberInput = {
|
||||
projectId: string;
|
||||
role?: ProjectRole;
|
||||
@@ -524,6 +535,83 @@ export type MyIssuesQuery = {
|
||||
};
|
||||
};
|
||||
|
||||
export type NotificationFieldsFragment = {
|
||||
id: string;
|
||||
kind: NotificationKind;
|
||||
projectId: string;
|
||||
projectKey: string;
|
||||
issueId: string | null;
|
||||
issueKey: string | null;
|
||||
summary: string;
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
actor: { id: string; name: string };
|
||||
};
|
||||
|
||||
export type MyNotificationsQueryVariables = Exact<{
|
||||
unreadOnly: boolean;
|
||||
limit: number;
|
||||
}>;
|
||||
|
||||
export type MyNotificationsQuery = {
|
||||
myNotifications: {
|
||||
unread: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
kind: NotificationKind;
|
||||
projectId: string;
|
||||
projectKey: string;
|
||||
issueId: string | null;
|
||||
issueKey: string | null;
|
||||
summary: string;
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
actor: { id: string; name: string };
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export type NotificationSettingsQueryVariables = Exact<{
|
||||
[key: string]: never;
|
||||
}>;
|
||||
|
||||
export type NotificationSettingsQuery = {
|
||||
notificationSettings: { email: boolean; emailAvailable: boolean };
|
||||
};
|
||||
|
||||
export type MarkNotificationsReadMutationVariables = Exact<{
|
||||
ids?: Array<string> | string | null | undefined;
|
||||
}>;
|
||||
|
||||
export type MarkNotificationsReadMutation = { markNotificationsRead: boolean };
|
||||
|
||||
export type UpdateNotificationSettingsMutationVariables = Exact<{
|
||||
email: boolean;
|
||||
}>;
|
||||
|
||||
export type UpdateNotificationSettingsMutation = {
|
||||
updateNotificationSettings: { email: boolean; emailAvailable: boolean };
|
||||
};
|
||||
|
||||
export type NotificationAddedSubscriptionVariables = Exact<{
|
||||
[key: string]: never;
|
||||
}>;
|
||||
|
||||
export type NotificationAddedSubscription = {
|
||||
notificationAdded: {
|
||||
id: string;
|
||||
kind: NotificationKind;
|
||||
projectId: string;
|
||||
projectKey: string;
|
||||
issueId: string | null;
|
||||
issueKey: string | null;
|
||||
summary: string;
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
actor: { id: string; name: string };
|
||||
};
|
||||
};
|
||||
|
||||
export type MyProjectsQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type MyProjectsQuery = {
|
||||
@@ -1559,6 +1647,61 @@ export const CommandResultFieldsFragmentDoc = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<CommandResultFieldsFragment, unknown>;
|
||||
export const NotificationFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'NotificationFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Notification' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectKey' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'readAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'actor' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<NotificationFieldsFragment, unknown>;
|
||||
export const BoardDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -3155,6 +3298,358 @@ export const MyIssuesDocument = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<MyIssuesQuery, MyIssuesQueryVariables>;
|
||||
export const MyNotificationsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'MyNotifications' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'unreadOnly' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'limit' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'myNotifications' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'unreadOnly' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'unreadOnly' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'limit' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'limit' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'unread' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'items' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'NotificationFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'NotificationFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Notification' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectKey' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'readAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'actor' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
MyNotificationsQuery,
|
||||
MyNotificationsQueryVariables
|
||||
>;
|
||||
export const NotificationSettingsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'NotificationSettings' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'notificationSettings' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'emailAvailable' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
NotificationSettingsQuery,
|
||||
NotificationSettingsQueryVariables
|
||||
>;
|
||||
export const MarkNotificationsReadDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'MarkNotificationsRead' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'ids' } },
|
||||
type: {
|
||||
kind: 'ListType',
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'markNotificationsRead' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'ids' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'ids' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
MarkNotificationsReadMutation,
|
||||
MarkNotificationsReadMutationVariables
|
||||
>;
|
||||
export const UpdateNotificationSettingsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'UpdateNotificationSettings' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'email' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'updateNotificationSettings' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'email' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'email' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'emailAvailable' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
UpdateNotificationSettingsMutation,
|
||||
UpdateNotificationSettingsMutationVariables
|
||||
>;
|
||||
export const NotificationAddedDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'subscription',
|
||||
name: { kind: 'Name', value: 'NotificationAdded' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'notificationAdded' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'NotificationFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'NotificationFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Notification' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectKey' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'readAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'actor' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
NotificationAddedSubscription,
|
||||
NotificationAddedSubscriptionVariables
|
||||
>;
|
||||
export const MyProjectsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
fragment NotificationFields on Notification {
|
||||
id
|
||||
kind
|
||||
projectId
|
||||
projectKey
|
||||
issueId
|
||||
issueKey
|
||||
summary
|
||||
readAt
|
||||
createdAt
|
||||
actor {
|
||||
...UserRefFields
|
||||
}
|
||||
}
|
||||
|
||||
query MyNotifications($unreadOnly: Boolean!, $limit: Int!) {
|
||||
myNotifications(unreadOnly: $unreadOnly, limit: $limit) {
|
||||
unread
|
||||
items {
|
||||
...NotificationFields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query NotificationSettings {
|
||||
notificationSettings {
|
||||
email
|
||||
emailAvailable
|
||||
}
|
||||
}
|
||||
|
||||
mutation MarkNotificationsRead($ids: [ID!]) {
|
||||
markNotificationsRead(ids: $ids)
|
||||
}
|
||||
|
||||
mutation UpdateNotificationSettings($email: Boolean!) {
|
||||
updateNotificationSettings(email: $email) {
|
||||
email
|
||||
emailAvailable
|
||||
}
|
||||
}
|
||||
|
||||
subscription NotificationAdded {
|
||||
notificationAdded {
|
||||
...NotificationFields
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AttachmentsGateway,
|
||||
CommentsGateway,
|
||||
IssuesGateway,
|
||||
NotificationsGateway,
|
||||
ProjectsGateway,
|
||||
ReleasesGateway,
|
||||
TrackerRealtimeGateway,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
GraphqlUsersGateway,
|
||||
} from './adapters/graphql-collaboration.gateways';
|
||||
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
|
||||
import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway';
|
||||
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
|
||||
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
|
||||
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
|
||||
@@ -33,6 +35,7 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||
{ provide: ActivityGateway, useClass: GraphqlActivityGateway },
|
||||
{ provide: AttachmentsGateway, useClass: HttpAttachmentsGateway },
|
||||
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
|
||||
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
|
||||
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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