diff --git a/app/lib/frontend/templates/layout.dart b/app/lib/frontend/templates/layout.dart index 61a5e6eab1..4cec68a38f 100644 --- a/app/lib/frontend/templates/layout.dart +++ b/app/lib/frontend/templates/layout.dart @@ -2,8 +2,12 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'dart:convert'; + import 'package:_pub_shared/data/page_data.dart'; import 'package:_pub_shared/search/search_form.dart'; +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; import 'package:pub_dev/admin/models.dart'; import '../../frontend/request_context.dart'; @@ -97,6 +101,11 @@ String renderLayoutPage( announcementBanner: announcementBannerHtml == null ? null : d.unsafeRawHtml(announcementBannerHtml), + announcementBannerHash: announcementBannerHtml == null + ? '' + : hex + .encode(sha1.convert(utf8.encode(announcementBannerHtml)).bytes) + .substring(0, 16), searchBanner: showSearchBanner(type) ? _renderSearchBanner( type: type, diff --git a/app/lib/frontend/templates/views/shared/layout.dart b/app/lib/frontend/templates/views/shared/layout.dart index de1cc2239c..258e255c47 100644 --- a/app/lib/frontend/templates/views/shared/layout.dart +++ b/app/lib/frontend/templates/views/shared/layout.dart @@ -20,6 +20,7 @@ d.Node pageLayoutNode({ required List? bodyClasses, required d.Node siteHeader, required d.Node? announcementBanner, + required String announcementBannerHash, required d.Node? searchBanner, required bool isLanding, required d.Node? landingBlurb, @@ -194,8 +195,19 @@ d.Node pageLayoutNode({ children: [ if (announcementBanner != null) d.div( - classes: ['announcement-banner'], - child: announcementBanner, + classes: ['announcement-banner', 'dismissed'], + children: [ + announcementBanner, + d.div( + classes: ['dismisser'], + attributes: { + 'data-widget': 'dismiss', + 'data-dismiss-target': '.announcement-banner', + 'data-dismiss-message-id': announcementBannerHash, + }, + text: 'x', + ), + ], ), ], ), diff --git a/app/test/frontend/static_files_test.dart b/app/test/frontend/static_files_test.dart index 5fbbaf8f19..b284f4bad9 100644 --- a/app/test/frontend/static_files_test.dart +++ b/app/test/frontend/static_files_test.dart @@ -219,7 +219,7 @@ void main() { path.startsWith('/static/js/script.dart.js') && path.endsWith('part.js')) .toList(); - expect(parts.length, closeTo(11, 3)); + expect(parts.length, closeTo(17, 3)); final partsSize = parts .map((p) => cache.getFile(p)!.bytes.length) .reduce((a, b) => a + b); diff --git a/pkg/web_app/lib/src/widget/completion/widget.dart b/pkg/web_app/lib/src/widget/completion/widget.dart index e60e9892dd..0d460094b0 100644 --- a/pkg/web_app/lib/src/widget/completion/widget.dart +++ b/pkg/web_app/lib/src/widget/completion/widget.dart @@ -31,7 +31,7 @@ import '../../web_util.dart'; /// * `completion-dropdown` for the completion dropdown. /// * `completion-option` for each option in the dropdown, and, /// * `completion-option-select` is applied to selected options. -void create(Element element, Map options) { +void create(HTMLElement element, Map options) { if (!element.isA()) { throw UnsupportedError('Must be element'); } diff --git a/pkg/web_app/lib/src/widget/dismiss/widget.dart b/pkg/web_app/lib/src/widget/dismiss/widget.dart new file mode 100644 index 0000000000..1c9c57cd1e --- /dev/null +++ b/pkg/web_app/lib/src/widget/dismiss/widget.dart @@ -0,0 +1,117 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:js_interop'; + +import 'package:collection/collection.dart'; +import 'package:web/web.dart'; + +import '../../web_util.dart'; + +/// Forget dismissed messages that are more than 2 years old +late final _deadline = DateTime.now().subtract(Duration(days: 365 * 2)); + +/// Don't save more than 50 entries +const _maxMissedMessages = 50; + +/// Create a dismiss widget on [element]. +/// +/// A `data-dismiss-target` is required, this must be a CSS selector for the +/// element(s) that are to be dismissed when this widget is clicked. +/// +/// A `data-dismiss-message-id` is required, this must be a string identifying +/// the message being dismissed. Once dismissed this identifier will be stored +/// in `localStorage`. And next time this widget is instantiated with the same +/// `data-dismiss-message-id` it'll be removed immediately. +/// +/// When in a dismissed state the `data-dismiss-target` elements will have a +/// `dismissed` class added to them. If they have this class initially, it will +/// be removed unless, the message has already been dismissed previously. +/// +/// Identifiers of dismissed messages will be stored for up to 2 years. +/// No more than 50 dismissed messages are retained in `localStorage`. +void create(HTMLElement element, Map options) { + final target = options['target']; + if (target == null) { + throw UnsupportedError('data-dismissible-target required'); + } + final messageId = options['message-id']; + if (messageId == null) { + throw UnsupportedError('data-dismissible-message-id required'); + } + + void applyDismissed(bool enabled) { + for (final e in document.querySelectorAll(target).toList()) { + if (e.isA()) { + final element = e as HTMLHtmlElement; + if (enabled) { + element.classList.add('dismissed'); + } else { + element.classList.remove('dismissed'); + } + } + } + } + + if (_dismissed.any((e) => e.id == messageId)) { + applyDismissed(true); + return; + } else { + applyDismissed(false); + } + + void dismiss(Event e) { + e.preventDefault(); + + applyDismissed(true); + _dismissed.add(( + id: messageId, + date: DateTime.now(), + )); + _saveDismissed(); + } + + element.addEventListener('click', dismiss.toJS); +} + +/// LocalStorage key where we store the identifiers of messages that have been +/// dismissed. +/// +/// Data is stored on the format: `@;@;...`, +/// where: +/// * `` is on the form `YYYY-MM-DD`. +/// * `` is the base64 encoded `data-dismiss-message-id` passed to +/// a dismiss widget. +const _dismissedMessageslocalStorageKey = 'dismissed-messages'; + +late final _dismissed = [ + ...?window.localStorage + .getItem(_dismissedMessageslocalStorageKey) + ?.split(';') + .where((e) => e.contains('@')) + .map((entry) { + final [id, date, ...] = entry.split('@'); + return ( + id: window.atob(id), + date: DateTime.tryParse(date) ?? DateTime.fromMicrosecondsSinceEpoch(0), + ); + }).where((entry) => entry.date.isAfter(_deadline)), +]; + +void _saveDismissed() { + window.localStorage.setItem( + _dismissedMessageslocalStorageKey, + _dismissed + .sortedBy((e) => e.date) // Sort by date + .reversed // Reverse ordering to prefer newest dates + .take(_maxMissedMessages) // Limit how many entries we save + .map( + (e) => + window.btoa(e.id) + + '@' + + e.date.toIso8601String().split('T').first, + ) + .join(';'), + ); +} diff --git a/pkg/web_app/lib/src/widget/widget.dart b/pkg/web_app/lib/src/widget/widget.dart index 46c877fd08..3b6d7189b1 100644 --- a/pkg/web_app/lib/src/widget/widget.dart +++ b/pkg/web_app/lib/src/widget/widget.dart @@ -10,6 +10,7 @@ import 'package:web/web.dart'; import '../web_util.dart'; import 'completion/widget.dart' deferred as completion; +import 'dismiss/widget.dart' deferred as dismiss; /// Function to create an instance of the widget given an element and options. /// @@ -21,7 +22,7 @@ import 'completion/widget.dart' deferred as completion; /// `data-widget="completion"`. And option `src` is specified with: /// `data-completion-src="$value"`. typedef _WidgetFn = FutureOr Function( - Element element, + HTMLElement element, Map options, ); @@ -31,6 +32,7 @@ typedef _WidgetLoaderFn = FutureOr<_WidgetFn> Function(); /// Map from widget name to widget loader final _widgets = { 'completion': () => completion.loadLibrary().then((_) => completion.create), + 'dismiss': () => dismiss.loadLibrary().then((_) => dismiss.create), }; Future<_WidgetFn> _noSuchWidget() async => diff --git a/pkg/web_app/test/deferred_import_test.dart b/pkg/web_app/test/deferred_import_test.dart index 41342f4d10..470238772e 100644 --- a/pkg/web_app/test/deferred_import_test.dart +++ b/pkg/web_app/test/deferred_import_test.dart @@ -36,6 +36,7 @@ void main() { 'lib/src/deferred/markdown.dart', ], 'completion/': [], + 'dismiss/': [], }; for (final file in files) { diff --git a/pkg/web_css/lib/src/_base.scss b/pkg/web_css/lib/src/_base.scss index a34400f10c..7c62839c86 100644 --- a/pkg/web_css/lib/src/_base.scss +++ b/pkg/web_css/lib/src/_base.scss @@ -390,6 +390,33 @@ pre { font-size: 16px; text-align: center; + + .dismisser { + float: right; + padding: 5px 15px; + margin-top: -5px; + cursor: pointer; + user-select: none; + } + + &.dismissed { + display: none; + } + + z-index: 0; + animation-duration: 200ms; + animation-name: slide-down; + animation-timing-function: ease; +} + +@keyframes slide-down { + from { + translate: 0 -100%; + } + + to { + translate: 0 0; + } } a.-x-ago { diff --git a/pkg/web_css/lib/src/_site_header.scss b/pkg/web_css/lib/src/_site_header.scss index 0d7b2ad4a4..245066b409 100644 --- a/pkg/web_css/lib/src/_site_header.scss +++ b/pkg/web_css/lib/src/_site_header.scss @@ -21,6 +21,7 @@ } .site-header { + z-index: 100; // for animation of announcement background: var(--pub-site_header_banner-background-color); color: var(--pub-site_header_banner-text-color); display: flex; @@ -32,6 +33,7 @@ @media (max-width: $device-mobile-max-width) { &:focus-within { + .hamburger, .site-logo { opacity: 0.3; @@ -223,6 +225,7 @@ } .site-header-nav { + /* Navigation styles for mobile. */ @media (max-width: $device-mobile-max-width) { position: fixed; @@ -354,7 +357,7 @@ padding: 12px; min-width: 100px; - > h3 { + >h3 { border-bottom: 1px solid var(--pub-site_header_popup-border-color); } }