Removes setting for iOS devices that support Visual Viewport API. On devices where it was previously enabled, it was causing some scrolling drift when invoking the composer.
701 lines
18 KiB
JavaScript
701 lines
18 KiB
JavaScript
import { escape } from "pretty-text/sanitizer";
|
|
import toMarkdown from "discourse/lib/to-markdown";
|
|
|
|
const homepageSelector = "meta[name=discourse_current_homepage]";
|
|
|
|
export function translateSize(size) {
|
|
switch (size) {
|
|
case "tiny":
|
|
return 20;
|
|
case "small":
|
|
return 25;
|
|
case "medium":
|
|
return 32;
|
|
case "large":
|
|
return 45;
|
|
case "extra_large":
|
|
return 60;
|
|
case "huge":
|
|
return 120;
|
|
}
|
|
return size;
|
|
}
|
|
|
|
export function escapeExpression(string) {
|
|
// don't escape SafeStrings, since they're already safe
|
|
if (string instanceof Handlebars.SafeString) {
|
|
return string.toString();
|
|
}
|
|
|
|
return escape(string);
|
|
}
|
|
|
|
let _usernameFormatDelegate = username => username;
|
|
|
|
export function formatUsername(username) {
|
|
return _usernameFormatDelegate(username || "");
|
|
}
|
|
|
|
export function replaceFormatter(fn) {
|
|
_usernameFormatDelegate = fn;
|
|
}
|
|
|
|
export function avatarUrl(template, size) {
|
|
if (!template) {
|
|
return "";
|
|
}
|
|
const rawSize = getRawSize(translateSize(size));
|
|
return template.replace(/\{size\}/g, rawSize);
|
|
}
|
|
|
|
export function getRawSize(size) {
|
|
const pixelRatio = window.devicePixelRatio || 1;
|
|
return size * Math.min(3, Math.max(1, Math.round(pixelRatio)));
|
|
}
|
|
|
|
export function avatarImg(options, getURL) {
|
|
getURL = getURL || Discourse.getURLWithCDN;
|
|
|
|
const size = translateSize(options.size);
|
|
const url = avatarUrl(options.avatarTemplate, size);
|
|
|
|
// We won't render an invalid url
|
|
if (!url || url.length === 0) {
|
|
return "";
|
|
}
|
|
|
|
const classes =
|
|
"avatar" + (options.extraClasses ? " " + options.extraClasses : "");
|
|
const title = options.title
|
|
? " title='" + escapeExpression(options.title || "") + "'"
|
|
: "";
|
|
|
|
return (
|
|
"<img alt='' width='" +
|
|
size +
|
|
"' height='" +
|
|
size +
|
|
"' src='" +
|
|
getURL(url) +
|
|
"' class='" +
|
|
classes +
|
|
"'" +
|
|
title +
|
|
">"
|
|
);
|
|
}
|
|
|
|
export function tinyAvatar(avatarTemplate, options) {
|
|
return avatarImg(
|
|
_.merge({ avatarTemplate: avatarTemplate, size: "tiny" }, options)
|
|
);
|
|
}
|
|
|
|
export function postUrl(slug, topicId, postNumber) {
|
|
var url = Discourse.getURL("/t/");
|
|
if (slug) {
|
|
url += slug + "/";
|
|
} else {
|
|
url += "topic/";
|
|
}
|
|
url += topicId;
|
|
if (postNumber > 1) {
|
|
url += "/" + postNumber;
|
|
}
|
|
return url;
|
|
}
|
|
|
|
export function emailValid(email) {
|
|
// see: http://stackoverflow.com/questions/46155/validate-email-address-in-javascript
|
|
const re = /^[a-zA-Z0-9!#$%&'*+\/=?\^_`{|}~\-]+(?:\.[a-zA-Z0-9!#$%&'\*+\/=?\^_`{|}~\-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?$/;
|
|
return re.test(email);
|
|
}
|
|
|
|
export function extractDomainFromUrl(url) {
|
|
if (url.indexOf("://") > -1) {
|
|
url = url.split("/")[2];
|
|
} else {
|
|
url = url.split("/")[0];
|
|
}
|
|
return url.split(":")[0];
|
|
}
|
|
|
|
export function selectedText() {
|
|
const selection = window.getSelection();
|
|
if (selection.isCollapsed) {
|
|
return "";
|
|
}
|
|
|
|
const $div = $("<div>");
|
|
for (let r = 0; r < selection.rangeCount; r++) {
|
|
const range = selection.getRangeAt(r);
|
|
const $ancestor = $(range.commonAncestorContainer);
|
|
|
|
// ensure we never quote text in the post menu area
|
|
const $postMenuArea = $ancestor.find(".post-menu-area")[0];
|
|
if ($postMenuArea) {
|
|
range.setEndBefore($postMenuArea);
|
|
}
|
|
|
|
$div.append(range.cloneContents());
|
|
}
|
|
|
|
return toMarkdown($div.html());
|
|
}
|
|
|
|
// Determine the row and col of the caret in an element
|
|
export function caretRowCol(el) {
|
|
var cp = caretPosition(el);
|
|
var rows = el.value.slice(0, cp).split("\n");
|
|
var rowNum = rows.length;
|
|
|
|
var colNum =
|
|
cp -
|
|
rows.splice(0, rowNum - 1).reduce(function(sum, row) {
|
|
return sum + row.length + 1;
|
|
}, 0);
|
|
|
|
return { rowNum: rowNum, colNum: colNum };
|
|
}
|
|
|
|
// Determine the position of the caret in an element
|
|
export function caretPosition(el) {
|
|
var r, rc, re;
|
|
if (el.selectionStart) {
|
|
return el.selectionStart;
|
|
}
|
|
if (document.selection) {
|
|
el.focus();
|
|
r = document.selection.createRange();
|
|
if (!r) return 0;
|
|
|
|
re = el.createTextRange();
|
|
rc = re.duplicate();
|
|
re.moveToBookmark(r.getBookmark());
|
|
rc.setEndPoint("EndToStart", re);
|
|
return rc.text.length;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Set the caret's position
|
|
export function setCaretPosition(ctrl, pos) {
|
|
var range;
|
|
if (ctrl.setSelectionRange) {
|
|
ctrl.focus();
|
|
ctrl.setSelectionRange(pos, pos);
|
|
return;
|
|
}
|
|
if (ctrl.createTextRange) {
|
|
range = ctrl.createTextRange();
|
|
range.collapse(true);
|
|
range.moveEnd("character", pos);
|
|
range.moveStart("character", pos);
|
|
return range.select();
|
|
}
|
|
}
|
|
|
|
export function validateUploadedFiles(files, opts) {
|
|
if (!files || files.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
if (files.length > 1) {
|
|
bootbox.alert(I18n.t("post.errors.too_many_uploads"));
|
|
return false;
|
|
}
|
|
|
|
const upload = files[0];
|
|
|
|
// CHROME ONLY: if the image was pasted, sets its name to a default one
|
|
if (typeof Blob !== "undefined" && typeof File !== "undefined") {
|
|
if (
|
|
upload instanceof Blob &&
|
|
!(upload instanceof File) &&
|
|
upload.type === "image/png"
|
|
) {
|
|
upload.name = "image.png";
|
|
}
|
|
}
|
|
|
|
opts = opts || {};
|
|
opts.type = uploadTypeFromFileName(upload.name);
|
|
|
|
return validateUploadedFile(upload, opts);
|
|
}
|
|
|
|
export function validateUploadedFile(file, opts) {
|
|
if (opts.skipValidation) return true;
|
|
if (!authorizesOneOrMoreExtensions()) return false;
|
|
|
|
opts = opts || {};
|
|
|
|
const name = file && file.name;
|
|
|
|
if (!name) {
|
|
return false;
|
|
}
|
|
|
|
// check that the uploaded file is authorized
|
|
if (opts.allowStaffToUploadAnyFileInPm && opts.isPrivateMessage) {
|
|
if (Discourse.User.currentProp("staff")) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (opts.imagesOnly) {
|
|
if (!isAnImage(name) && !isAuthorizedImage(name)) {
|
|
bootbox.alert(
|
|
I18n.t("post.errors.upload_not_authorized", {
|
|
authorized_extensions: authorizedImagesExtensions()
|
|
})
|
|
);
|
|
return false;
|
|
}
|
|
} else if (opts.csvOnly) {
|
|
if (!/\.csv$/i.test(name)) {
|
|
bootbox.alert(I18n.t("user.invited.bulk_invite.error"));
|
|
return false;
|
|
}
|
|
} else {
|
|
if (!authorizesAllExtensions() && !isAuthorizedFile(name)) {
|
|
bootbox.alert(
|
|
I18n.t("post.errors.upload_not_authorized", {
|
|
authorized_extensions: authorizedExtensions()
|
|
})
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (!opts.bypassNewUserRestriction) {
|
|
// ensures that new users can upload a file
|
|
if (!Discourse.User.current().isAllowedToUploadAFile(opts.type)) {
|
|
bootbox.alert(
|
|
I18n.t(`post.errors.${opts.type}_upload_not_allowed_for_new_user`)
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// everything went fine
|
|
return true;
|
|
}
|
|
|
|
const IMAGES_EXTENSIONS_REGEX = /(png|jpe?g|gif|svg|ico)/i;
|
|
|
|
function extensionsToArray(exts) {
|
|
return exts
|
|
.toLowerCase()
|
|
.replace(/[\s\.]+/g, "")
|
|
.split("|")
|
|
.filter(ext => ext.indexOf("*") === -1);
|
|
}
|
|
|
|
function extensions() {
|
|
return extensionsToArray(Discourse.SiteSettings.authorized_extensions);
|
|
}
|
|
|
|
function staffExtensions() {
|
|
return extensionsToArray(
|
|
Discourse.SiteSettings.authorized_extensions_for_staff
|
|
);
|
|
}
|
|
|
|
function imagesExtensions() {
|
|
let exts = extensions().filter(ext => IMAGES_EXTENSIONS_REGEX.test(ext));
|
|
if (Discourse.User.currentProp("staff")) {
|
|
const staffExts = staffExtensions().filter(ext =>
|
|
IMAGES_EXTENSIONS_REGEX.test(ext)
|
|
);
|
|
exts = _.union(exts, staffExts);
|
|
}
|
|
return exts;
|
|
}
|
|
|
|
function extensionsRegex() {
|
|
return new RegExp("\\.(" + extensions().join("|") + ")$", "i");
|
|
}
|
|
|
|
function imagesExtensionsRegex() {
|
|
return new RegExp("\\.(" + imagesExtensions().join("|") + ")$", "i");
|
|
}
|
|
|
|
function staffExtensionsRegex() {
|
|
return new RegExp("\\.(" + staffExtensions().join("|") + ")$", "i");
|
|
}
|
|
|
|
function isAuthorizedFile(fileName) {
|
|
if (
|
|
Discourse.User.currentProp("staff") &&
|
|
staffExtensionsRegex().test(fileName)
|
|
) {
|
|
return true;
|
|
}
|
|
return extensionsRegex().test(fileName);
|
|
}
|
|
|
|
function isAuthorizedImage(fileName) {
|
|
return imagesExtensionsRegex().test(fileName);
|
|
}
|
|
|
|
export function authorizedExtensions() {
|
|
const exts = Discourse.User.currentProp("staff")
|
|
? [...extensions(), ...staffExtensions()]
|
|
: extensions();
|
|
return exts.filter(ext => ext.length > 0).join(", ");
|
|
}
|
|
|
|
export function authorizedImagesExtensions() {
|
|
return authorizesAllExtensions()
|
|
? "png, jpg, jpeg, gif, svg, ico"
|
|
: imagesExtensions().join(", ");
|
|
}
|
|
|
|
export function authorizesAllExtensions() {
|
|
return (
|
|
Discourse.SiteSettings.authorized_extensions.indexOf("*") >= 0 ||
|
|
(Discourse.SiteSettings.authorized_extensions_for_staff.indexOf("*") >= 0 &&
|
|
Discourse.User.currentProp("staff"))
|
|
);
|
|
}
|
|
|
|
export function authorizesOneOrMoreExtensions() {
|
|
if (authorizesAllExtensions()) return true;
|
|
|
|
return (
|
|
Discourse.SiteSettings.authorized_extensions.split("|").filter(ext => ext)
|
|
.length > 0
|
|
);
|
|
}
|
|
|
|
export function authorizesOneOrMoreImageExtensions() {
|
|
if (authorizesAllExtensions()) return true;
|
|
|
|
return imagesExtensions().length > 0;
|
|
}
|
|
|
|
export function isAnImage(path) {
|
|
return /\.(png|jpe?g|gif|svg|ico)$/i.test(path);
|
|
}
|
|
|
|
function uploadTypeFromFileName(fileName) {
|
|
return isAnImage(fileName) ? "image" : "attachment";
|
|
}
|
|
|
|
function isGUID(value) {
|
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
|
value
|
|
);
|
|
}
|
|
|
|
function imageNameFromFileName(fileName) {
|
|
const split = fileName.split(".");
|
|
let name = split[split.length - 2];
|
|
|
|
if (exports.isAppleDevice() && isGUID(name)) {
|
|
name = I18n.t("upload_selector.default_image_alt_text");
|
|
}
|
|
|
|
return encodeURIComponent(name);
|
|
}
|
|
|
|
export function allowsImages() {
|
|
return (
|
|
authorizesAllExtensions() ||
|
|
IMAGES_EXTENSIONS_REGEX.test(authorizedExtensions())
|
|
);
|
|
}
|
|
|
|
export function allowsAttachments() {
|
|
return (
|
|
authorizesAllExtensions() ||
|
|
authorizedExtensions().split(", ").length > imagesExtensions().length
|
|
);
|
|
}
|
|
|
|
export function uploadIcon() {
|
|
return allowsAttachments() ? "upload" : "far-image";
|
|
}
|
|
|
|
export function uploadLocation(url) {
|
|
if (Discourse.CDN) {
|
|
url = Discourse.getURLWithCDN(url);
|
|
return /^\/\//.test(url) ? "http:" + url : url;
|
|
} else if (Discourse.S3BaseUrl) {
|
|
return "https:" + url;
|
|
} else {
|
|
var protocol = window.location.protocol + "//",
|
|
hostname = window.location.hostname,
|
|
port = window.location.port ? ":" + window.location.port : "";
|
|
return protocol + hostname + port + url;
|
|
}
|
|
}
|
|
|
|
export function getUploadMarkdown(upload) {
|
|
if (isAnImage(upload.original_filename)) {
|
|
const name = imageNameFromFileName(upload.original_filename);
|
|
return ``;
|
|
} else if (
|
|
!Discourse.SiteSettings.prevent_anons_from_downloading_files &&
|
|
/\.(mov|mp4|webm|ogv|mp3|ogg|wav|m4a)$/i.test(upload.original_filename)
|
|
) {
|
|
return uploadLocation(upload.url);
|
|
} else {
|
|
return `[${upload.original_filename}|attachment](${
|
|
upload.short_url
|
|
}) (${I18n.toHumanSize(upload.filesize)})`;
|
|
}
|
|
}
|
|
|
|
export function displayErrorForUpload(data) {
|
|
if (data.jqXHR) {
|
|
switch (data.jqXHR.status) {
|
|
// cancelled by the user
|
|
case 0:
|
|
return;
|
|
|
|
// entity too large, usually returned from the web server
|
|
case 413:
|
|
const type = uploadTypeFromFileName(data.files[0].name);
|
|
const max_size_kb = Discourse.SiteSettings[`max_${type}_size_kb`];
|
|
bootbox.alert(I18n.t("post.errors.file_too_large", { max_size_kb }));
|
|
return;
|
|
|
|
// the error message is provided by the server
|
|
case 422:
|
|
if (data.jqXHR.responseJSON.message) {
|
|
bootbox.alert(data.jqXHR.responseJSON.message);
|
|
} else {
|
|
bootbox.alert(data.jqXHR.responseJSON.errors.join("\n"));
|
|
}
|
|
return;
|
|
}
|
|
} else if (data.errors && data.errors.length > 0) {
|
|
bootbox.alert(data.errors.join("\n"));
|
|
return;
|
|
}
|
|
// otherwise, display a generic error message
|
|
bootbox.alert(I18n.t("post.errors.upload"));
|
|
}
|
|
|
|
export function defaultHomepage() {
|
|
let homepage = null;
|
|
let elem = _.first($(homepageSelector));
|
|
if (elem) {
|
|
homepage = elem.content;
|
|
}
|
|
if (!homepage) {
|
|
homepage = Discourse.SiteSettings.top_menu.split("|")[0].split(",")[0];
|
|
}
|
|
return homepage;
|
|
}
|
|
|
|
export function setDefaultHomepage(homepage) {
|
|
let elem = _.first($(homepageSelector));
|
|
if (elem) {
|
|
elem.content = homepage;
|
|
}
|
|
}
|
|
|
|
export function determinePostReplaceSelection({
|
|
selection,
|
|
needle,
|
|
replacement
|
|
}) {
|
|
const diff =
|
|
replacement.end - replacement.start - (needle.end - needle.start);
|
|
|
|
if (selection.end <= needle.start) {
|
|
// Selection ends (and starts) before needle.
|
|
return { start: selection.start, end: selection.end };
|
|
} else if (selection.start <= needle.start) {
|
|
// Selection starts before needle...
|
|
if (selection.end < needle.end) {
|
|
// ... and ends inside needle.
|
|
return { start: selection.start, end: needle.start };
|
|
} else {
|
|
// ... and spans needle completely.
|
|
return { start: selection.start, end: selection.end + diff };
|
|
}
|
|
} else if (selection.start < needle.end) {
|
|
// Selection starts inside needle...
|
|
if (selection.end <= needle.end) {
|
|
// ... and ends inside needle.
|
|
return { start: replacement.end, end: replacement.end };
|
|
} else {
|
|
// ... and spans end of needle.
|
|
return { start: replacement.end, end: selection.end + diff };
|
|
}
|
|
} else {
|
|
// Selection starts (and ends) behind needle.
|
|
return { start: selection.start + diff, end: selection.end + diff };
|
|
}
|
|
}
|
|
|
|
export function isAppleDevice() {
|
|
// IE has no DOMNodeInserted so can not get this hack despite saying it is like iPhone
|
|
// This will apply hack on all iDevices
|
|
const caps = Discourse.__container__.lookup("capabilities:main");
|
|
return caps.isIOS && !navigator.userAgent.match(/Trident/g);
|
|
}
|
|
|
|
let iPadDetected = undefined;
|
|
|
|
export function iOSWithVisualViewport() {
|
|
return isAppleDevice() && window.visualViewport !== undefined;
|
|
}
|
|
|
|
export function isiPad() {
|
|
if (iPadDetected === undefined) {
|
|
iPadDetected =
|
|
navigator.userAgent.match(/iPad/g) &&
|
|
!navigator.userAgent.match(/Trident/g);
|
|
}
|
|
return iPadDetected;
|
|
}
|
|
|
|
export function safariHacksDisabled() {
|
|
if (iOSWithVisualViewport()) return false;
|
|
|
|
let pref = localStorage.getItem("safari-hacks-disabled");
|
|
let result = false;
|
|
if (pref !== null) {
|
|
result = pref === "true";
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const toArray = items => {
|
|
items = items || [];
|
|
|
|
if (!Array.isArray(items)) {
|
|
return Array.from(items);
|
|
}
|
|
|
|
return items;
|
|
};
|
|
|
|
export function clipboardData(e, canUpload) {
|
|
const clipboard =
|
|
e.clipboardData ||
|
|
e.originalEvent.clipboardData ||
|
|
e.delegatedEvent.originalEvent.clipboardData;
|
|
|
|
const types = toArray(clipboard.types);
|
|
let files = toArray(clipboard.files);
|
|
|
|
if (types.includes("Files") && files.length === 0) {
|
|
// for IE
|
|
files = toArray(clipboard.items).filter(i => i.kind === "file");
|
|
}
|
|
|
|
canUpload = files && canUpload && types.includes("Files");
|
|
const canUploadImage =
|
|
canUpload && files.filter(f => f.type.match("^image/"))[0];
|
|
const canPasteHtml =
|
|
Discourse.SiteSettings.enable_rich_text_paste &&
|
|
types.includes("text/html") &&
|
|
!canUploadImage;
|
|
|
|
return { clipboard, types, canUpload, canPasteHtml };
|
|
}
|
|
|
|
export function toNumber(input) {
|
|
return typeof input === "number" ? input : parseFloat(input);
|
|
}
|
|
|
|
export function isNumeric(input) {
|
|
return !isNaN(toNumber(input)) && isFinite(input);
|
|
}
|
|
|
|
export function fillMissingDates(data, startDate, endDate) {
|
|
const startMoment = moment(startDate, "YYYY-MM-DD");
|
|
const endMoment = moment(endDate, "YYYY-MM-DD");
|
|
const countDays = endMoment.diff(startMoment, "days");
|
|
let currentMoment = startMoment;
|
|
|
|
for (let i = 0; i <= countDays; i++) {
|
|
let date = data[i] ? moment(data[i].x, "YYYY-MM-DD") : null;
|
|
if (i === 0 && (!date || date.isAfter(startMoment))) {
|
|
data.splice(i, 0, { x: startMoment.format("YYYY-MM-DD"), y: 0 });
|
|
} else {
|
|
if (!date || date.isAfter(moment(currentMoment))) {
|
|
data.splice(i, 0, { x: currentMoment, y: 0 });
|
|
}
|
|
}
|
|
currentMoment = moment(currentMoment)
|
|
.add(1, "day")
|
|
.format("YYYY-MM-DD");
|
|
}
|
|
return data;
|
|
}
|
|
|
|
export function areCookiesEnabled() {
|
|
// see: https://github.com/Modernizr/Modernizr/blob/400db4043c22af98d46e1d2b9cbc5cb062791192/feature-detects/cookies.js
|
|
try {
|
|
document.cookie = "cookietest=1";
|
|
var ret = document.cookie.indexOf("cookietest=") !== -1;
|
|
document.cookie = "cookietest=1; expires=Thu, 01-Jan-1970 00:00:01 GMT";
|
|
return ret;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function isiOSPWA() {
|
|
return (
|
|
window.matchMedia("(display-mode: standalone)").matches &&
|
|
navigator.userAgent.match(/(iPad|iPhone|iPod)/g)
|
|
);
|
|
}
|
|
|
|
export function isAppWebview() {
|
|
return window.ReactNativeWebView !== undefined;
|
|
}
|
|
|
|
export function postRNWebviewMessage(prop, value) {
|
|
if (window.ReactNativeWebView !== undefined) {
|
|
window.ReactNativeWebView.postMessage(JSON.stringify({ [prop]: value }));
|
|
}
|
|
}
|
|
|
|
function reportToLogster(name, error) {
|
|
const data = {
|
|
message: `${name} theme/component is throwing errors`,
|
|
stacktrace: error.stack
|
|
};
|
|
|
|
Ember.$.ajax(`${Discourse.BaseUri}/logs/report_js_error`, {
|
|
data,
|
|
type: "POST",
|
|
cache: false
|
|
});
|
|
}
|
|
// this function is used in lib/theme_javascript_compiler.rb
|
|
export function rescueThemeError(name, error, api) {
|
|
/* eslint-disable-next-line no-console */
|
|
console.error(`"${name}" error:`, error);
|
|
reportToLogster(name, error);
|
|
|
|
const currentUser = api.getCurrentUser();
|
|
if (!currentUser || !currentUser.admin) {
|
|
return;
|
|
}
|
|
|
|
const path = `${Discourse.BaseUri}/admin/customize/themes`;
|
|
const message = I18n.t("themes.broken_theme_alert", {
|
|
theme: name,
|
|
path: `<a href="${path}">${path}</a>`
|
|
});
|
|
const alertDiv = document.createElement("div");
|
|
alertDiv.classList.add("broken-theme-alert");
|
|
alertDiv.innerHTML = `⚠️ ${message}`;
|
|
document.body.prepend(alertDiv);
|
|
}
|
|
|
|
// This prevents a mini racer crash
|
|
export default {};
|