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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/lib/frontend/templates/layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 14 additions & 2 deletions app/lib/frontend/templates/views/shared/layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ d.Node pageLayoutNode({
required List<String>? bodyClasses,
required d.Node siteHeader,
required d.Node? announcementBanner,
required String announcementBannerHash,
required d.Node? searchBanner,
required bool isLanding,
required d.Node? landingBlurb,
Expand Down Expand Up @@ -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',
),
],
),
],
),
Expand Down
2 changes: 1 addition & 1 deletion app/test/frontend/static_files_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion pkg/web_app/lib/src/widget/completion/widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> options) {
void create(HTMLElement element, Map<String, String> options) {
if (!element.isA<HTMLInputElement>()) {
throw UnsupportedError('Must be <input> element');
}
Expand Down
117 changes: 117 additions & 0 deletions pkg/web_app/lib/src/widget/dismiss/widget.dart
Original file line number Diff line number Diff line change
@@ -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<String, String> 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<HTMLElement>()) {
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: `<message-id>@<date>;<message-id>@<date>;...`,
/// where:
/// * `<date>` is on the form `YYYY-MM-DD`.
/// * `<message-id>` 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(';'),
);
}
4 changes: 3 additions & 1 deletion pkg/web_app/lib/src/widget/widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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<void> Function(
Element element,
HTMLElement element,
Map<String, String> options,
);

Expand All @@ -31,6 +32,7 @@ typedef _WidgetLoaderFn = FutureOr<_WidgetFn> Function();
/// Map from widget name to widget loader
final _widgets = <String, _WidgetLoaderFn>{
'completion': () => completion.loadLibrary().then((_) => completion.create),
'dismiss': () => dismiss.loadLibrary().then((_) => dismiss.create),
};

Future<_WidgetFn> _noSuchWidget() async =>
Expand Down
1 change: 1 addition & 0 deletions pkg/web_app/test/deferred_import_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ void main() {
'lib/src/deferred/markdown.dart',
],
'completion/': [],
'dismiss/': [],
};

for (final file in files) {
Expand Down
27 changes: 27 additions & 0 deletions pkg/web_css/lib/src/_base.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion pkg/web_css/lib/src/_site_header.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,6 +33,7 @@

@media (max-width: $device-mobile-max-width) {
&:focus-within {

.hamburger,
.site-logo {
opacity: 0.3;
Expand Down Expand Up @@ -223,6 +225,7 @@
}

.site-header-nav {

/* Navigation styles for mobile. */
@media (max-width: $device-mobile-max-width) {
position: fixed;
Expand Down Expand Up @@ -354,7 +357,7 @@
padding: 12px;
min-width: 100px;

> h3 {
>h3 {
border-bottom: 1px solid var(--pub-site_header_popup-border-color);
}
}
Expand Down
Loading