Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import org.eclipse.ui.PlatformUI;

import software.aws.toolkits.eclipse.amazonq.broker.events.QDeveloperProfileState;
import software.aws.toolkits.eclipse.amazonq.notifications.NotificationPollingService;
import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
import software.aws.toolkits.eclipse.amazonq.providers.browser.AmazonQBrowserProvider;
import software.aws.toolkits.eclipse.amazonq.telemetry.ToolkitTelemetryProvider;
Expand Down Expand Up @@ -83,6 +84,7 @@ private void schedulePostStartupJobs() {
Display.getDefault().asyncExec(() -> attachAutoTriggerListenersIfApplicable());
Display.getDefault().asyncExec(() -> showKiroSunsetNotification());
checkForUpdates();
NotificationPollingService.getInstance().start();
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.eclipse.amazonq.notifications;

import java.util.List;

import org.eclipse.jface.resource.ImageDescriptor;
import org.eclipse.swt.SWT;
import org.eclipse.swt.events.SelectionAdapter;
import org.eclipse.swt.events.SelectionEvent;
import org.eclipse.swt.graphics.Image;
import org.eclipse.swt.layout.GridData;
import org.eclipse.swt.layout.GridLayout;
import org.eclipse.swt.widgets.Button;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Label;
import org.eclipse.ui.ISharedImages;
import org.eclipse.ui.PlatformUI;

import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationSeverity;
import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
import software.aws.toolkits.eclipse.amazonq.util.ToolkitNotification;

/**
* A toast notification that renders a severity icon, wrapped description, and N action buttons built from a hosted
* notification's actions. INFO/WARNING keep the base auto-close timer; CRITICAL overrides {@link #scheduleAutoClose()}
* to a no-op so it persists until the user dismisses it, ensuring critical alerts reach the user.
*/
public final class AmazonQNotificationPopup extends ToolkitNotification {

/** A rendered action button: a label plus the handler to run when clicked. */
public record NotificationAction(String label, Runnable onClick) { }

private final String description;
// Mylyn's default auto-close is 8s, which is too short to read a multi-line known-issue message.
private static final long TRANSIENT_DELAY_CLOSE_MS = 20_000L;

private final NotificationSeverity severity;
private final boolean persistent;
private final List<NotificationAction> actions;

public AmazonQNotificationPopup(final Display display, final String title, final String description,
final NotificationSeverity severity, final List<NotificationAction> actions) {
super(display, title, description);
this.description = description;
this.severity = severity;
this.persistent = severity == NotificationSeverity.CRITICAL;
this.actions = actions == null ? List.of() : List.copyOf(actions);
// CRITICAL persists until dismissed (delayClose = 0 => scheduleAutoClose is a no-op); others stay readable.
final long delayClose = persistent ? 0L : TRANSIENT_DELAY_CLOSE_MS;
setDelayClose(delayClose);
Activator.getLogger().info("AmazonQNotificationPopup created: severity=" + severity
+ " persistent=" + persistent + " delayCloseMs=" + delayClose);
}

@Override
protected void scheduleAutoClose() {
// Belt-and-suspenders: never schedule an auto-close for a persistent (CRITICAL) notification.
if (!persistent) {
super.scheduleAutoClose();
}
}

@Override
protected void createContentArea(final Composite parent) {
final Composite container = new Composite(parent, SWT.NONE);
container.setLayout(new GridLayout(2, false));
container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false));

final Label iconLabel = new Label(container, SWT.NONE);
final Image icon = createSeverityImage(severity);
if (icon != null) {
iconLabel.setImage(icon);
// close() is final in the base class, so dispose the icon via a listener instead of overriding close().
iconLabel.addDisposeListener(e -> {
if (!icon.isDisposed()) {
icon.dispose();
}
});
}
iconLabel.setLayoutData(new GridData(SWT.BEGINNING, SWT.BEGINNING, false, false));

final Label messageLabel = new Label(container, SWT.WRAP);
messageLabel.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false));
messageLabel.setText(description != null ? description : "");

if (!actions.isEmpty()) {
createActionButtons(parent);
}
}

private void createActionButtons(final Composite parent) {
final Composite buttonRow = new Composite(parent, SWT.NONE);
final GridLayout layout = new GridLayout(actions.size(), false);
layout.marginWidth = 0;
layout.marginHeight = 0;
buttonRow.setLayout(layout);
buttonRow.setLayoutData(new GridData(SWT.END, SWT.CENTER, true, false));

for (final NotificationAction action : actions) {
final Button button = new Button(buttonRow, SWT.PUSH);
button.setText(action.label());
button.setLayoutData(new GridData(SWT.END, SWT.CENTER, false, false));
button.addSelectionListener(new SelectionAdapter() {
@Override
public void widgetSelected(final SelectionEvent e) {
action.onClick().run();
close();
}
});
}
}

private static Image createSeverityImage(final NotificationSeverity severity) {
try {
final ISharedImages sharedImages = PlatformUI.getWorkbench().getSharedImages();
final ImageDescriptor descriptor = sharedImages.getImageDescriptor(iconKey(severity));
if (descriptor == null) {
return null;
}
// createImage(false) returns null (rather than throwing) if the image can't be loaded.
return descriptor.createImage(false);
} catch (Exception e) {
return null;
}
}

