[RN] Add recent-list feature

This commit is contained in:
Lyubo Marinov 2017-12-19 18:49:36 -06:00
parent 45c405de0e
commit 1e0550c746
10 changed files with 333 additions and 351 deletions

View File

@ -25,12 +25,12 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-public:before {
content: "\e80b";
}
.icon-event_note:before {
content: "\e616";
}
.icon-public:before {
content: "\e80b";
}
.icon-timer:before {
content: "\e425";
}

View File

@ -90,22 +90,20 @@ export default class Storage {
/**
* Returns the value associated with a specific key in this storage in an
* async manner. This method is required for those cases where we need
* the stored data but we're not sure yet whether the {@code Storage}
* is already initialised or not - e.g. on app start.
* async manner. This method is required for those cases where we need the
* stored data but we're not sure yet whether the {@code Storage} is already
* initialised or not - e.g. on app start.
*
* @private
* @param {string} key - The name of the key to retrieve the value of.
* @private
* @returns {Promise}
*/
_getItemAsync(key) {
return new Promise(resolve => {
return new Promise(
resolve =>
AsyncStorage.getItem(
`${String(this._keyPrefix)}${key}`,
(error, result) => {
resolve(result ? result : null);
});
});
(error, result) => resolve(result ? result : null)));
}
/**

View File

@ -3,22 +3,9 @@
import { Component } from 'react';
import { ListView } from 'react-native';
import { getRecentRooms } from '../functions';
import { appNavigate } from '../../app';
/**
* The type of the React {@code Component} state of {@link AbstractRecentList}.
*/
type State = {
/**
* The {@code ListView.DataSource} to be used for the {@code ListView}.
* Its content comes from the native implementation of
* {@code window.localStorage}.
*/
dataSource: Object
}
import { getRecentRooms } from '../functions';
/**
* The type of the React {@code Component} props of {@link AbstractRecentList}
@ -26,28 +13,41 @@ type State = {
type Props = {
/**
* Redux store dispatch function.
* The redux store's {@code dispatch} function.
*/
dispatch: Dispatch<*>,
}
dispatch: Dispatch<*>
};
/**
* Implements a React {@link Component} which represents the list of
* conferences recently joined, similar to how a list of last dialed
* numbers list would do on a mobile
* The type of the React {@code Component} state of {@link AbstractRecentList}.
*/
type State = {
/**
* The {@code ListView.DataSource} to be used for the {@code ListView}. Its
* content comes from the native implementation of
* {@code window.localStorage}.
*/
dataSource: Object
};
/**
* Implements a React {@link Component} which represents the list of conferences
* recently joined, similar to how a list of last dialed numbers list would do
* on a mobile device.
*
* @extends Component
*/
export default class AbstractRecentList extends Component<Props, State> {
/**
* The datasource that backs the {@code ListView}
* The datasource that backs the {@code ListView}.
*/
listDataSource = new ListView.DataSource({
rowHasChanged: (r1, r2) =>
r1.conference !== r2.conference
&& r1.dateTimeStamp !== r2.dateTimeStamp
});;
});
/**
* Initializes a new {@code AbstractRecentList} instance.
@ -67,13 +67,23 @@ export default class AbstractRecentList extends Component<Props, State> {
* @inheritdoc
*/
componentWillMount() {
// this must be done asynchronously because we don't have the storage
// initiated on app startup immediately.
getRecentRooms().then(rooms => {
// The following must be done asynchronously because we don't have the
// storage initiated on app startup immediately.
getRecentRooms()
.then(rooms =>
this.setState({
dataSource: this.listDataSource.cloneWithRows(rooms)
});
});
}));
}
/**
* Joins the selected room.
*
* @param {string} room - The selected room.
* @returns {void}
*/
_onJoin(room) {
room && this.props.dispatch(appNavigate(room));
}
/**
@ -85,17 +95,4 @@ export default class AbstractRecentList extends Component<Props, State> {
_onSelect(room) {
return this._onJoin.bind(this, room);
}
/**
* Joins the selected room.
*
* @param {string} room - The selected room.
* @returns {void}
*/
_onJoin(room) {
if (room) {
this.props.dispatch(appNavigate(room));
}
}
}

View File

