mattcasters commented on code in PR #8270: URL: https://github.com/apache/hop/pull/8270#discussion_r3941953665
########## ui/src/main/java/org/apache/hop/ui/hopgui/notifications/NotificationProviderPlugins.java: ########## @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.ui.hopgui.notifications; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.apache.hop.core.logging.ILogChannel; +import org.apache.hop.core.notifications.INotificationProvider; +import org.apache.hop.core.notifications.NotificationProviderPluginType; +import org.apache.hop.core.plugins.IPlugin; +import org.apache.hop.core.plugins.PluginRegistry; +import org.apache.hop.ui.hopgui.notifications.config.NotificationSourceConfig; + +/** + * The notification providers contributed by plugins, as found in the plugin registry. + * + * <p>A plugin declares a provider with {@link + * org.apache.hop.core.notifications.NotificationProviderPlugin} and is discovered from its jar. It + * therefore needs no entry in the configuration to work: a stored source only records what the user + * changed about it, and a provider whose plugin has been uninstalled simply stops being found. + */ +public final class NotificationProviderPlugins { + + private NotificationProviderPlugins() { + // Utility class + } + + /** + * @return The registry entries of every declared notification provider + */ + public static List<IPlugin> plugins() { + List<IPlugin> plugins = + PluginRegistry.getInstance().getPlugins(NotificationProviderPluginType.class); + return plugins == null ? new ArrayList<>() : plugins; + } + + /** + * @return The identifiers of every declared notification provider + */ + public static Set<String> ids() { + Set<String> ids = new LinkedHashSet<>(); + for (IPlugin plugin : plugins()) { + String id = idOf(plugin); + if (id != null) { + ids.add(id); + } + } + return ids; + } + + /** + * Instantiate a declared provider. + * + * @param plugin The registry entry + * @param log Where to report a provider that cannot be loaded + * @return The provider, or null when its class could not be loaded + */ + public static INotificationProvider load(IPlugin plugin, ILogChannel log) { + try { + return PluginRegistry.getInstance().loadClass(plugin, INotificationProvider.class); + } catch (Exception e) { + log.logError("Unable to load the notification provider of plugin " + plugin.getName(), e); + return null; + } + } + + /** + * The identifier a plugin's provider is known by. This is the source id its notifications are + * qualified with, so it has to be the plugin id and nothing derived from the instance. + * + * @param plugin The registry entry + * @return The identifier, or null when the entry declares none + */ + public static String idOf(IPlugin plugin) { + String[] ids = plugin.getIds(); + return ids == null || ids.length == 0 ? null : ids[0]; + } + + /** + * Describe the declared providers as configuration sources, so the Notifications settings can + * list a plugin that has never been configured alongside the sources the user added. + * + * @return One source per declared provider, in registry order + */ + public static List<NotificationSourceConfig> describeAsSources() { + List<NotificationSourceConfig> described = new ArrayList<>(); + for (IPlugin plugin : plugins()) { + String id = idOf(plugin); + if (id == null) { + continue; + } + NotificationSourceConfig source = new NotificationSourceConfig(); + source.setId(id); + source.setPluginId(id); + source.setName( + plugin.getName() == null || plugin.getName().isEmpty() ? id : plugin.getName()); + source.setType(NotificationSourceConfig.SourceType.CUSTOM_PLUGIN); + source.setEnabled(true); + source.setPollIntervalMinutes("60"); Review Comment: **[bug]** Discovered plugin sources are synthesized with `setPollIntervalMinutes("60")`. `MarketplaceNotificationProvider` defaults to 6 hours. Opening Notifications and clicking Save (documented as required) persists every discovered row via `saveSources()`, after which `reloadFromConfig` / `applyPollInterval` overwrites the marketplace provider with 60 minutes. The same happens if the user edits any source and saves the table. **Suggestion:** Leave poll interval unset on synthesized sources so the provider default is kept, and only write a `poll.intervalMinutes` property when the user actually changes it. ########## ui/src/main/java/org/apache/hop/ui/hopgui/notifications/providers/RssNotificationProvider.java: ########## @@ -0,0 +1,503 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hop.ui.hopgui.notifications.providers; + +import java.io.BufferedInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import javax.xml.parsers.DocumentBuilder; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hop.core.exception.HopException; +import org.apache.hop.core.logging.LogChannel; +import org.apache.hop.core.notifications.INotificationProvider; +import org.apache.hop.core.notifications.Notification; +import org.apache.hop.core.notifications.NotificationCategory; +import org.apache.hop.core.notifications.NotificationPriority; +import org.apache.hop.core.xml.XmlParserFactoryProducer; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +/** + * RSS/Atom feed notification provider. Supports both RSS 2.0 and Atom 1.0 feeds. Can be configured + * with any feed URL. + */ +public class RssNotificationProvider implements INotificationProvider { + private String feedUrl; + private String providerId; + private String providerName; + private boolean enabled = true; + private long pollInterval = 3600000; // 1 hour default + private String username; + private String password; + + /** What the feed last answered, so a poll that changes nothing costs a 304. */ + private final NotificationHttp.Conditional conditional = new NotificationHttp.Conditional(); + + /** The entries of the last answer, replayed while the feed keeps saying "not modified". */ + private List<Notification> lastFetched = new ArrayList<>(); + + /** + * Create a new RSS notification provider + * + * @param feedUrl The URL of the RSS/Atom feed + * @param providerId Unique identifier for this provider instance + * @param providerName Human-readable name for this provider + */ + public RssNotificationProvider(String feedUrl, String providerId, String providerName) { + this.feedUrl = feedUrl; + this.providerId = providerId; + this.providerName = providerName; + } + + @Override + public String getId() { + return providerId; + } + + @Override + public String getName() { + return providerName; + } + + @Override + public String getDescription() { + return "RSS/Atom feed provider for: " + feedUrl; + } + + @Override + public List<Notification> fetchNotifications() throws HopException { + List<Notification> notifications = new ArrayList<>(); + + if (feedUrl == null || feedUrl.isEmpty()) { + return notifications; + } + + try { + CloseableHttpClient client = NotificationHttp.newClient(username, password); + HttpGet request = new HttpGet(feedUrl); + request.addHeader( + "Accept", "application/rss+xml, application/atom+xml, application/xml, text/xml"); + conditional.applyTo(request); + + try (ClassicHttpResponse response = (ClassicHttpResponse) client.execute(request)) { + // Check HTTP status code + int statusCode = response.getCode(); + if (statusCode == 304) { + // Unchanged since the last poll. + return new ArrayList<>(lastFetched); + } + if (statusCode < 200 || statusCode >= 300) { + throw new HopException("The feed at " + feedUrl + " returned HTTP " + statusCode + "."); + } + + HttpEntity entity = response.getEntity(); + if (entity == null) { + throw new HopException("The feed at " + feedUrl + " returned an empty response."); + } + + try (InputStream rawInputStream = entity.getContent(); + BufferedInputStream inputStream = new BufferedInputStream(rawInputStream, 8192)) { + // Read first few bytes to check for BOM or non-XML content + inputStream.mark(1024); + byte[] buffer = new byte[1024]; + int bytesRead = inputStream.read(buffer); + inputStream.reset(); + + if (bytesRead > 0) { + String contentStart = + stripByteOrderMark( + new String( + buffer, + 0, + Math.min(bytesRead, 100), + java.nio.charset.StandardCharsets.UTF_8)); + // Check if it looks like HTML (common error response) + if (contentStart.trim().startsWith("<html") + || contentStart.trim().startsWith("<!DOCTYPE html")) { + throw new HopException( + "The feed at " + + feedUrl + + " returned an HTML page instead of a feed. Check the URL, or whether a" + + " proxy or sign-in page is answering for it."); + } + // Check if it starts with XML declaration or valid XML tag + String trimmed = contentStart.trim(); + if (!trimmed.startsWith("<?xml") + && !trimmed.startsWith("<feed") + && !trimmed.startsWith("<rss") + && !trimmed.startsWith("<rdf:RDF")) { + throw new HopException( + "The response from " + feedUrl + " is not an RSS or Atom feed."); + } + } + + DocumentBuilder builder = + XmlParserFactoryProducer.createSecureDocBuilderFactory().newDocumentBuilder(); + Document document = builder.parse(inputStream); + + // Check if it's Atom or RSS + Element root = document.getDocumentElement(); + if (root == null) { + throw new HopException("The feed at " + feedUrl + " is an empty XML document."); + } + String rootName = root.getNodeName(); + + if ("feed".equals(rootName) || rootName.contains("atom")) { + // Atom feed + notifications.addAll(parseAtomFeed(document)); + } else if ("rss".equals(rootName) || "rdf:RDF".equals(rootName)) { + // RSS feed + notifications.addAll(parseRssFeed(document)); + } else { + throw new HopException( + "The feed at " + feedUrl + " is in an unsupported format: <" + rootName + ">."); + } + } + + // Only now that the feed has been read and understood. Remembering the validator any + // earlier means a parse that fails still arms the next request's If-None-Match: the feed + // would answer 304 forever, this method would return the entries it never managed to read + // (none), and because that is not a failure the error banner would clear itself. + conditional.remember(response); + } + lastFetched = new ArrayList<>(notifications); + } catch (HopException e) { + throw e; + } catch (Exception e) { + // Reported to the user through the panel's error banner. NotificationService catches this + // per provider, so one unreachable feed does not stop the others. + throw new HopException("Could not read the feed at " + feedUrl + ": " + e.getMessage(), e); + } + + return notifications; + } + + /** + * Drop a leading byte order mark. + * + * <p>Feeds written by Windows tooling are commonly served with one. Decoded from UTF-8 it is + * U+FEFF, which {@link String#trim()} leaves alone - it only strips characters up to U+0020 - so + * the sniff below would find the feed starting with an invisible character rather than with + * {@code <?xml}, and reject a feed the XML parser handles perfectly well. + * + * @param text The decoded start of the response + * @return The text without its byte order mark + */ + static String stripByteOrderMark(String text) { + if (!text.isEmpty() && text.charAt(0) == '\uFEFF') { + return text.substring(1); + } + return text; + } + + /** Parse Atom 1.0 feed */ + List<Notification> parseAtomFeed(Document document) { + List<Notification> notifications = new ArrayList<>(); + NodeList entries = document.getElementsByTagName("entry"); + + for (int i = 0; i < entries.getLength(); i++) { + Element entry = (Element) entries.item(i); + try { + String id = getElementText(entry, "id"); + String title = getElementText(entry, "title"); + String summary = getElementText(entry, "summary"); + if (summary == null || summary.isEmpty()) { + summary = getElementText(entry, "content"); + } + String link = getElementLink(entry); + String publishedText = getElementText(entry, "published"); + if (publishedText == null || publishedText.isEmpty()) { + publishedText = getElementText(entry, "updated"); + } + Date published = parseAtomDate(publishedText); + if (published == null) { + published = new Date(); + } + + String localId = entryId(id, link, title, publishedText); + if (localId == null) { + LogChannel.UI.logDetailed( + "Skipping an entry in the feed at " + feedUrl + " that cannot be identified"); + continue; + } + + Notification notification = + new Notification( + localId, + title != null ? title : "Untitled", + summary != null ? summary : "", Review Comment: **[suggestion]** Atom `summary`/`content` and RSS `description` are stored and shown with `Label.setText` unstripped. That is not RAP markup XSS (markup is not enabled), but feeds routinely ship HTML, so the panel shows raw tags, and SWT still treats `&` as a mnemonic. **Suggestion:** Strip tags to text (or a small allow-list), collapse whitespace, and escape `&` for SWT (`&&`) before display. Keep using a text Label, not a Browser. ########## rap/src/main/java/org/apache/hop/ui/hopgui/notifications/NotificationServiceImpl.java: ########## @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.ui.hopgui.notifications; + +import org.apache.hop.ui.hopgui.ISingletonProvider; +import org.eclipse.rap.rwt.RWT; +import org.eclipse.rap.rwt.SingletonUtil; + +/** + * One NotificationService per user session. Hop Web serves many users from one process, and this + * holds state that belongs to one of them. Review Comment: **[bug]** Per-session `NotificationService` is the right split for listeners and in-memory unread state, but each session also gets its own daemon scheduler (`newScheduledThreadPool(1)`) and polls every source in the process-wide `hop-config.json`. The default source is unauthenticated `api.github.com/repos/apache/hop/releases` (60 req/hour/IP). N Hop Web sessions therefore issue N identical GitHub calls shortly after login and again every poll interval, so a modest multi-user server will trip the rate limit and the error banner for everyone. Authenticated audit isolation does not help: sources live in shared HopConfig. **Suggestion:** Keep read/removed/listeners per session, but fetch once per process (or per distinct source URL) and fan the result out. At minimum, coalesce GitHub polls and send a token from server config rather than N anonymous clients. ########## ui/src/main/java/org/apache/hop/ui/hopgui/notifications/providers/NotificationHttp.java: ########## @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.ui.hopgui.notifications.providers; + +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hop.core.util.HttpClientManager; +import org.apache.hop.core.variables.Variables; + +/** Shared HTTP setup for the notification providers. */ +final class NotificationHttp { + + /** Give up if the remote host has not accepted the connection by then. */ + static final int CONNECT_TIMEOUT_MS = 10000; + + /** Give up if the remote host has not answered by then. */ + static final int RESPONSE_TIMEOUT_MS = 20000; + + private NotificationHttp() { + // Utility class + } + + /** + * A client with timeouts. {@link HttpClientManager#createDefaultClient()} sets none at all, and + * HttpClient 5 waits indefinitely for a response, so a single unresponsive feed would otherwise + * hold a polling thread forever. + * + * <p>The client is built on the process-wide shared connection manager, so do not close it: + * closing the response is what returns this request's connection to the pool. + * + * @return A client configured for polling a notification source + */ + static CloseableHttpClient newClient() { + return newClient(null, null); + } + + /** + * A client with timeouts, authenticating when credentials are given. + * + * @param username The user name, may be null or empty for anonymous access + * @param password The password or token, may be null or empty for anonymous access + * @return A client configured for polling a notification source + */ + static CloseableHttpClient newClient(String username, String password) { + HttpClientManager.HttpClientBuilderFacade builder = + HttpClientManager.getInstance() + .createBuilder() + .setConnectionTimeout(CONNECT_TIMEOUT_MS) + .setSocketTimeout(RESPONSE_TIMEOUT_MS); + String resolvedPassword = resolve(password); + if (resolvedPassword != null && !resolvedPassword.isEmpty()) { + // A token is often all a source wants; GitHub, for one, ignores the user name entirely. + builder.setCredentials(resolve(username), resolvedPassword); Review Comment: **[bug]** `builder.setCredentials(resolve(username), resolvedPassword)` has two defects. (1) `HttpClientManager.setCredentials(user, password)` registers `AuthScope(null, null, -1, null, null)`, so Basic credentials are offered to **any** host after a redirect, not only `api.github.com` / the feed origin. (2) HC5 `UsernamePasswordCredentials` / `BasicUserPrincipal` require a non-null username; a token-only source whose JSON has `auth.password` and no `auth.username` NPEs on fetch even though the UI and docs treat username as optional. **Suggestion:** Scope `AuthScope` to the request host (and do not forward credentials on cross-host redirects). Coerce a missing username to `""` or `"token"`. Prefer a GitHub `Authorization: Bearer` header over Basic for PATs. ########## ui/src/main/java/org/apache/hop/ui/hopgui/notifications/providers/RssNotificationProvider.java: ########## @@ -0,0 +1,503 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hop.ui.hopgui.notifications.providers; + +import java.io.BufferedInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import javax.xml.parsers.DocumentBuilder; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hop.core.exception.HopException; +import org.apache.hop.core.logging.LogChannel; +import org.apache.hop.core.notifications.INotificationProvider; +import org.apache.hop.core.notifications.Notification; +import org.apache.hop.core.notifications.NotificationCategory; +import org.apache.hop.core.notifications.NotificationPriority; +import org.apache.hop.core.xml.XmlParserFactoryProducer; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +/** + * RSS/Atom feed notification provider. Supports both RSS 2.0 and Atom 1.0 feeds. Can be configured + * with any feed URL. + */ +public class RssNotificationProvider implements INotificationProvider { + private String feedUrl; + private String providerId; + private String providerName; + private boolean enabled = true; + private long pollInterval = 3600000; // 1 hour default + private String username; + private String password; + + /** What the feed last answered, so a poll that changes nothing costs a 304. */ + private final NotificationHttp.Conditional conditional = new NotificationHttp.Conditional(); + + /** The entries of the last answer, replayed while the feed keeps saying "not modified". */ + private List<Notification> lastFetched = new ArrayList<>(); + + /** + * Create a new RSS notification provider + * + * @param feedUrl The URL of the RSS/Atom feed + * @param providerId Unique identifier for this provider instance + * @param providerName Human-readable name for this provider + */ + public RssNotificationProvider(String feedUrl, String providerId, String providerName) { + this.feedUrl = feedUrl; + this.providerId = providerId; + this.providerName = providerName; + } + + @Override + public String getId() { + return providerId; + } + + @Override + public String getName() { + return providerName; + } + + @Override + public String getDescription() { + return "RSS/Atom feed provider for: " + feedUrl; + } + + @Override + public List<Notification> fetchNotifications() throws HopException { + List<Notification> notifications = new ArrayList<>(); + + if (feedUrl == null || feedUrl.isEmpty()) { + return notifications; + } + + try { + CloseableHttpClient client = NotificationHttp.newClient(username, password); + HttpGet request = new HttpGet(feedUrl); Review Comment: **[bug]** `HttpGet(feedUrl)` fetches whatever URL the user stored. There is no http(s)-only check (unlike `NotificationLinks.isSafe`), no host allow/deny list, and `HttpClientManager` follows redirects by default. On the desktop that is the user's own client; in Hop Web the **server** periodically fetches it. Any session that can add an RSS source can probe loopback, link-local, and cloud metadata (`http://127.0.0.1:8080/hop/`, `http://169.254.169.254/`), including via a 302 from an otherwise "https" feed. Responses are also unbounded. **Suggestion:** Reject anything `NotificationLinks.isSafe` would reject before `HttpGet`. In Hop Web, also refuse loopback/link-local/private ranges and disable or tightly scope redirects. Cap response size. Do not treat "the user configured it" as sufficient for a multi-user server. ########## ui/src/main/java/org/apache/hop/ui/hopgui/notifications/config/NotificationSourceDialog.java: ########## @@ -0,0 +1,976 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hop.ui.hopgui.notifications.config; + +import java.util.UUID; +import org.apache.hop.core.util.Utils; +import org.apache.hop.i18n.BaseMessages; +import org.apache.hop.ui.core.PropsUi; +import org.apache.hop.ui.core.dialog.ErrorDialog; +import org.apache.hop.ui.core.widget.PasswordTextVar; +import org.apache.hop.ui.core.widget.TextVar; +import org.apache.hop.ui.hopgui.HopGui; +import org.apache.hop.ui.pipeline.transform.BaseTransformDialog; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.layout.FormAttachment; +import org.eclipse.swt.layout.FormData; +import org.eclipse.swt.layout.FormLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.ColorDialog; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; + +/** Dialog for adding or editing a notification source configuration. */ +public class NotificationSourceDialog { + + private static final Class<?> PKG = NotificationSourceDialog.class; + + private Shell shell; + private Shell parentShell; + private NotificationSourceConfig sourceConfig; + + /** + * Whether the dialog was closed without confirming. Only OK clears it: closing the window any + * other way - the title bar, Escape - leaves the edits unsaved, which is what closing a dialog + * means everywhere else. + */ + private boolean cancelled = true; + + private PropsUi props = PropsUi.getInstance(); + + // UI widgets + private TextVar wName; + private Combo wType; + private Button wEnabled; + private Button wColorButton; + private Label wColorPreview; + private Composite wTypeSpecificComposite; + private TextVar wGithubUrl; // For URL input + private TextVar wGithubOwner; + private TextVar wGithubRepo; + private Button wGithubIncludePrereleases; + private TextVar wRssUrl; + private TextVar wPluginId; + private TextVar wPollInterval; + private TextVar wDaysToGoBack; + private TextVar wUsername; + private PasswordTextVar wPassword; + private TextVar wMinimumVersion; + + /** Set while one GitHub field is updating another, so the two directions do not loop. */ + private boolean syncingGithubFields; + + public NotificationSourceDialog(Shell parent, NotificationSourceConfig sourceConfig) { + this.parentShell = parent; + this.shell = new Shell(parent, SWT.DIALOG_TRIM | SWT.RESIZE | SWT.MAX | SWT.MIN); + this.sourceConfig = sourceConfig != null ? sourceConfig : new NotificationSourceConfig(); + props.setLook(this.shell); + } + + public String open() { + Display display = parentShell.getDisplay(); + + shell.setText(BaseMessages.getString(PKG, "NotificationSourceDialog.Title")); + + FormLayout formLayout = new FormLayout(); Review Comment: **[suggestion]** ~900 lines of hand-laid `FormAttachment` rows on the shell (name, type, credentials, GitHub/RSS/plugin fields, OK/Cancel). Project GUI dialogs are supposed to start from grouped `@GuiWidgetElement` + `GuiCompositeWidgets.addScrolledComposite` so the form scrolls and the button bar stays pinned. Type-specific fields can be extra groups, not a free-floating composite under the buttons. **Suggestion:** Annotate `NotificationSourceConfig` (or a dialog model) with grouped widgets; keep custom parse/color controls as `registerExtraGroup` rather than laying out the whole shell by hand. ########## ui/src/main/java/org/apache/hop/ui/hopgui/notifications/NotificationPanel.java: ########## @@ -0,0 +1,1002 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hop.ui.hopgui.notifications; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import org.apache.hop.core.logging.LogChannel; +import org.apache.hop.core.notifications.Notification; +import org.apache.hop.i18n.BaseMessages; +import org.apache.hop.ui.core.PropsUi; +import org.apache.hop.ui.core.gui.GuiResource; +import org.apache.hop.ui.hopgui.HopGui; +import org.apache.hop.ui.hopgui.ISingletonProvider; +import org.apache.hop.ui.hopgui.ImplementationLoader; +import org.apache.hop.ui.hopgui.perspective.configuration.ConfigurationPerspective; +import org.apache.hop.ui.util.EnvironmentUtils; +import org.eclipse.swt.SWT; +import org.eclipse.swt.SWTException; +import org.eclipse.swt.custom.CLabel; +import org.eclipse.swt.custom.ScrolledComposite; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.layout.FormAttachment; +import org.eclipse.swt.layout.FormData; +import org.eclipse.swt.layout.FormLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Canvas; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Listener; +import org.eclipse.swt.widgets.Shell; + +/** Dropdown panel for displaying notifications */ +public class NotificationPanel implements INotificationListener { + private static final Class<?> PKG = NotificationPanel.class; + + /** How many notifications the panel draws before it stops and says how many are left. */ + private static final int MAX_RENDERED_NOTIFICATIONS = 100; + + private static NotificationPanel fallback; + + private static final ISingletonProvider PROVIDER = loadProvider(); + + private static ISingletonProvider loadProvider() { + try { + return (ISingletonProvider) ImplementationLoader.newInstance(NotificationPanel.class); + } catch (Throwable e) { + // hop-ui unit tests have no rcp/rap *Impl on the classpath. Anywhere else this is a + // misconfiguration worth shouting about: one instance would then be shared by every Hop Web + // session, which is the very thing the per-session provider exists to prevent. + LogChannel.GENERAL.logBasic( + "No NotificationPanelImpl found; falling back to a single instance for this process. " + + "In Hop Web that means every session shares one."); + return () -> { + synchronized (NotificationPanel.class) { + if (fallback == null) { + fallback = new NotificationPanel(); + } + return fallback; + } + }; + } + } + + private Shell shell; + private Shell parentShell; + private ScrolledComposite scrolledComposite; + private Composite contentComposite; + private boolean isVisible = false; + + /** The configured sources, refreshed each time the list is drawn, for the source colours. */ + private java.util.List<org.apache.hop.ui.hopgui.notifications.config.NotificationSourceConfig> + sourcesForRender = new java.util.ArrayList<>(); + + /** Use {@link #getInstance()}. Public so RWT can create one per user session in Hop Web. */ + public NotificationPanel() { + this.parentShell = HopGui.getInstance().getShell(); + NotificationService.getInstance().addNotificationListener(this); + } + + /** + * @return The notification panel of this process, or of this user's session in Hop Web + */ + public static NotificationPanel getInstance() { + return (NotificationPanel) PROVIDER.getInstanceInternal(); + } + + /** Toggle the panel visibility */ + public void toggle() { + if (isVisible) { + hide(); + } else { + show(); + } + } + + /** Show the notification panel */ + public void show() { + if (shell != null && !shell.isDisposed()) { + // Panel already exists, refresh notifications and show + updateNotifications(); + shell.setVisible(true); + shell.setFocus(); + // Without this the panel stays "not visible" after the first open and close, which stops + // notificationsChanged() from refreshing it and leaves the bell unable to close it again. + isVisible = true; + return; + } + + createPanel(); + updateNotifications(); + positionPanel(); + shell.setVisible(true); + isVisible = true; + } + + /** Hide the notification panel */ + public void hide() { + if (shell != null && !shell.isDisposed()) { + shell.setVisible(false); + } + isVisible = false; + } + + /** Create the panel UI */ + private void createPanel() { + // Use DIALOG_TRIM instead of ON_TOP to keep it attached to parent + // Remove ON_TOP so it doesn't stay on top when switching applications + shell = new Shell(parentShell, SWT.DIALOG_TRIM | SWT.RESIZE); + shell.setLayout(new FormLayout()); + PropsUi.setLook(shell); + + // Header + Composite header = new Composite(shell, SWT.NONE); + header.setLayout(new FormLayout()); + PropsUi.setLook(header); + FormData fdHeader = new FormData(); + fdHeader.left = new FormAttachment(0, 0); + fdHeader.right = new FormAttachment(100, 0); + fdHeader.top = new FormAttachment(0, 0); + header.setLayoutData(fdHeader); + + // Settings button + Button settingsButton = new Button(header, SWT.PUSH); + settingsButton.setText(BaseMessages.getString(PKG, "NotificationPanel.Settings")); + settingsButton.setToolTipText( + BaseMessages.getString(PKG, "NotificationPanel.Settings.Tooltip")); + PropsUi.setLook(settingsButton); + FormData fdSettings = new FormData(); + fdSettings.right = new FormAttachment(100, -10); + fdSettings.top = new FormAttachment(0, 5); + fdSettings.bottom = new FormAttachment(100, -5); + settingsButton.setLayoutData(fdSettings); + settingsButton.addSelectionListener( + new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + org.apache.hop.ui.hopgui.perspective.configuration.ConfigurationPerspective + configPerspective = HopGui.getConfigurationPerspective(); + if (configPerspective != null) { + HopGui.getInstance().setActivePerspective(configPerspective); + + // Defer tab/tree selection until perspective is fully activated + Display.getCurrent() + .asyncExec( + () -> { + ConfigurationPerspective perspective = HopGui.getConfigurationPerspective(); + if (perspective != null) { + perspective.showNotificationsTab(); + } + }); + } + } + }); + + Button clearAll = new Button(header, SWT.PUSH); + clearAll.setText(BaseMessages.getString(PKG, "NotificationPanel.ClearAll")); + clearAll.setToolTipText(BaseMessages.getString(PKG, "NotificationPanel.ClearAll.Tooltip")); + PropsUi.setLook(clearAll); + FormData fdClearAll = new FormData(); + fdClearAll.right = new FormAttachment(settingsButton, -10); + fdClearAll.top = new FormAttachment(0, 5); + fdClearAll.bottom = new FormAttachment(100, -5); + clearAll.setLayoutData(fdClearAll); + clearAll.addSelectionListener( + new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + NotificationService.getInstance().clearAll(); + updateNotifications(); + } + }); + + Button markAllRead = new Button(header, SWT.PUSH); + markAllRead.setText(BaseMessages.getString(PKG, "NotificationPanel.MarkAllRead")); + PropsUi.setLook(markAllRead); + FormData fdMarkAll = new FormData(); + fdMarkAll.right = new FormAttachment(clearAll, -10); + fdMarkAll.top = new FormAttachment(0, 5); + fdMarkAll.bottom = new FormAttachment(100, -5); + markAllRead.setLayoutData(fdMarkAll); + markAllRead.addSelectionListener( + new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + NotificationService.getInstance().markAllAsRead(); + updateNotifications(); + } + }); + + // Scrolled content area + scrolledComposite = new ScrolledComposite(shell, SWT.V_SCROLL | SWT.BORDER); + PropsUi.setLook(scrolledComposite); + FormData fdScrolled = new FormData(); + fdScrolled.left = new FormAttachment(0, 0); + fdScrolled.right = new FormAttachment(100, 0); + fdScrolled.top = new FormAttachment(header, 0); + fdScrolled.bottom = new FormAttachment(100, -40); + scrolledComposite.setLayoutData(fdScrolled); + + contentComposite = new Composite(scrolledComposite, SWT.NONE); + contentComposite.setLayout(new FormLayout()); + PropsUi.setLook(contentComposite); + scrolledComposite.setContent(contentComposite); + scrolledComposite.setExpandHorizontal(true); + scrolledComposite.setExpandVertical(true); + + // Footer + Composite footer = new Composite(shell, SWT.NONE); + footer.setLayout(new FormLayout()); + PropsUi.setLook(footer); + FormData fdFooter = new FormData(); + fdFooter.left = new FormAttachment(0, 0); + fdFooter.right = new FormAttachment(100, 0); + fdFooter.bottom = new FormAttachment(100, 0); + footer.setLayoutData(fdFooter); + + Button closeButton = new Button(footer, SWT.PUSH); + closeButton.setText(BaseMessages.getString(PKG, "NotificationPanel.Close")); + PropsUi.setLook(closeButton); + FormData fdClose = new FormData(); + fdClose.right = new FormAttachment(100, -10); + fdClose.top = new FormAttachment(0, 5); + fdClose.bottom = new FormAttachment(100, -5); + closeButton.setLayoutData(fdClose); + closeButton.addSelectionListener( + new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + hide(); + } + }); + + // Close when clicking outside + shell.addListener( + SWT.Deactivate, + e -> { + if (!shell.isDisposed()) { + Display.getCurrent() + .asyncExec( + () -> { + if (!shell.isDisposed() && !shell.isFocusControl()) { + hide(); + } + }); + } + }); + + // The panel hangs off the bell in the main toolbar, so it has to follow the main window + // whenever that moves or is resized, not just when it is resized. + if (parentShell != null && !parentShell.isDisposed()) { + Listener repositionListener = + e -> { + if (shell != null && !shell.isDisposed() && isVisible) { + Display.getCurrent() + .asyncExec( + () -> { + if (shell != null && !shell.isDisposed() && isVisible) { + positionPanel(); + } + }); + } + }; + parentShell.addListener(SWT.Resize, repositionListener); + parentShell.addListener(SWT.Move, repositionListener); + } + + shell.setSize(400, 500); + + // Add listener to shell resize to ensure titles truncate properly + shell.addListener( + SWT.Resize, + e -> { + if (contentComposite != null && !contentComposite.isDisposed()) { + // Force layout update to ensure titles truncate correctly + contentComposite.layout(true, true); + } + }); + } + + /** Update the notifications display */ + private void updateNotifications() { + if (contentComposite == null || contentComposite.isDisposed()) { + return; + } + + // Clear existing notifications + for (Control control : contentComposite.getChildren()) { + control.dispose(); + } + + // Get configuration options + org.apache.hop.core.config.HopConfig hopConfig = + org.apache.hop.core.config.HopConfig.getInstance(); + boolean showReadNotifications = + org.apache.hop.core.config.HopConfig.readOptionString( + "notification.showReadNotifications", "true") + .equalsIgnoreCase("true"); + String daysToGoBackStr = + org.apache.hop.core.config.HopConfig.readOptionString( + "notification.global.daysToGoBack", "30"); + int daysToGoBack = 0; + try { + daysToGoBack = Integer.parseInt(daysToGoBackStr); + } catch (NumberFormatException e) { + daysToGoBack = 30; // Default to 30 days + } + + // Get provider errors and notifications + List<org.apache.hop.ui.hopgui.notifications.ProviderErrorInfo> providerErrors = + NotificationService.getInstance().getProviderErrors(); + List<Notification> notifications = + NotificationService.getInstance().getNotifications(!showReadNotifications, daysToGoBack); Review Comment: **[bug]** The source dialog validates and stores per-source `daysToGoBack` (`NotificationSourceDialog.saveSource`), and the field is labeled "0 = use global". The panel always calls `getNotifications(..., daysToGoBack)` with only `notification.global.daysToGoBack`. Factory/providers never read the per-source property either, so the control is dead: a source set to 7 days still uses the global 30. **Suggestion:** When listing, apply `max(source.daysToGoBack, 0)` per notification (0 meaning global), or remove the per-source field from the dialog until it is wired through. ########## ui/src/main/java/org/apache/hop/ui/hopgui/notifications/NotificationPanel.java: ########## @@ -0,0 +1,1002 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hop.ui.hopgui.notifications; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import org.apache.hop.core.logging.LogChannel; +import org.apache.hop.core.notifications.Notification; +import org.apache.hop.i18n.BaseMessages; +import org.apache.hop.ui.core.PropsUi; +import org.apache.hop.ui.core.gui.GuiResource; +import org.apache.hop.ui.hopgui.HopGui; +import org.apache.hop.ui.hopgui.ISingletonProvider; +import org.apache.hop.ui.hopgui.ImplementationLoader; +import org.apache.hop.ui.hopgui.perspective.configuration.ConfigurationPerspective; +import org.apache.hop.ui.util.EnvironmentUtils; +import org.eclipse.swt.SWT; +import org.eclipse.swt.SWTException; +import org.eclipse.swt.custom.CLabel; +import org.eclipse.swt.custom.ScrolledComposite; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.layout.FormAttachment; +import org.eclipse.swt.layout.FormData; +import org.eclipse.swt.layout.FormLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Canvas; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Listener; +import org.eclipse.swt.widgets.Shell; + +/** Dropdown panel for displaying notifications */ +public class NotificationPanel implements INotificationListener { + private static final Class<?> PKG = NotificationPanel.class; + + /** How many notifications the panel draws before it stops and says how many are left. */ + private static final int MAX_RENDERED_NOTIFICATIONS = 100; + + private static NotificationPanel fallback; + + private static final ISingletonProvider PROVIDER = loadProvider(); + + private static ISingletonProvider loadProvider() { + try { + return (ISingletonProvider) ImplementationLoader.newInstance(NotificationPanel.class); + } catch (Throwable e) { + // hop-ui unit tests have no rcp/rap *Impl on the classpath. Anywhere else this is a + // misconfiguration worth shouting about: one instance would then be shared by every Hop Web + // session, which is the very thing the per-session provider exists to prevent. + LogChannel.GENERAL.logBasic( + "No NotificationPanelImpl found; falling back to a single instance for this process. " + + "In Hop Web that means every session shares one."); + return () -> { + synchronized (NotificationPanel.class) { + if (fallback == null) { + fallback = new NotificationPanel(); + } + return fallback; + } + }; + } + } + + private Shell shell; + private Shell parentShell; + private ScrolledComposite scrolledComposite; + private Composite contentComposite; + private boolean isVisible = false; + + /** The configured sources, refreshed each time the list is drawn, for the source colours. */ + private java.util.List<org.apache.hop.ui.hopgui.notifications.config.NotificationSourceConfig> + sourcesForRender = new java.util.ArrayList<>(); + + /** Use {@link #getInstance()}. Public so RWT can create one per user session in Hop Web. */ + public NotificationPanel() { + this.parentShell = HopGui.getInstance().getShell(); + NotificationService.getInstance().addNotificationListener(this); + } + + /** + * @return The notification panel of this process, or of this user's session in Hop Web + */ + public static NotificationPanel getInstance() { + return (NotificationPanel) PROVIDER.getInstanceInternal(); + } + + /** Toggle the panel visibility */ + public void toggle() { + if (isVisible) { + hide(); + } else { + show(); + } + } + + /** Show the notification panel */ + public void show() { + if (shell != null && !shell.isDisposed()) { + // Panel already exists, refresh notifications and show + updateNotifications(); + shell.setVisible(true); + shell.setFocus(); + // Without this the panel stays "not visible" after the first open and close, which stops + // notificationsChanged() from refreshing it and leaves the bell unable to close it again. + isVisible = true; + return; + } + + createPanel(); + updateNotifications(); + positionPanel(); + shell.setVisible(true); + isVisible = true; + } + + /** Hide the notification panel */ + public void hide() { + if (shell != null && !shell.isDisposed()) { + shell.setVisible(false); + } + isVisible = false; + } + + /** Create the panel UI */ + private void createPanel() { + // Use DIALOG_TRIM instead of ON_TOP to keep it attached to parent + // Remove ON_TOP so it doesn't stay on top when switching applications + shell = new Shell(parentShell, SWT.DIALOG_TRIM | SWT.RESIZE); + shell.setLayout(new FormLayout()); + PropsUi.setLook(shell); + + // Header + Composite header = new Composite(shell, SWT.NONE); + header.setLayout(new FormLayout()); + PropsUi.setLook(header); + FormData fdHeader = new FormData(); + fdHeader.left = new FormAttachment(0, 0); + fdHeader.right = new FormAttachment(100, 0); + fdHeader.top = new FormAttachment(0, 0); + header.setLayoutData(fdHeader); + + // Settings button + Button settingsButton = new Button(header, SWT.PUSH); + settingsButton.setText(BaseMessages.getString(PKG, "NotificationPanel.Settings")); + settingsButton.setToolTipText( + BaseMessages.getString(PKG, "NotificationPanel.Settings.Tooltip")); + PropsUi.setLook(settingsButton); + FormData fdSettings = new FormData(); + fdSettings.right = new FormAttachment(100, -10); + fdSettings.top = new FormAttachment(0, 5); + fdSettings.bottom = new FormAttachment(100, -5); + settingsButton.setLayoutData(fdSettings); + settingsButton.addSelectionListener( + new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + org.apache.hop.ui.hopgui.perspective.configuration.ConfigurationPerspective + configPerspective = HopGui.getConfigurationPerspective(); + if (configPerspective != null) { + HopGui.getInstance().setActivePerspective(configPerspective); + + // Defer tab/tree selection until perspective is fully activated + Display.getCurrent() + .asyncExec( + () -> { + ConfigurationPerspective perspective = HopGui.getConfigurationPerspective(); + if (perspective != null) { + perspective.showNotificationsTab(); + } + }); + } + } + }); + + Button clearAll = new Button(header, SWT.PUSH); + clearAll.setText(BaseMessages.getString(PKG, "NotificationPanel.ClearAll")); + clearAll.setToolTipText(BaseMessages.getString(PKG, "NotificationPanel.ClearAll.Tooltip")); + PropsUi.setLook(clearAll); + FormData fdClearAll = new FormData(); + fdClearAll.right = new FormAttachment(settingsButton, -10); + fdClearAll.top = new FormAttachment(0, 5); + fdClearAll.bottom = new FormAttachment(100, -5); + clearAll.setLayoutData(fdClearAll); + clearAll.addSelectionListener( + new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + NotificationService.getInstance().clearAll(); + updateNotifications(); + } + }); + + Button markAllRead = new Button(header, SWT.PUSH); + markAllRead.setText(BaseMessages.getString(PKG, "NotificationPanel.MarkAllRead")); + PropsUi.setLook(markAllRead); + FormData fdMarkAll = new FormData(); + fdMarkAll.right = new FormAttachment(clearAll, -10); + fdMarkAll.top = new FormAttachment(0, 5); + fdMarkAll.bottom = new FormAttachment(100, -5); + markAllRead.setLayoutData(fdMarkAll); + markAllRead.addSelectionListener( + new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + NotificationService.getInstance().markAllAsRead(); + updateNotifications(); + } + }); + + // Scrolled content area + scrolledComposite = new ScrolledComposite(shell, SWT.V_SCROLL | SWT.BORDER); + PropsUi.setLook(scrolledComposite); + FormData fdScrolled = new FormData(); + fdScrolled.left = new FormAttachment(0, 0); + fdScrolled.right = new FormAttachment(100, 0); + fdScrolled.top = new FormAttachment(header, 0); + fdScrolled.bottom = new FormAttachment(100, -40); + scrolledComposite.setLayoutData(fdScrolled); + + contentComposite = new Composite(scrolledComposite, SWT.NONE); + contentComposite.setLayout(new FormLayout()); + PropsUi.setLook(contentComposite); + scrolledComposite.setContent(contentComposite); + scrolledComposite.setExpandHorizontal(true); + scrolledComposite.setExpandVertical(true); + + // Footer + Composite footer = new Composite(shell, SWT.NONE); + footer.setLayout(new FormLayout()); + PropsUi.setLook(footer); + FormData fdFooter = new FormData(); + fdFooter.left = new FormAttachment(0, 0); + fdFooter.right = new FormAttachment(100, 0); + fdFooter.bottom = new FormAttachment(100, 0); + footer.setLayoutData(fdFooter); + + Button closeButton = new Button(footer, SWT.PUSH); + closeButton.setText(BaseMessages.getString(PKG, "NotificationPanel.Close")); + PropsUi.setLook(closeButton); + FormData fdClose = new FormData(); + fdClose.right = new FormAttachment(100, -10); + fdClose.top = new FormAttachment(0, 5); + fdClose.bottom = new FormAttachment(100, -5); + closeButton.setLayoutData(fdClose); + closeButton.addSelectionListener( + new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + hide(); + } + }); + + // Close when clicking outside + shell.addListener( + SWT.Deactivate, + e -> { + if (!shell.isDisposed()) { + Display.getCurrent() + .asyncExec( + () -> { + if (!shell.isDisposed() && !shell.isFocusControl()) { + hide(); + } + }); + } + }); + + // The panel hangs off the bell in the main toolbar, so it has to follow the main window + // whenever that moves or is resized, not just when it is resized. + if (parentShell != null && !parentShell.isDisposed()) { + Listener repositionListener = + e -> { + if (shell != null && !shell.isDisposed() && isVisible) { + Display.getCurrent() + .asyncExec( + () -> { + if (shell != null && !shell.isDisposed() && isVisible) { + positionPanel(); + } + }); + } + }; + parentShell.addListener(SWT.Resize, repositionListener); + parentShell.addListener(SWT.Move, repositionListener); + } + + shell.setSize(400, 500); + + // Add listener to shell resize to ensure titles truncate properly + shell.addListener( + SWT.Resize, + e -> { + if (contentComposite != null && !contentComposite.isDisposed()) { + // Force layout update to ensure titles truncate correctly + contentComposite.layout(true, true); + } + }); + } + + /** Update the notifications display */ + private void updateNotifications() { + if (contentComposite == null || contentComposite.isDisposed()) { + return; + } + + // Clear existing notifications + for (Control control : contentComposite.getChildren()) { + control.dispose(); + } + + // Get configuration options + org.apache.hop.core.config.HopConfig hopConfig = + org.apache.hop.core.config.HopConfig.getInstance(); + boolean showReadNotifications = + org.apache.hop.core.config.HopConfig.readOptionString( + "notification.showReadNotifications", "true") + .equalsIgnoreCase("true"); + String daysToGoBackStr = + org.apache.hop.core.config.HopConfig.readOptionString( + "notification.global.daysToGoBack", "30"); + int daysToGoBack = 0; + try { + daysToGoBack = Integer.parseInt(daysToGoBackStr); + } catch (NumberFormatException e) { + daysToGoBack = 30; // Default to 30 days + } + + // Get provider errors and notifications + List<org.apache.hop.ui.hopgui.notifications.ProviderErrorInfo> providerErrors = + NotificationService.getInstance().getProviderErrors(); + List<Notification> notifications = + NotificationService.getInstance().getNotifications(!showReadNotifications, daysToGoBack); + // Read once per repaint: getSourceColor runs twice for every notification on screen. + sourcesForRender = org.apache.hop.ui.hopgui.notifications.config.NotificationSources.load(); + + Control lastControl = null; + + // Provider error banner + if (!providerErrors.isEmpty()) { + Composite errorBanner = createProviderErrorBanner(providerErrors, lastControl); + lastControl = errorBanner; + } + + if (notifications.isEmpty() && lastControl == null) { + Label emptyLabel = new Label(contentComposite, SWT.CENTER | SWT.WRAP); + emptyLabel.setText(BaseMessages.getString(PKG, "NotificationPanel.NoNotifications")); + PropsUi.setLook(emptyLabel); + FormData fdEmpty = new FormData(); + fdEmpty.left = new FormAttachment(0, 10); + fdEmpty.right = new FormAttachment(100, -10); + fdEmpty.top = new FormAttachment(0, 20); + emptyLabel.setLayoutData(fdEmpty); + } else if (!notifications.isEmpty()) { + // Every notification becomes a small stack of widgets. Beyond a screenful or two nobody + // scrolls anyway, and building hundreds of them is what makes opening the panel feel slow. + int shown = Math.min(notifications.size(), MAX_RENDERED_NOTIFICATIONS); + for (Notification notification : notifications.subList(0, shown)) { + try { + Composite notifComposite = createNotificationItem(notification, lastControl); + lastControl = notifComposite; + } catch (Exception e) { + // Log error but continue with other notifications + LogChannel.UI.logError("Error creating notification item: " + notification.getTitle(), e); + } + } + if (notifications.size() > shown) { + Label moreLabel = new Label(contentComposite, SWT.CENTER | SWT.WRAP); + moreLabel.setText( + BaseMessages.getString( + PKG, + "NotificationPanel.MoreNotifications", + Integer.toString(notifications.size() - shown))); + PropsUi.setLook(moreLabel); + FormData fdMore = new FormData(); + fdMore.left = new FormAttachment(0, 10); + fdMore.right = new FormAttachment(100, -10); + fdMore.top = new FormAttachment(lastControl, 10); + moreLabel.setLayoutData(fdMore); + lastControl = moreLabel; + } + } else if (lastControl != null && notifications.isEmpty()) { + // Errors only, no notifications + Label emptyLabel = new Label(contentComposite, SWT.CENTER | SWT.WRAP); + emptyLabel.setText(BaseMessages.getString(PKG, "NotificationPanel.NoNotifications")); + PropsUi.setLook(emptyLabel); + FormData fdEmpty = new FormData(); + fdEmpty.left = new FormAttachment(0, 10); + fdEmpty.right = new FormAttachment(100, -10); + fdEmpty.top = new FormAttachment(lastControl, 10); + emptyLabel.setLayoutData(fdEmpty); + } + + // Force layout of content composite and scrolled composite + if (contentComposite != null && !contentComposite.isDisposed()) { + // Get the scrolled composite width first to ensure proper sizing + int availableWidth = SWT.DEFAULT; + if (scrolledComposite != null && !scrolledComposite.isDisposed()) { + org.eclipse.swt.graphics.Rectangle scrolledBounds = scrolledComposite.getBounds(); + if (scrolledBounds.width > 0) { + availableWidth = scrolledBounds.width - 20; // Account for margins + } + } + + // Layout scrolled composite first to get its actual width + if (scrolledComposite != null && !scrolledComposite.isDisposed()) { + scrolledComposite.layout(true, false); + org.eclipse.swt.graphics.Rectangle scrolledBounds = scrolledComposite.getBounds(); + if (scrolledBounds.width > 0) { + availableWidth = scrolledBounds.width - 20; // Account for margins + } else { + // Fallback: use shell width if scrolled composite not sized yet + if (shell != null && !shell.isDisposed()) { + availableWidth = shell.getSize().x > 0 ? shell.getSize().x - 40 : 380; + } else { + availableWidth = 380; // Default width + } + } + } + + // Layout content composite with proper width constraint + // This is critical for SWT.WRAP labels to calculate their height + contentComposite.layout(true, true); + + // Compute size with width constraint for proper wrapping + org.eclipse.swt.graphics.Point contentSize = + contentComposite.computeSize(availableWidth, SWT.DEFAULT); + + if (scrolledComposite != null && !scrolledComposite.isDisposed()) { + scrolledComposite.setMinSize(contentSize); + scrolledComposite.layout(true, true); + } + } + } + + /** Create the provider error banner with Retry button */ + private Composite createProviderErrorBanner( + List<org.apache.hop.ui.hopgui.notifications.ProviderErrorInfo> errors, Control above) { + Composite banner = new Composite(contentComposite, SWT.BORDER); + FormLayout bannerLayout = new FormLayout(); + // The error text wraps to several lines; without a bottom margin the last one sits on the + // border, because nothing attaches the final label to the bottom of the banner. + bannerLayout.marginBottom = 10; + banner.setLayout(bannerLayout); + // Look first, then the banner's own colours: setLook applies the theme's foreground, which on + // a dark theme is near white and left this text unreadable on the light background. Both + // colours are set explicitly so the banner reads the same whichever theme is in use. + PropsUi.setLook(banner); + Color bannerBackground = GuiResource.getInstance().getColor(255, 248, 220); // Light yellow + Color bannerForeground = GuiResource.getInstance().getColor(60, 50, 20); // Dark brown + banner.setBackground(bannerBackground); + banner.setForeground(bannerForeground); + + FormData fdBanner = new FormData(); + fdBanner.left = new FormAttachment(0, 0); + fdBanner.right = new FormAttachment(100, 0); + fdBanner.top = above != null ? new FormAttachment(above, 10) : new FormAttachment(0, 10); + banner.setLayoutData(fdBanner); + + Label headerLabel = new Label(banner, SWT.WRAP); + headerLabel.setText(BaseMessages.getString(PKG, "NotificationPanel.ProviderErrors")); + PropsUi.setLook(headerLabel); + headerLabel.setBackground(bannerBackground); + headerLabel.setForeground(bannerForeground); + FormData fdHeader = new FormData(); + fdHeader.left = new FormAttachment(0, 10); + fdHeader.right = new FormAttachment(100, -80); + fdHeader.top = new FormAttachment(0, 10); + headerLabel.setLayoutData(fdHeader); + + Button retryButton = new Button(banner, SWT.PUSH); + retryButton.setText(BaseMessages.getString(PKG, "NotificationPanel.Retry")); + retryButton.setToolTipText(BaseMessages.getString(PKG, "NotificationPanel.Retry.Tooltip")); + PropsUi.setLook(retryButton); + FormData fdRetry = new FormData(); + fdRetry.right = new FormAttachment(100, -10); + fdRetry.top = new FormAttachment(0, 5); + retryButton.setLayoutData(fdRetry); + retryButton.addSelectionListener( + new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + // Fetches in the background; the panel refreshes through notificationsChanged(). + NotificationService.getInstance().retryNow(); + } + }); + + Control lastLine = headerLabel; + for (org.apache.hop.ui.hopgui.notifications.ProviderErrorInfo err : errors) { + String text = + BaseMessages.getString( + PKG, "NotificationPanel.ProviderErrorItem", err.getProviderName(), err.getMessage()); + Label line = new Label(banner, SWT.WRAP); + line.setText(text); + PropsUi.setLook(line); + line.setBackground(bannerBackground); + line.setForeground(bannerForeground); + FormData fdLine = new FormData(); + fdLine.left = new FormAttachment(0, 10); + fdLine.right = new FormAttachment(100, -10); + fdLine.top = new FormAttachment(lastLine, 5); + line.setLayoutData(fdLine); + lastLine = line; + } + + return banner; + } + + /** Create a notification item UI */ + private Composite createNotificationItem(Notification notification, Control above) { + Composite composite = new Composite(contentComposite, SWT.BORDER); + composite.setLayout(new FormLayout()); + PropsUi.setLook(composite); + + GuiResource guiResource = GuiResource.getInstance(); + + // Set background color based on read state and priority + // Note: We'll update this dynamically when notification is marked as read + updateNotificationBackground(composite, notification, guiResource); + + // Set FormData for positioning in parent + FormData fdComposite = new FormData(); + fdComposite.left = new FormAttachment(0, 0); + fdComposite.right = new FormAttachment(100, 0); + if (above != null) { + fdComposite.top = new FormAttachment(above, 5); + } else { + fdComposite.top = new FormAttachment(0, 5); + } + // Don't set bottom - composite will size to its children + composite.setLayoutData(fdComposite); + + // Priority indicator (colored bar on the left) + Composite priorityBar = new Composite(composite, SWT.NONE); + priorityBar.setLayout(null); + priorityBar.setData("type", "priorityBar"); // Mark for later updates + FormData fdPriorityBar = new FormData(); + fdPriorityBar.left = new FormAttachment(0, 0); + fdPriorityBar.top = new FormAttachment(0, 0); + fdPriorityBar.bottom = new FormAttachment(100, 0); + fdPriorityBar.width = 4; + priorityBar.setLayoutData(fdPriorityBar); + + // Set initial priority bar color (will be updated when read state changes) + updatePriorityBar(priorityBar, notification, guiResource); + + // Source color indicator (small colored square) - positioned on the left, after priority bar + // A Canvas, not a plain Composite: the border below is drawn in a paint listener, and RAP + // only offers one on Canvas. On a Composite this compiles against desktop SWT and fails in + // Hop Web with NoSuchMethodError, taking the whole notification list down with it. + Canvas sourceIndicator = new Canvas(composite, SWT.NONE); + sourceIndicator.setLayout(null); + sourceIndicator.setData("type", "sourceIndicator"); // Mark to exclude from click handling + PropsUi.setLook(sourceIndicator); + // Get color for this source from configuration + org.eclipse.swt.graphics.Color sourceColor = getSourceColor(notification, guiResource); + sourceIndicator.setBackground(sourceColor); + FormData fdSourceIndicator = new FormData(); + // Position after priority bar, 8px gap + fdSourceIndicator.left = new FormAttachment(priorityBar, 8); + fdSourceIndicator.top = new FormAttachment(0, 10); + fdSourceIndicator.width = 12; // Fixed width + fdSourceIndicator.height = 12; // Fixed height + sourceIndicator.setLayoutData(fdSourceIndicator); + + // Add a PaintListener to draw a border for better visibility + sourceIndicator.addPaintListener( + e -> { + org.eclipse.swt.graphics.Rectangle bounds = sourceIndicator.getBounds(); + e.gc.setForeground(guiResource.getColorDarkGray()); + e.gc.setLineWidth(1); + e.gc.drawRectangle(0, 0, bounds.width - 1, bounds.height - 1); + }); + + // Tooltip with source name and URL (source name shown in tooltip, not as text) + String tooltipText = buildSourceTooltip(notification); + sourceIndicator.setToolTipText(tooltipText); + + // Title - use CLabel for automatic ellipsis truncation + // Title starts after source indicator + CLabel titleLabel = new CLabel(composite, SWT.LEFT); + String fullTitle = notification.getTitle() != null ? notification.getTitle() : ""; + titleLabel.setText(fullTitle); + titleLabel.setData("type", "title"); // Mark for later updates + titleLabel.setData("fullTitle", fullTitle); // Store full title for tooltip + PropsUi.setLook(titleLabel); + if (!notification.isRead()) { + titleLabel.setFont(guiResource.getFontBold()); + } + // Set tooltip to show full title if truncated + titleLabel.setToolTipText(fullTitle); + FormData fdTitle = new FormData(); + // Title starts after source indicator with 8px gap + fdTitle.left = new FormAttachment(sourceIndicator, 8); + // Title extends to right edge with margin + fdTitle.right = new FormAttachment(100, -10); + fdTitle.top = new FormAttachment(0, 10); + // Don't attach bottom - CLabel will size to its preferred height + titleLabel.setLayoutData(fdTitle); + + // Timestamp between title and body (always visible) + Label timeLabel = new Label(composite, SWT.NONE); + String timeText = formatTimestamp(notification.getTimestamp()); + if (timeText == null || timeText.isEmpty()) { + timeText = "Unknown date"; + } + timeLabel.setText(timeText); + PropsUi.setLook(timeLabel); + timeLabel.setForeground(guiResource.getColorDarkGray()); + FormData fdTime = new FormData(); + fdTime.left = new FormAttachment(priorityBar, 10); + // Timestamp extends to right edge of composite + fdTime.right = new FormAttachment(100, -10); + fdTime.top = new FormAttachment(titleLabel, 5); + // Don't attach bottom - label will size to its preferred height + timeLabel.setLayoutData(fdTime); + + // Message/Description - simplified, truncated to max 3-5 lines + Label messageLabel = null; + String message = notification.getMessage(); + + if (message != null && !message.isEmpty()) { + // Limit to approximately 3-5 lines (roughly 200-300 characters) + // Simple truncation - just show start of message + int maxLength = 250; + String displayMessage = message; + if (displayMessage.length() > maxLength) { + displayMessage = displayMessage.substring(0, maxLength).trim() + "..."; + } + messageLabel = new Label(composite, SWT.WRAP); + messageLabel.setText(displayMessage); + PropsUi.setLook(messageLabel); + FormData fdMessage = new FormData(); + fdMessage.left = new FormAttachment(priorityBar, 10); + // Message extends to right edge of composite (sourceIndicator is on left, so don't constrain + // by it) + fdMessage.right = new FormAttachment(100, -10); + fdMessage.top = new FormAttachment(timeLabel, 5); + // Don't attach bottom - label will wrap and size to its content + messageLabel.setLayoutData(fdMessage); + + // Set default cursor (not clickable) + messageLabel.setCursor( + composite.getDisplay().getSystemCursor(org.eclipse.swt.SWT.CURSOR_ARROW)); + } + + // Force layout of this composite to ensure all children are properly sized + composite.layout(true, true); + + // Set cursor to pointer to indicate clickability for entire notification area + // Store reference to notification ID and guiResource for updates + composite.setData("notificationId", notification.getId()); + composite.setData("guiResource", guiResource); + + // Set cursor behavior: title and composite are clickable, body and timestamp are not + org.eclipse.swt.graphics.Cursor handCursor = + composite.getDisplay().getSystemCursor(org.eclipse.swt.SWT.CURSOR_HAND); + org.eclipse.swt.graphics.Cursor defaultCursor = + composite.getDisplay().getSystemCursor(org.eclipse.swt.SWT.CURSOR_ARROW); + + // Title is clickable - use hand cursor + titleLabel.setCursor(handCursor); + + // Body (message) and timestamp are NOT clickable - use default cursor + if (messageLabel != null) { + messageLabel.setCursor(defaultCursor); + } + timeLabel.setCursor(defaultCursor); + + // Composite itself is clickable (for clicking outside title but still on notification) + composite.setCursor(handCursor); + + // Click handler - attach to composite and all child controls + org.eclipse.swt.widgets.Listener clickListener = + e -> { + // Mark as read first - this updates the notification in the service + NotificationService.getInstance().markAsRead(notification.getId()); + + // Get fresh notification from service to ensure we have updated state + List<Notification> allNotifications = + NotificationService.getInstance().getNotifications(false); + Notification updatedNotification = + allNotifications.stream() + .filter(n -> notification.getId().equals(n.getId())) + .findFirst() + .orElse(notification); + + // Ensure it's marked as read (should already be, but be safe) + updatedNotification.setRead(true); + + // Update visual state immediately + updateNotificationBackground(composite, updatedNotification, guiResource); + + // Force redraw to ensure visual changes are visible + composite.redraw(); + + // Open link if available. NotificationService drops links it will not open, but the + // link is handed to the operating system here, so it is checked again at the click. + String link = updatedNotification.getLink(); + if (link != null && !link.isEmpty()) { + if (NotificationLinks.isSafe(link)) { + try { + EnvironmentUtils.getInstance().openUrl(link); + } catch (Exception ex) { + LogChannel.UI.logError("Error opening notification link " + link, ex); + } + } else { + LogChannel.UI.logBasic( + "Refusing to open notification link " + + link + + ": only http and https are opened"); + } + } + }; + + // Attach click handler to composite + composite.addListener(SWT.MouseDown, clickListener); + + // Also attach to all child controls to make entire area clickable + attachClickListenerRecursive(composite, clickListener); + + return composite; + } + + /** Update notification background based on read state and priority */ + private void updateNotificationBackground( + Composite composite, Notification notification, GuiResource guiResource) { + if (notification.isRead()) { + // Read notifications have default background + composite.setBackground(null); + } else { + // Unread notifications have colored background based on priority + if (notification.getPriority() != null) { + switch (notification.getPriority()) { + case ERROR: + composite.setBackground(guiResource.getColor(255, 240, 240)); // Light red tint + break; + case WARNING: + composite.setBackground(guiResource.getColor(255, 250, 240)); // Light yellow tint + break; + case INFO: + default: + composite.setBackground(guiResource.getColorLightGray()); + break; + } + } else { + composite.setBackground(guiResource.getColorLightGray()); + } + } + + // Update priority bar color based on read state + Control[] children = composite.getChildren(); + for (Control child : children) { + if (child instanceof Composite) { + Object type = child.getData("type"); + if ("priorityBar".equals(type)) { + updatePriorityBar((Composite) child, notification, guiResource); + } + } + // Check for title label (can be CLabel or Label) + Object type = child.getData("type"); + if ("title".equals(type)) { + if (child instanceof CLabel) { + CLabel titleLabel = (CLabel) child; + if (notification.isRead()) { + // Use default font (remove bold) + titleLabel.setFont(null); + } else { + titleLabel.setFont(guiResource.getFontBold()); + } + } else if (child instanceof Label) { + Label titleLabel = (Label) child; + if (notification.isRead()) { + // Use default font (remove bold) + titleLabel.setFont(null); + } else { + titleLabel.setFont(guiResource.getFontBold()); + } + } + } + } + } + + /** Update priority bar color based on read state */ + private void updatePriorityBar( + Composite priorityBar, Notification notification, GuiResource guiResource) { + if (notification.isRead()) { + // Read notifications have gray priority bar + priorityBar.setBackground(guiResource.getColorGray()); + } else { + // Unread notifications use the source color from configuration + org.eclipse.swt.graphics.Color sourceColor = getSourceColor(notification, guiResource); + priorityBar.setBackground(sourceColor); + } + } + + /** Recursively attach click listener to composite and all its children */ + private void attachClickListenerRecursive( + Control control, org.eclipse.swt.widgets.Listener listener) { + if (control == null || control.isDisposed()) { + return; + } + // Don't attach to the priority bar or source indicator (they're just visual indicators) + Object type = control.getData("type"); + if (!"priorityBar".equals(type) && !"sourceIndicator".equals(type)) { + // Only attach to leaf controls (Labels, etc.) to avoid duplicate events + // The composite already has the listener, so we don't need to attach to child composites + if (!(control instanceof Composite)) { + control.addListener(SWT.MouseDown, listener); + } + } + if (control instanceof Composite) { + Composite composite = (Composite) control; + for (Control child : composite.getChildren()) { + attachClickListenerRecursive(child, listener); + } + } + } + + /** + * Get color for a notification source. This will be configurable via ConfigOption later. For now, + * uses a simple hash-based color scheme. + */ + private org.eclipse.swt.graphics.Color getSourceColor( + Notification notification, GuiResource guiResource) { + // Try to get color from notification source configuration + String sourceId = notification.getSourceId(); + if (sourceId != null && !sourceId.isEmpty()) { + for (org.apache.hop.ui.hopgui.notifications.config.NotificationSourceConfig source : + sourcesForRender) { + if (sourceId.equals(source.getId())) { + String colorHex = source.getColor(); + if (colorHex != null && !colorHex.isEmpty()) { + try { + String hex = colorHex.startsWith("#") ? colorHex.substring(1) : colorHex; + int colorValue = Integer.parseInt(hex, 16); + return guiResource.getColor( + (colorValue >> 16) & 0xFF, (colorValue >> 8) & 0xFF, colorValue & 0xFF); + } catch (NumberFormatException e) { + // Not a colour we can read; fall through to one derived from the source name. + } + } + break; + } + } + } + + // Fallback: hash-based color generation for consistent colors per source + String source = notification.getSource(); + if (source == null || source.isEmpty()) { + return guiResource.getColorGray(); + } + + int hash = source.hashCode(); + int r = Math.abs(hash % 200) + 50; // 50-250 range + int g = Math.abs((hash >> 8) % 200) + 50; + int b = Math.abs((hash >> 16) % 200) + 50; + + return guiResource.getColor(r, g, b); + } + + /** Build tooltip text for source indicator showing source name and URL */ + private String buildSourceTooltip(Notification notification) { + StringBuilder tooltip = new StringBuilder(); + if (notification.getSource() != null && !notification.getSource().isEmpty()) { + tooltip.append("Source: ").append(notification.getSource()); + } + if (notification.getLink() != null && !notification.getLink().isEmpty()) { + if (tooltip.length() > 0) { + tooltip.append("\n"); + } + tooltip.append("URL: ").append(notification.getLink()); + } + return tooltip.length() > 0 ? tooltip.toString() : "Unknown source"; + } + + /** Format timestamp for display */ + private String formatTimestamp(Date timestamp) { + if (timestamp == null) { + return ""; + } + long diff = System.currentTimeMillis() - timestamp.getTime(); + long minutes = diff / 60000; + long hours = diff / 3600000; + long days = diff / 86400000; + + if (minutes < 1) { + return "Just now"; Review Comment: **[suggestion]** Relative timestamps and source tooltips are hardcoded English (`"Just now"`, `" minute(s) ago"`, `"Source:"`, `"URL:"`) while the rest of the panel is in `messages_en_US.properties`. **Suggestion:** Move these strings into the notification panel resource bundle (and handle pluralization). -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