static String iconKey(final NotificationSeverity severity) {
switch (severity) {
case CRITICAL:
return ISharedImages.IMG_OBJS_ERROR_TSK;
case WARNING:
return ISharedImages.IMG_OBJS_WARN_TSK;
case INFO:
default:
return ISharedImages.IMG_OBJS_INFO_TSK;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.eclipse.amazonq.notifications;

/** A dismissed notification id plus the epoch-millis timestamp it was dismissed (for retention cleanup). Gson-friendly. */
public final class DismissedNotification {

private String id;
private long dismissedAtEpochMs;

public DismissedNotification() {
// no-arg constructor for Gson
}

public DismissedNotification(final String id, final long dismissedAtEpochMs) {
this.id = id;
this.dismissedAtEpochMs = dismissedAtEpochMs;
}

public String getId() {
return id;
}

public void setId(final String id) {
this.id = id;
}

public long getDismissedAtEpochMs() {
return dismissedAtEpochMs;
}

public void setDismissedAtEpochMs(final long dismissedAtEpochMs) {
this.dismissedAtEpochMs = dismissedAtEpochMs;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.eclipse.amazonq.notifications;

/** Snapshot of a feature's auth/connection state, used by the rules engine's {@code authx} matching. */
public record FeatureAuthDetails(String connectionType, String region, String connectionState) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.eclipse.amazonq.notifications;

import java.util.ArrayList;
import java.util.List;

import org.eclipse.jface.dialogs.MessageDialog;
import org.eclipse.swt.widgets.Display;

import software.aws.toolkits.eclipse.amazonq.notifications.AmazonQNotificationPopup.NotificationAction;
import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationFollowupAction;
import software.aws.toolkits.eclipse.amazonq.util.Constants;
import software.aws.toolkits.eclipse.amazonq.util.PluginUtils;

/**
* Builds the rendered action buttons for a notification from its hosted {@code actions[]}, ports the JetBrains mapping:
* {@code ShowUrl} (case-sensitive) only supplies the URL for the always-present "More" button; {@code UpdateExtension}
* and {@code OpenChangelog} open pages; unknown types are ignored.
*/
public final class NotificationActionFactory {

private static final String SHOW_URL = "ShowUrl";
private static final String UPDATE_EXTENSION = "UpdateExtension";
private static final String OPEN_CHANGELOG = "OpenChangelog";

private NotificationActionFactory() {
// prevent instantiation
}

public static List<NotificationAction> createActions(final String notificationId,
final List<NotificationFollowupAction> followupActions, final String title, final String description) {
final List<NotificationAction> result = new ArrayList<>();
String moreUrl = null;

if (followupActions != null) {
for (final NotificationFollowupAction action : followupActions) {
final String type = action.type();
if (SHOW_URL.equals(type)) {
if (action.content() != null && action.content().enUs() != null) {
moreUrl = action.content().enUs().url();
}
} else if (UPDATE_EXTENSION.equals(type)) {
result.add(new NotificationAction("Update", () -> {
NotificationTelemetryProvider.emitInvokeAction(notificationId, UPDATE_EXTENSION);
PluginUtils.openWebpage(Constants.AMAZON_Q_UPDATE_SITE_URL);
}));
} else if (OPEN_CHANGELOG.equals(type)) {
result.add(new NotificationAction("View changelog", () -> {
NotificationTelemetryProvider.emitInvokeAction(notificationId, OPEN_CHANGELOG);
PluginUtils.openWebpage(Constants.AMAZON_Q_CHANGELOG_URL);
}));
}
}
}

final String capturedUrl = moreUrl;
result.add(new NotificationAction("More", () -> {
NotificationTelemetryProvider.emitInvokeAction(notificationId, "More");
showMoreDialog(title, description, capturedUrl);
}));
return result;
}

private static void showMoreDialog(final String title, final String description, final String url) {
if (url != null && !url.isBlank()) {
PluginUtils.handleExternalLinkClick(url);
} else {
MessageDialog.openInformation(Display.getDefault().getActiveShell(), title, description);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.eclipse.amazonq.notifications;

/** Endpoint, cache, and storage-key constants for the hosted-file notifications feature. */
public final class NotificationConstants {

/** Production hosted-file endpoint for Eclipse notifications (schema 2.x combined). */
public static final String NOTIFICATIONS_ENDPOINT =
"https://idetoolkits-hostedfiles.amazonaws.com/Notifications/Eclipse/combined/2.x.json";

/** Subdirectory (under the plugin state dir) that holds the cached notifications file. */
public static final String NOTIFICATIONS_SUBDIRECTORY = "notifications";

/** Filename of the cached notifications payload. */
public static final String NOTIFICATIONS_CACHE_FILENAME = "notifications.json";

/** PluginStore key under which dismissed-notification state is persisted. */
public static final String DISMISSAL_STORAGE_KEY = "qNotificationDismissals";

/** Environment variable that overrides the endpoint (for local dev/testing). */
public static final String NOTIFICATIONS_ENDPOINT_ENV = "AMAZONQ_NOTIFICATIONS_ENDPOINT";

private NotificationConstants() {
// prevent instantiation
}
}
Loading
Loading