@ -2,11 +2,11 @@ import React from 'react';
import { ListView, Text, TouchableHighlight, View } from 'react-native';
import { connect } from 'react-redux';
import { Icon } from '../../base/font-icons';
import AbstractRecentList from './AbstractRecentList';
import styles, { UNDERLAY_COLOR } from './styles';
import { Icon } from '../../base/font-icons';
/**
* The native container rendering the list of the recently joined rooms.
*
@ -15,11 +15,11 @@ import { Icon } from '../../base/font-icons';
class RecentList extends AbstractRecentList {
/**
* Initializes a new {@code RecentList} instance.
*
*/
constructor() {
super();
// Bind event handlers so they are only bound once per instance.
this._getAvatarStyle = this._getAvatarStyle.bind(this);
this._onSelect = this._onSelect.bind(this);
this._renderConfDuration = this._renderConfDuration.bind(this);
@ -28,8 +28,8 @@ class RecentList extends AbstractRecentList {
}
/**
* Implements React's {@link Component#render()}. Renders a list of
* recently joined rooms.
* Implements React's {@link Component#render()}. Renders a list of recently
* joined rooms.
*
* @inheritdoc
* @returns {ReactElement}
@ -49,11 +49,97 @@ class RecentList extends AbstractRecentList {
);
}
/**
* Assembles the style array of the avatar based on if the conference was a
* home or remote server conference (based on current app setting).
*
* @param {Object} recentListEntry - The recent list entry being rendered.
* @private
* @returns {Array<Object>}
*/
_getAvatarStyle(recentListEntry) {
const avatarStyles = [ styles.avatar ];
if (recentListEntry.baseURL !== this.props._homeServer) {
avatarStyles.push(
this._getColorForServerName(recentListEntry.serverName));
}
return avatarStyles;
}
/**
* Returns a style (color) based on the server name, so then the same server
* will always be rendered with the same avatar color.
*
* @param {string} serverName - The recent list entry being rendered.
* @private
* @returns {Object}
*/
_getColorForServerName(serverName) {
let nameHash = 0;
for (let i = 0; i < serverName.length; i++) {
nameHash += serverName.codePointAt(i);
}
return styles[`avatarRemoteServer${(nameHash % 5) + 1}`];
}
/**
* Renders the conference duration if available.
*
* @param {Object} recentListEntry - The recent list entry being rendered.
* @private
* @returns {ReactElement}
*/
_renderConfDuration({ conferenceDurationString }) {
if (conferenceDurationString) {
return (
<View style = { styles.infoWithIcon } >
<Icon
name = 'timer'
style = { styles.inlineIcon } />
<Text style = { styles.confLength }>
{ conferenceDurationString }
</Text>
</View>
);
}
return null;
}
/**
* Renders the server info component based on if the entry was on a
* different server or not.
*
* @param {Object} recentListEntry - The recent list entry being rendered.
* @private
* @returns {ReactElement}
*/
_renderServerInfo(recentListEntry) {
if (recentListEntry.baseURL !== this.props._homeServer) {
return (
<View style = { styles.infoWithIcon } >
<Icon
name = 'public'
style = { styles.inlineIcon } />
<Text style = { styles.serverName }>
{ recentListEntry.serverName }
</Text>
</View>
);
}
return null;
}
/**
* Renders the list of recently joined rooms.
*
* @private
* @param {Object} data - The row data to be rendered.
* @private
* @returns {ReactElement}
*/
_renderRow(data) {
@ -94,93 +180,6 @@ class RecentList extends AbstractRecentList {
</TouchableHighlight>
);
}
/**
* Assembles the style array of the avatar based on if the conference
* was a home or remote server conference (based on current app setting).
*
* @private
* @param {Object} recentListEntry - The recent list entry being rendered.
* @returns {Array<Object>}
*/
_getAvatarStyle(recentListEntry) {
const avatarStyles = [ styles.avatar ];
if (recentListEntry.baseURL !== this.props._homeServer) {
avatarStyles.push(
this._getColorForServerName(recentListEntry.serverName)
);
}
return avatarStyles;
}
/**
* Returns a style (color) based on the server name, so then the
* same server will always be rendered with the same avatar color.
*
* @private
* @param {string} serverName - The recent list entry being rendered.
* @returns {Object}
*/
_getColorForServerName(serverName) {
let nameHash = 0;
for (let i = 0; i < serverName.length; i++) {
nameHash += serverName.codePointAt(i);
}
return styles[`avatarRemoteServer${(nameHash % 5) + 1}`];
}
/**
* Renders the server info component based on if the entry was
* on a different server or not.
*
* @private
* @param {Object} recentListEntry - The recent list entry being rendered.
* @returns {ReactElement}
*/
_renderServerInfo(recentListEntry) {
if (recentListEntry.baseURL !== this.props._homeServer) {
return (
<View style = { styles.infoWithIcon } >
<Icon
name = 'public'
style = { styles.inlineIcon } />
<Text style = { styles.serverName }>
{ recentListEntry.serverName }
</Text>
</View>
);
}
return null;
}
/**
* Renders the conference duration if available.
*
* @private
* @param {Object} recentListEntry - The recent list entry being rendered.
* @returns {ReactElement}
*/
_renderConfDuration(recentListEntry) {
if (recentListEntry.conferenceDurationString) {
return (
<View style = { styles.infoWithIcon } >
<Icon
name = 'timer'
style = { styles.inlineIcon } />
<Text style = { styles.confLength }>
{ recentListEntry.conferenceDurationString }
</Text>
</View>
);
}
return null;
}
}
/**
@ -195,8 +194,8 @@ class RecentList extends AbstractRecentList {
function _mapStateToProps(state) {
return {
/**
* The default server name based on which we determine
* the render method.
* The default server name based on which we determine the render
* method.
*
* @private
* @type {string}

View File

@ -1,57 +1,56 @@
import {
createStyleSheet,
BoxModel
} from '../../base/styles';
import { createStyleSheet, BoxModel } from '../../base/styles';
const AVATAR_OPACITY = 0.4;
const AVATAR_SIZE = 65;
const OVERLAY_FONT_COLOR = 'rgba(255, 255, 255, 0.6)';
export const UNDERLAY_COLOR = 'rgba(255, 255, 255, 0.2)';
/**
* The styles of the React {@code Components} of the feature: recent list
* The styles of the React {@code Component}s of the feature recent-list i.e.
* {@code RecentList}.
*/
export default createStyleSheet({
/**
* The style of the actual avatar
* The style of the actual avatar.
*/
avatar: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
alignItems: 'center',
backgroundColor: `rgba(23, 160, 219, ${AVATAR_OPACITY})`,
borderRadius: AVATAR_SIZE,
height: AVATAR_SIZE,
justifyContent: 'center',
borderRadius: AVATAR_SIZE
width: AVATAR_SIZE
},
/**
* The style of the avatar container that makes the avatar rounded.
*/
avatarContainer: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
paddingTop: 5
},
/**
* Simple {@code Text} content of the avatar (the actual initials)
* Simple {@code Text} content of the avatar (the actual initials).
*/
avatarContent: {
backgroundColor: 'rgba(0, 0, 0, 0)',
color: OVERLAY_FONT_COLOR,
fontSize: 32,
fontWeight: '100',
backgroundColor: 'rgba(0, 0, 0, 0)',
textAlign: 'center'
},
/**
* List of styles of the avatar of a remote meeting
* (not the default server). The number of colors are limited
* because they should match nicely.
* List of styles of the avatar of a remote meeting (not the default
* server). The number of colors are limited because they should match
* nicely.
*/
avatarRemoteServer1: {
backgroundColor: `rgba(232, 105, 156, ${AVATAR_OPACITY})`
@ -74,7 +73,7 @@ export default createStyleSheet({
},
/**
* Style of the conference length (if rendered)
* The style of the conference length (if rendered).
*/
confLength: {
color: OVERLAY_FONT_COLOR,
@ -82,42 +81,41 @@ export default createStyleSheet({
},
/**
* This is the top level container style of the list
* The top level container style of the list.
*/
container: {
flex: 1
},
/**
* Second line of the list (date).
* May be extended with server name later.
* Second line of the list (date). May be extended with server name later.
*/
date: {
color: OVERLAY_FONT_COLOR
},
/**
* The style of the details container (right side) of the list
* The style of the details container (right side) of the list.
*/
detailsContainer: {
alignItems: 'flex-start',
flex: 1,
flexDirection: 'column',
justifyContent: 'center',
marginLeft: 2 * BoxModel.margin,
alignItems: 'flex-start'
marginLeft: 2 * BoxModel.margin
},
/**
* The container for an info line with an inline icon.
*/
infoWithIcon: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'flex-start',
alignItems: 'center'
justifyContent: 'flex-start'
},
/**
* Style of an inline icon in an info line.
* The style of an inline icon in an info line.
*/
inlineIcon: {
color: OVERLAY_FONT_COLOR,
@ -125,27 +123,27 @@ export default createStyleSheet({
},
/**
* First line of the list (room name)
* First line of the list (room name).
*/
roomName: {
color: OVERLAY_FONT_COLOR,
fontSize: 18,
fontWeight: 'bold',
color: OVERLAY_FONT_COLOR
fontWeight: 'bold'
},
/**
* The style of one single row in the list
* The style of one single row in the list.
*/
row: {
padding: 8,
paddingBottom: 0,
alignItems: 'center',
flex: 1,
flexDirection: 'row',
alignItems: 'center'
padding: 8,
paddingBottom: 0
},
/**
* Style of the server name component (if rendered)
* The style of the server name component (if rendered).
*/
serverName: {
color: OVERLAY_FONT_COLOR,

View File

@ -1,10 +1,13 @@
/**
* The max size of the list.
*
* @type {number}
*/
export const LIST_SIZE = 30;
/**
* The name of the {@code localStorage} item where recent rooms are stored.
* The name of the {@code window.localStorage} item where recent rooms are
* stored.
*
* @type {string}
*/

View File

@ -2,131 +2,116 @@
import moment from 'moment';
import { RECENT_URL_STORAGE } from './constants';
import { i18next } from '../base/i18n';
import { parseURIString } from '../base/util';
import { RECENT_URL_STORAGE } from './constants';
/**
* Retreives the recent room list and generates all the data needed
* to be displayed.
*
* @returns {Promise} The {@code Promise} to be resolved when the list
* is available.
*/
* Retreives the recent room list and generates all the data needed to be
* displayed.
*
* @returns {Promise} The {@code Promise} to be resolved when the list is
* available.
*/
export function getRecentRooms(): Promise<Array<Object>> {
return new Promise(resolve => {
window.localStorage._getItemAsync(RECENT_URL_STORAGE)
.then(recentUrls => {
if (recentUrls) {
const recentUrlsObj = JSON.parse(recentUrls);
return new Promise((resolve, reject) =>
window.localStorage._getItemAsync(RECENT_URL_STORAGE).then(
/* onFulfilled */ recentURLs => {
const recentRoomDS = [];
for (const entry of recentUrlsObj) {
const location = parseURIString(entry.conference);
if (recentURLs) {
for (const e of JSON.parse(recentURLs)) {
const location = parseURIString(e.conference);
if (location && location.room && location.hostname) {
recentRoomDS.push({
baseURL:
`${location.protocol}//${location.host}`,
conference: entry.conference,
dateTimeStamp: entry.date,
conferenceDuration: entry.conferenceDuration,
dateString: _getDateString(
entry.date
),
conferenceDurationString: _getLengthString(
entry.conferenceDuration
),
conference: e.conference,
conferenceDuration: e.conferenceDuration,
conferenceDurationString:
_getDurationString(e.conferenceDuration),
dateString: _getDateString(e.date),
dateTimeStamp: e.date,
initials: _getInitials(location.room),
room: location.room,
serverName: location.hostname
});
}
}
}
resolve(recentRoomDS.reverse());
} else {
resolve([]);
}
});
});
}
/**
* Retreives the recent URL list as a list of objects.
*
* @returns {Array} The list of already stored recent URLs.
*/
export function getRecentUrls() {
let recentUrls = window.localStorage.getItem(RECENT_URL_STORAGE);
if (recentUrls) {
recentUrls = JSON.parse(recentUrls);
} else {
recentUrls = [];
}
return recentUrls;
}
/**
* Updates the recent URL list.
*
* @param {Array} recentUrls - The new URL list.
* @returns {void}
*/
export function updaterecentUrls(recentUrls: Array<Object>) {
window.localStorage.setItem(
RECENT_URL_STORAGE,
JSON.stringify(recentUrls)
},
/* onRejected */ reject)
);
}
/**
* Returns a well formatted date string to be displayed in the list.
*
* @private
* @param {number} dateTimeStamp - The UTC timestamp to be converted to String.
* @returns {string}
*/
function _getDateString(dateTimeStamp: number) {
const date = new Date(dateTimeStamp);
* Retreives the recent URL list as a list of objects.
*
* @returns {Array} The list of already stored recent URLs.
*/
export function getRecentURLs() {
const recentURLs = window.localStorage.getItem(RECENT_URL_STORAGE);
if (date.toDateString() === new Date().toDateString()) {
// the date is today, we use fromNow format
return moment(date)
.locale(i18next.language)
.fromNow();
}
return moment(date)
.locale(i18next.language)
.format('lll');
return recentURLs ? JSON.parse(recentURLs) : [];
}
/**
* Returns a well formatted duration string to be displayed
* as the conference length.
*
* @private
* @param {number} duration - The duration in MS.
* @returns {string}
*/
function _getLengthString(duration: number) {
* Updates the recent URL list.
*
* @param {Array} recentURLs - The new URL list.
* @returns {void}
*/
export function updateRecentURLs(recentURLs: Array<Object>) {
window.localStorage.setItem(
RECENT_URL_STORAGE,
JSON.stringify(recentURLs)
);
}
/**
* Returns a well formatted date string to be displayed in the list.
*
* @param {number} dateTimeStamp - The UTC timestamp to be converted to String.
* @private
* @returns {string}
*/
function _getDateString(dateTimeStamp: number) {
const date = new Date(dateTimeStamp);
const m = moment(date).locale(i18next.language);
if (date.toDateString() === new Date().toDateString()) {
// The date is today, we use fromNow format.
return m.fromNow();
}
return m.format('lll');
}
/**
* Returns a well formatted duration string to be displayed as the conference
* length.
*
* @param {number} duration - The duration in MS.
* @private
* @returns {string}
*/
function _getDurationString(duration: number) {
return moment.duration(duration)
.locale(i18next.language)
.humanize();
}
/**
* Returns the initials supposed to be used based on the room name.
*
* @private
* @param {string} room - The room name.
* @returns {string}
*/
* Returns the initials supposed to be used based on the room name.
*
* @param {string} room - The room name.
* @private
* @returns {string}
*/
function _getInitials(room: string) {
return room && room.charAt(0) ? room.charAt(0).toUpperCase() : '?';
}

View File

@ -1,16 +1,16 @@
/* @flow */
import { LIST_SIZE } from './constants';
import { getRecentUrls, updaterecentUrls } from './functions';
// @flow
import { CONFERENCE_WILL_LEAVE, SET_ROOM } from '../base/conference';
import { MiddlewareRegistry } from '../base/redux';
import { LIST_SIZE } from './constants';
import { getRecentURLs, updateRecentURLs } from './functions';
/**
* Middleware that captures joined rooms so then it can be saved to
* {@code localStorage}
* Middleware that captures joined rooms so they can be saved into
* {@code window.localStorage}.
*
* @param {Store} store - Redux store.
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
@ -26,44 +26,44 @@ MiddlewareRegistry.register(store => next => action => {
});
/**
* Stores the recently joined room in {@code localStorage}.
* Stores the recently joined room into {@code window.localStorage}.
*
* @param {Store} store - The redux store in which the specified action is being
* dispatched.
* @param {Dispatch} next - The redux dispatch function to dispatch the
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
* specified action to the specified store.
* @param {Action} action - The redux action CONFERENCE_JOINED which is being
* @param {Action} action - The redux action {@code SET_ROOM} which is being
* dispatched in the specified store.
* @private
* @returns {Object} The new state that is the result of the reduction of the
* specified action.
*/
function _storeJoinedRoom(store, next, action) {
const result = next(action);
const { room } = action;
if (room) {
const { locationURL } = store.getState()['features/base/connection'];
const conferenceLink = locationURL.href;
const conference = locationURL.href;
// if the current conference is already in the list,
// we remove it to add it
// to the top at the end
const recentUrls = getRecentUrls().filter(
entry => entry.conference !== conferenceLink
);
// If the current conference is already in the list, we remove it to add
// it to the top at the end.
const recentURLs
= getRecentURLs()
.filter(e => e.conference !== conference);
// please note, this is a reverse sorted array
// (newer elements at the end)
recentUrls.push({
conference: conferenceLink,
date: Date.now(),
conferenceDuration: 0
// XXX This is a reverse sorted array (i.e. newer elements at the end).
recentURLs.push({
conference,
conferenceDuration: 0,
date: Date.now()
});
// maximising the size
recentUrls.splice(0, recentUrls.length - LIST_SIZE);
recentURLs.splice(0, recentURLs.length - LIST_SIZE);
updaterecentUrls(recentUrls);
updateRecentURLs(recentURLs);
}
return result;
@ -72,33 +72,35 @@ function _storeJoinedRoom(store, next, action) {
/**
* Updates the conference length when left.
*
* @private
* @param {Store} store - The redux store in which the specified action is being
* dispatched.
* @param {Dispatch} next - The redux dispatch function to dispatch the
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
* specified action to the specified store.
* @param {Action} action - The redux action CONFERENCE_JOINED which is being
* dispatched in the specified store.
* @param {Action} action - The redux action {@code CONFERENCE_WILL_LEAVE} which
* is being dispatched in the specified store.
* @private
* @returns {Object} The new state that is the result of the reduction of the
* specified action.
*/
function _updateConferenceDuration(store, next, action) {
function _updateConferenceDuration({ getState }, next, action) {
const result = next(action);
const { locationURL } = store.getState()['features/base/connection'];
const { locationURL } = getState()['features/base/connection'];
if (locationURL && locationURL.href) {
const recentUrls = getRecentUrls();
const recentURLs = getRecentURLs();
if (recentUrls.length > 0
&& recentUrls[recentUrls.length - 1].conference
=== locationURL.href) {
// the last conference start was stored
// so we need to update the length
if (recentURLs.length > 0) {
const mostRecentURL = recentURLs[recentURLs.length - 1];
recentUrls[recentUrls.length - 1].conferenceDuration
= Date.now() - recentUrls[recentUrls.length - 1].date;
if (mostRecentURL.conference === locationURL.href) {
// The last conference start was stored so we need to update the
// length.
mostRecentURL.conferenceDuration
= Date.now() - mostRecentURL.date;
updaterecentUrls(recentUrls);
updateRecentURLs(recentURLs);
}
}
}

View File

@ -2,10 +2,6 @@ import React from 'react';
import { TextInput, TouchableHighlight, View } from 'react-native';
import { connect } from 'react-redux';
import { AbstractWelcomePage, _mapStateToProps } from './AbstractWelcomePage';
import LocalVideoTrackUnderlay from './LocalVideoTrackUnderlay';
import styles, { PLACEHOLDER_TEXT_COLOR } from './styles';
import { translate } from '../../base/i18n';
import { MEDIA_TYPE } from '../../base/media';
import { Link, LoadingIndicator, Text } from '../../base/react';
@ -13,6 +9,10 @@ import { ColorPalette } from '../../base/styles';
import { createDesiredLocalTracks } from '../../base/tracks';
import { RecentList } from '../../recent-list';
import { AbstractWelcomePage, _mapStateToProps } from './AbstractWelcomePage';
import LocalVideoTrackUnderlay from './LocalVideoTrackUnderlay';
import styles, { PLACEHOLDER_TEXT_COLOR } from './styles';
/**
* The URL at which the privacy policy is available to the user.
*/

View File

@ -5,13 +5,13 @@ import {
fixAndroidViewClipping
} from '../../base/styles';
export const PLACEHOLDER_TEXT_COLOR = 'rgba(255, 255, 255, 0.3)';
/**
* The default color of text on the WelcomePage.
*/
const TEXT_COLOR = ColorPalette.white;
export const PLACEHOLDER_TEXT_COLOR = 'rgba(255, 255, 255, 0.3)';
/**
* The styles of the React {@code Components} of the feature welcome including
* {@code WelcomePage} and {@code BlankPage}.