Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 12 additions & 19 deletions frontend/src/lib/watchtower/notifications.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,34 @@ import { STALE_TIMEOUT_MINUTES } from "$lib/watchtower/types";
import { GetUnreadNotifications, MarkNotificationAsRead } from "$lib/wailsjs/go/watchtower/Service";

export class NotificationsService {
readonly #notifications: notifications.Notification[];
readonly #unread: notifications.Notification[];
#lastSync?: number;

constructor() {
this.#notifications = $state([]);
this.#unread = $state([]);
}
/**
* Retrieves the unread notifications for the user.
*/
async getUnread() {
if (this.isStale()) {
await this.forceGetNotifications();
await this.forceGetUnread();
}

return this.#notifications;
return this.#unread;
}

/**
* Marks a notification as read based on the provided notification ID.
*/
async markAsRead(id: number) {
await MarkNotificationAsRead(id);
const idx = this.#notifications.findIndex((n) => n.id === id);
const idx = this.#unread.findIndex((n) => n.id === id);
if (idx < 0) {
return;
}

this.#notifications.splice(
idx,
1,
new notifications.Notification({
...this.#notifications[idx],
status: "read"
})
);
this.#unread.splice(idx, 1);
}

/**
Expand All @@ -49,22 +42,22 @@ export class NotificationsService {
await this.markAsRead(notification.id);
}

await this.forceGetNotifications();
await this.forceGetUnread();
}

/**
* Forces the retrieval of unread notifications and updates the internal state with the fetched notifications.
*/
private async forceGetNotifications() {
private async forceGetUnread() {
const notifications = (await GetUnreadNotifications()) ?? [];
this.updateInternalNotifications(notifications);
this.updateInternalUnread(notifications);
}

/**
* Updates the internal notifications by replacing the current list with the provided notifications array.
*/
private updateInternalNotifications(notifications: notifications.Notification[]) {
this.#notifications.splice(0, this.#notifications.length, ...notifications);
private updateInternalUnread(notifications: notifications.Notification[]) {
this.#unread.splice(0, this.#unread.length, ...notifications);
this.#lastSync = Date.now();
}

Expand All @@ -73,7 +66,7 @@ export class NotificationsService {
* and the presence of notifications.
*/
private isStale() {
if (!this.#lastSync || this.#notifications.length === 0) {
if (!this.#lastSync || this.#unread.length === 0) {
return true;
}

Expand Down
104 changes: 55 additions & 49 deletions frontend/src/routes/(orgs)/notifications/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script lang="ts">
import { fade, fly } from "svelte/transition";
import { flip } from "svelte/animate";
import { PageTitle } from "$components/page_title";
import { EmptySlate } from "$components/empty_slate";
import * as Card from "$lib/components/ui/card";
Expand All @@ -11,17 +13,19 @@
import { resolve } from "$app/paths";
import { SimpleFilter } from "$lib/hooks/filters.svelte";
import { BaseInput } from "$components/base_input";
import { notifications } from "$lib/wailsjs/go/models";

const { data } = $props();

let notifications = $derived(data.notifications);
let unreadNotifications = $derived(data.notifications);

let searchState = $state("");
const searchFilter = $derived(
new SimpleFilter(notifications, (item) => {
return item.content.toLowerCase().includes(searchState.toLowerCase());
})
);

function applySearchFilter(notification: notifications.Notification) {
return notification.content.toLowerCase().includes(searchState.toLowerCase());
}

const searchFilter = $derived(new SimpleFilter(unreadNotifications, applySearchFilter));

async function markAllAsRead() {
await notificationSvc.markAllAsRead();
Expand All @@ -42,15 +46,15 @@
<div class="page-container">
<div class="flex items-center justify-between">
<PageTitle title="Notifications" subtitle="Unread notifications across all orgs" />
{#if notifications.length > 0}
{#if searchFilter.data.length > 0}
<Button variant="outline" size="sm" onclick={markAllAsRead}>
<Check class="mr-2 h-4 w-4" />
Mark all read
</Button>
{/if}
</div>

{#if notifications.length === 0}
{#if unreadNotifications.length === 0}
<EmptySlate
title="No new notifications"
description="You're all caught up! Check back later for updates."
Expand All @@ -68,52 +72,54 @@
</div>
<div class="flex flex-col gap-2">
{#each searchFilter.data as notification (notification.id)}
<Card.Root
onclick={(e) => {
e.preventDefault();
<div animate:flip in:fade={{ duration: 200 }} out:fly={{ x: 100, duration: 200 }}>
<Card.Root
onclick={(e) => {
e.preventDefault();

routeToOrgDashboard(notification.organisation_id);
}}
class="overflow-hidden p-0 hover:cursor-pointer"
>
<div class="flex items-center gap-4 p-4">
<div class="shrink-0">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10"
>
<Bell class="h-5 w-5 text-primary" />
routeToOrgDashboard(notification.organisation_id);
}}
class="overflow-hidden p-0 hover:cursor-pointer"
>
<div class="flex items-center gap-4 p-4">
<div class="shrink-0">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10"
>
<Bell class="h-5 w-5 text-primary" />
</div>
</div>
</div>
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-2">
<Badge variant="secondary" class="text-xs">
{toSentenceCase(notification.type)}
</Badge>
<span class="text-xs text-muted-foreground">
{formatDate(notification.created_at)}
</span>
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-2">
<Badge variant="secondary" class="text-xs">
{toSentenceCase(notification.type)}
</Badge>
<span class="text-xs text-muted-foreground">
{formatDate(notification.created_at)}
</span>
</div>
<p class="truncate text-sm font-medium">
{notification.content}
</p>
</div>
<p class="truncate text-sm font-medium">
{notification.content}
</p>
</div>
<div class="shrink-0">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={(e) => {
e.stopImmediatePropagation();
<div class="shrink-0">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={(e) => {
e.stopImmediatePropagation();

markAsRead(notification.id);
}}
title="Mark as read"
>
<Check class="h-4 w-4" />
</Button>
markAsRead(notification.id);
}}
title="Mark as read"
>
<Check class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</Card.Root>
</Card.Root>
</div>
{/each}
</div>
{/if}
Expand Down
3 changes: 2 additions & 1 deletion internal/watchtower/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ func (s *Service) MarkNotificationAsRead(notificationID int64) error {
}

func (s *Service) DeleteOldNotifications() error {
return s.notificationSvc.DeleteNotificationsByDate(s.ctx, time.Now())
nintyDaysAgo := time.Now().AddDate(0, 0, -90)
return s.notificationSvc.DeleteNotificationsByDate(s.ctx, nintyDaysAgo)
}
8 changes: 0 additions & 8 deletions internal/watchtower/notifications_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,8 @@ func TestService_Notifications(t *testing.T) {
err = s.MarkNotificationAsRead(unread.ID)
odize.AssertNoError(t, err)

// DeleteOldNotifications uses time.Now() and the query uses created_at < ?.
// Since we just created it in the same second, it might not be deleted.
// Let's wait 1 second to ensure created_at < time.Now().
time.Sleep(1100 * time.Millisecond)

err = s.DeleteOldNotifications()
odize.AssertNoError(t, err)

_, err = s.notificationSvc.GetNotificationByExternalID(ctx, "test-external-id-3")
odize.AssertError(t, err)
}).
Test("CreateUnreadPRNotification should create notifications for recent PRs", func(t *testing.T) {
// Setup: Org, Product, Repo, PR
Expand Down