Skip to content

Commit f3f58ee

Browse files
committed
Change widget to dismiss
1 parent 839e703 commit f3f58ee

File tree

7 files changed

+135
-116
lines changed

7 files changed

+135
-116
lines changed

app/lib/frontend/templates/layout.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5+
import 'dart:convert';
6+
57
import 'package:_pub_shared/data/page_data.dart';
68
import 'package:_pub_shared/search/search_form.dart';
9+
import 'package:convert/convert.dart';
10+
import 'package:crypto/crypto.dart';
711
import 'package:pub_dev/admin/models.dart';
812

913
import '../../frontend/request_context.dart';
@@ -97,6 +101,11 @@ String renderLayoutPage(
97101
announcementBanner: announcementBannerHtml == null
98102
? null
99103
: d.unsafeRawHtml(announcementBannerHtml),
104+
announcementBannerHash: announcementBannerHtml == null
105+
? ''
106+
: hex
107+
.encode(sha1.convert(utf8.encode(announcementBannerHtml)).bytes)
108+
.substring(0, 16),
100109
searchBanner: showSearchBanner(type)
101110
? _renderSearchBanner(
102111
type: type,

app/lib/frontend/templates/views/shared/layout.dart

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ d.Node pageLayoutNode({
2020
required List<String>? bodyClasses,
2121
required d.Node siteHeader,
2222
required d.Node? announcementBanner,
23+
required String announcementBannerHash,
2324
required d.Node? searchBanner,
2425
required bool isLanding,
2526
required d.Node? landingBlurb,
@@ -194,14 +195,18 @@ d.Node pageLayoutNode({
194195
children: [
195196
if (announcementBanner != null)
196197
d.div(
197-
classes: ['announcement-banner'],
198-
attributes: {
199-
'data-widget': 'dismissible',
200-
'data-dismissible-by': '.dismisser'
201-
},
198+
classes: ['announcement-banner', 'dismissed'],
202199
children: [
203200
announcementBanner,
204-
d.div(classes: ['dismisser'], text: 'x'),
201+
d.div(
202+
classes: ['dismisser'],
203+
attributes: {
204+
'data-widget': 'dismiss',
205+
'data-dismiss-target': '.announcement-banner',
206+
'data-dismiss-message-id': announcementBannerHash,
207+
},
208+
text: 'x',
209+
),
205210
],
206211
),
207212
],
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:js_interop';
6+
7+
import 'package:collection/collection.dart';
8+
import 'package:web/web.dart';
9+
10+
import '../../web_util.dart';
11+
12+
/// Forget dismissed messages that are more than 2 years old
13+
late final _deadline = DateTime.now().subtract(Duration(days: 365 * 2));
14+
15+
/// Don't save more than 50 entries
16+
const _maxMissedMessages = 50;
17+
18+
/// Create a dismiss widget on [element].
19+
///
20+
/// A `data-dismiss-target` is required, this must be a CSS selector for the
21+
/// element(s) that are to be dismissed when this widget is clicked.
22+
///
23+
/// A `data-dismiss-message-id` is required, this must be a string identifying
24+
/// the message being dismissed. Once dismissed this identifier will be stored
25+
/// in `localStorage`. And next time this widget is instantiated with the same
26+
/// `data-dismiss-message-id` it'll be removed immediately.
27+
///
28+
/// When in a dismissed state the `data-dismiss-target` elements will have a
29+
/// `dismissed` class added to them. If they have this class initially, it will
30+
/// be removed unless, the message has already been dismissed previously.
31+
///
32+
/// Identifiers of dismissed messages will be stored for up to 2 years.
33+
/// No more than 50 dismissed messages are retained in `localStorage`.
34+
void create(HTMLElement element, Map<String, String> options) {
35+
final target = options['target'];
36+
if (target == null) {
37+
throw UnsupportedError('data-dismissible-target required');
38+
}
39+
final messageId = options['message-id'];
40+
if (messageId == null) {
41+
throw UnsupportedError('data-dismissible-message-id required');
42+
}
43+
44+
void applyDismissed(bool enabled) {
45+
for (final e in document.querySelectorAll(target).toList()) {
46+
if (e.isA<HTMLElement>()) {
47+
final element = e as HTMLHtmlElement;
48+
if (enabled) {
49+
element.classList.add('dismissed');
50+
} else {
51+
element.classList.remove('dismissed');
52+
}
53+
}
54+
}
55+
}
56+
57+
if (_dismissed.any((e) => e.id == messageId)) {
58+
applyDismissed(true);
59+
return;
60+
} else {
61+
applyDismissed(false);
62+
}
63+
64+
void dismiss(Event e) {
65+
e.preventDefault();
66+
67+
applyDismissed(true);
68+
_dismissed.add((
69+
id: messageId,
70+
date: DateTime.now(),
71+
));
72+
_saveDismissed();
73+
}
74+
75+
element.addEventListener('click', dismiss.toJS);
76+
}
77+
78+
/// LocalStorage key where we store the hashes of messages that have been
79+
/// dismissed.
80+
///
81+
/// Data is stored on the format: `<hash>@<date>;<hash>@<date>;...`
82+
const _dismissedMessageslocalStorageKey = 'dismissed-messages';
83+
84+
late final _dismissed = [
85+
...?window.localStorage
86+
.getItem(_dismissedMessageslocalStorageKey)
87+
?.split(';')
88+
.where((e) => e.contains('@'))
89+
.map((entry) {
90+
final [id, date, ...] = entry.split('@');
91+
return (
92+
id: id,
93+
date: DateTime.tryParse(date) ?? DateTime.fromMicrosecondsSinceEpoch(0),
94+
);
95+
}).where((entry) => entry.date.isAfter(_deadline)),
96+
];
97+
98+
void _saveDismissed() {
99+
window.localStorage.setItem(
100+
_dismissedMessageslocalStorageKey,
101+
_dismissed
102+
.sortedBy((e) => e.date) // Sort by date
103+
.reversed // Reverse ordering to prefer newest dates
104+
.take(_maxMissedMessages) // Limit how many entries we save
105+
.map((e) => e.id + '@' + e.date.toIso8601String().split('T').first)
106+
.join(';'),
107+
);
108+
}

pkg/web_app/lib/src/widget/dismissible/widget.dart

Lines changed: 0 additions & 105 deletions
This file was deleted.

pkg/web_app/lib/src/widget/widget.dart

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import 'package:web/web.dart';
1010

1111
import '../web_util.dart';
1212
import 'completion/widget.dart' deferred as completion;
13-
import 'dismissible/widget.dart' deferred as dismissible;
13+
import 'dismiss/widget.dart' deferred as dismiss;
1414

1515
/// Function to create an instance of the widget given an element and options.
1616
///
@@ -32,8 +32,7 @@ typedef _WidgetLoaderFn = FutureOr<_WidgetFn> Function();
3232
/// Map from widget name to widget loader
3333
final _widgets = <String, _WidgetLoaderFn>{
3434
'completion': () => completion.loadLibrary().then((_) => completion.create),
35-
'dismissible': () =>
36-
dismissible.loadLibrary().then((_) => dismissible.create),
35+
'dismiss': () => dismiss.loadLibrary().then((_) => dismiss.create),
3736
};
3837

3938
Future<_WidgetFn> _noSuchWidget() async =>

pkg/web_app/test/deferred_import_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ void main() {
3636
'lib/src/deferred/markdown.dart',
3737
],
3838
'completion/': [],
39-
'dismissible/': [],
39+
'dismiss/': [],
4040
};
4141

4242
for (final file in files) {

pkg/web_css/lib/src/_base.scss

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,6 @@ pre {
390390
font-size: 16px;
391391

392392
text-align: center;
393-
display: none;
394393

395394
.dismisser {
396395
float: right;
@@ -400,6 +399,10 @@ pre {
400399
user-select: none;
401400
}
402401

402+
&.dismissed {
403+
display: none;
404+
}
405+
403406
z-index: 0;
404407
animation-duration: 200ms;
405408
animation-name: slide-down;

0 commit comments

Comments
 (0)