Merge remote-tracking branch 'upstream/dev' into dev
This commit is contained in:
commit
2f1702f5a4
5 changed files with 133 additions and 193 deletions
|
|
@ -28,84 +28,84 @@ let context = null;
|
||||||
|
|
||||||
/** @type {import("esbuild").BuildOptions} */
|
/** @type {import("esbuild").BuildOptions} */
|
||||||
const config = {
|
const config = {
|
||||||
entryPoints: ["src/entry.ts"],
|
entryPoints: ["src/entry.ts"],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
outfile: "dist/shiggycord.js",
|
outfile: "dist/kettu.js",
|
||||||
format: "iife",
|
format: "iife",
|
||||||
splitting: false,
|
splitting: false,
|
||||||
// Enable minification by default for release builds (when a release-branch is provided).
|
external: [],
|
||||||
// This keeps development builds unminified for easier debugging.
|
supported: {
|
||||||
minify: true,
|
// Hermes does not actually support const and let, even though it syntactically
|
||||||
external: [],
|
// accepts it, but it's treated just like 'var' and causes issues
|
||||||
supported: {
|
"const-and-let": false
|
||||||
// Hermes does not actually support const and let, even though it syntactically
|
},
|
||||||
// accepts it, but it's treated just like 'var' and causes issues
|
footer: {
|
||||||
"const-and-let": false,
|
js: "//# sourceURL=kettu"
|
||||||
},
|
},
|
||||||
loader: {
|
loader: {
|
||||||
".png": "dataurl",
|
".png": "dataurl",
|
||||||
".html": "text",
|
".html": "text"
|
||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
window: "globalThis",
|
window: "globalThis",
|
||||||
__DEV__: JSON.stringify(releaseBranch !== "main"),
|
__DEV__: JSON.stringify(releaseBranch !== "main")
|
||||||
},
|
},
|
||||||
inject: ["./shims/asyncIteratorSymbol.js", "./shims/promiseAllSettled.js", "./shims/weakref.js"],
|
inject: ["./shims/asyncIteratorSymbol.js", "./shims/promiseAllSettled.js"],
|
||||||
legalComments: "none",
|
legalComments: "none",
|
||||||
alias: {
|
alias: {
|
||||||
"!ShiggyCord-deps-shim!": "./shims/depsModule.ts",
|
"!kettu-deps-shim!": "./shims/depsModule.ts",
|
||||||
spitroast: "./node_modules/spitroast",
|
"spitroast": "./node_modules/spitroast",
|
||||||
"react/jsx-runtime": "./shims/jsxRuntime",
|
"react/jsx-runtime": "./shims/jsxRuntime"
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
globalPlugin({
|
globalPlugin({
|
||||||
...metroDeps.reduce((obj, key) => {
|
...metroDeps.reduce((obj, key) => {
|
||||||
obj[key] = `require("!ShiggyCord-deps-shim!")[${JSON.stringify(key)}]`;
|
obj[key] = `require("!kettu-deps-shim!")[${JSON.stringify(key)}]`;
|
||||||
return obj;
|
return obj;
|
||||||
}, {}),
|
}, {})
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "swc",
|
name: "swc",
|
||||||
setup(build) {
|
setup(build) {
|
||||||
build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => {
|
build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async args => {
|
||||||
const result = await swc.transformFile(args.path, {
|
const result = await swc.transformFile(args.path, {
|
||||||
jsc: {
|
jsc: {
|
||||||
externalHelpers: true,
|
externalHelpers: true,
|
||||||
transform: {
|
transform: {
|
||||||
constModules: {
|
constModules: {
|
||||||
globals: {
|
globals: {
|
||||||
"bunny-build-info": {
|
"bunny-build-info": {
|
||||||
version: `"1.4.1.2"`,
|
version: `"v1.4.2"`
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
react: {
|
react: {
|
||||||
runtime: "automatic",
|
runtime: "automatic"
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// https://github.com/facebook/hermes/blob/3815fec63d1a6667ca3195160d6e12fee6a0d8d5/doc/Features.md
|
// https://github.com/facebook/hermes/blob/3815fec63d1a6667ca3195160d6e12fee6a0d8d5/doc/Features.md
|
||||||
// https://github.com/facebook/hermes/issues/696#issuecomment-1396235791
|
// https://github.com/facebook/hermes/issues/696#issuecomment-1396235791
|
||||||
env: {
|
env: {
|
||||||
targets: "fully supports es6",
|
targets: "fully supports es6",
|
||||||
include: [
|
include: [
|
||||||
"transform-block-scoping",
|
"transform-block-scoping",
|
||||||
"transform-classes",
|
"transform-classes",
|
||||||
"transform-async-to-generator",
|
"transform-async-to-generator",
|
||||||
"transform-async-generator-functions",
|
"transform-async-generator-functions"
|
||||||
],
|
],
|
||||||
exclude: [
|
exclude: [
|
||||||
"transform-parameters",
|
"transform-parameters",
|
||||||
"transform-template-literals",
|
"transform-template-literals",
|
||||||
"transform-exponentiation-operator",
|
"transform-exponentiation-operator",
|
||||||
"transform-named-capturing-groups-regex",
|
"transform-named-capturing-groups-regex",
|
||||||
"transform-nullish-coalescing-operator",
|
"transform-nullish-coalescing-operator",
|
||||||
"transform-object-rest-spread",
|
"transform-object-rest-spread",
|
||||||
"transform-optional-chaining",
|
"transform-optional-chaining",
|
||||||
"transform-logical-assignment-operators",
|
"transform-logical-assignment-operators"
|
||||||
],
|
]
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { contents: result.code };
|
return { contents: result.code };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,6 @@
|
||||||
"MANUFACTURER": "Manufacturer",
|
"MANUFACTURER": "Manufacturer",
|
||||||
"MESSAGE": "Message",
|
"MESSAGE": "Message",
|
||||||
"MISCELLANEOUS": "Miscellaneous",
|
"MISCELLANEOUS": "Miscellaneous",
|
||||||
"MODAL_RELOAD_THEME_CHANGE": "Reload the app to apply the theme change?",
|
|
||||||
"MODAL_RELOAD_REQUIRED": "Reload app?",
|
"MODAL_RELOAD_REQUIRED": "Reload app?",
|
||||||
"MODAL_RELOAD_REQUIRED_DESC": "A reload is required to see the changes. Do you want to reload now?",
|
"MODAL_RELOAD_REQUIRED_DESC": "A reload is required to see the changes. Do you want to reload now?",
|
||||||
"MODAL_THEME_REFETCHED": "Theme refetched",
|
"MODAL_THEME_REFETCHED": "Theme refetched",
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,6 @@ import {
|
||||||
Text,
|
Text,
|
||||||
} from "@metro/common/components";
|
} from "@metro/common/components";
|
||||||
import { View, Image } from "react-native";
|
import { View, Image } from "react-native";
|
||||||
import { showConfirmationAlert } from "@core/vendetta/alerts";
|
|
||||||
import { BundleUpdaterManager } from "@lib/api/native/modules";
|
|
||||||
|
|
||||||
const { useReducer } = React;
|
const { useReducer } = React;
|
||||||
|
|
||||||
|
|
@ -352,58 +350,12 @@ function BrowseThemePage(props: BrowseThemePageProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Theme options have been changed from radio groups to individual switches
|
|
||||||
* for better UX. Each option can now be toggled independently.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export default function Themes() {
|
export default function Themes() {
|
||||||
useProxy(settings);
|
useProxy(settings);
|
||||||
useProxy(themes);
|
useProxy(themes);
|
||||||
const navigation = NavigationNative.useNavigation();
|
const navigation = NavigationNative.useNavigation();
|
||||||
|
|
||||||
const [mode, setMode] = React.useState<"installed" | "browse">("installed");
|
const [mode, setMode] = React.useState<"installed" | "browse">("installed");
|
||||||
|
|
||||||
// Track the theme that was active when the user entered the Themes page.
|
|
||||||
// If it changes while the user stays on this page, we'll prompt to reload when they leave.
|
|
||||||
const initialThemeIdRef = React.useRef<string | null>(getCurrentTheme()?.id ?? null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const beforeRemoveHandler = (e: any) => {
|
|
||||||
try {
|
|
||||||
const currentId = getCurrentTheme()?.id ?? null;
|
|
||||||
if (currentId !== initialThemeIdRef.current) {
|
|
||||||
// Notify the user that a reload is required for full application of theme changes
|
|
||||||
showConfirmationAlert({
|
|
||||||
title: Strings.MODAL_RELOAD_THEME_CHANGE,
|
|
||||||
content: Strings.MODAL_THEME_REFETCHED_DESC,
|
|
||||||
confirmText: Strings.RELOAD,
|
|
||||||
cancelText: Strings.CANCEL,
|
|
||||||
confirmColor: "red",
|
|
||||||
onConfirm: () => {
|
|
||||||
try {
|
|
||||||
BundleUpdaterManager.reload();
|
|
||||||
} catch (err) {
|
|
||||||
try {
|
|
||||||
(global as any).NativeModules?.BundleUpdaterManager?.reload?.();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// swallow any unexpected errors — we don't want to block navigation
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const unsub = navigation?.addListener ? navigation.addListener("beforeRemove", beforeRemoveHandler) : undefined;
|
|
||||||
return () => {
|
|
||||||
try {
|
|
||||||
if (unsub && typeof unsub === "function") unsub();
|
|
||||||
else if (unsub && typeof unsub.remove === "function") unsub.remove();
|
|
||||||
} catch (_) {}
|
|
||||||
};
|
|
||||||
}, [navigation]);
|
|
||||||
const [themesList, setThemesList] = React.useState<ThemeData[]>([]);
|
const [themesList, setThemesList] = React.useState<ThemeData[]>([]);
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ import { _colorRef } from "@lib/addons/themes/colors/updater";
|
||||||
import { NativeThemeModule } from "@lib/api/native/modules";
|
import { NativeThemeModule } from "@lib/api/native/modules";
|
||||||
import { before, instead } from "@lib/api/patcher";
|
import { before, instead } from "@lib/api/patcher";
|
||||||
import { findByProps } from "@metro";
|
import { findByProps } from "@metro";
|
||||||
import { byMutableProp } from "@metro/filters";
|
import chroma from "chroma-js";
|
||||||
import { createLazyModule } from "@metro/lazy";
|
|
||||||
const getChroma = () => require("chroma-js") as typeof import("chroma-js").default;
|
|
||||||
|
|
||||||
const tokenReference = findByProps("SemanticColor");
|
const tokenReference = findByProps("SemanticColor");
|
||||||
const isThemeModule = createLazyModule(byMutableProp("isThemeDark"));
|
const themeTypes = findByProps("ThemeTypes")?.ThemeTypes;
|
||||||
|
|
||||||
|
const origRawColor = { ...tokenReference.RawColor };
|
||||||
|
const origDarker = themeTypes.DARKER as string;
|
||||||
|
const origLight = themeTypes.LIGHT as string;
|
||||||
|
|
||||||
const SEMANTIC_FALLBACK_MAP: Record<string, string> = {
|
const SEMANTIC_FALLBACK_MAP: Record<string, string> = {
|
||||||
"BG_BACKDROP": "BACKGROUND_FLOATING",
|
"BG_BACKDROP": "BACKGROUND_FLOATING",
|
||||||
|
|
@ -22,13 +24,19 @@ const SEMANTIC_FALLBACK_MAP: Record<string, string> = {
|
||||||
"BG_SURFACE_RAISED": "BACKGROUND_MOBILE_PRIMARY"
|
"BG_SURFACE_RAISED": "BACKGROUND_MOBILE_PRIMARY"
|
||||||
};
|
};
|
||||||
|
|
||||||
const origRawColor = { ...tokenReference.RawColor };
|
|
||||||
|
|
||||||
export default function patchDefinitionAndResolver() {
|
export default function patchDefinitionAndResolver() {
|
||||||
const callback = ([theme]: any[]) => {
|
const callback = ([theme]: any[]) => theme === _colorRef.key ? [_colorRef.current!.reference] : void 0;
|
||||||
if (!_colorRef.current) return void 0;
|
|
||||||
return theme === _colorRef.key ? [_colorRef.current.reference] : void 0;
|
Object.defineProperty(themeTypes, "DARKER", {
|
||||||
};
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
get: () => _colorRef.current?.reference === "darker" ? _colorRef.key : origDarker,
|
||||||
|
});
|
||||||
|
Object.defineProperty(themeTypes, "LIGHT", {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
get: () => _colorRef.current?.reference === "light" ? _colorRef.key : origLight,
|
||||||
|
});
|
||||||
|
|
||||||
Object.keys(tokenReference.RawColor).forEach(key => {
|
Object.keys(tokenReference.RawColor).forEach(key => {
|
||||||
Object.defineProperty(tokenReference.RawColor, key, {
|
Object.defineProperty(tokenReference.RawColor, key, {
|
||||||
|
|
@ -37,50 +45,48 @@ export default function patchDefinitionAndResolver() {
|
||||||
get: () => {
|
get: () => {
|
||||||
const ret = _colorRef.current?.raw[key];
|
const ret = _colorRef.current?.raw[key];
|
||||||
if (ret) return ret;
|
if (ret) return ret;
|
||||||
|
|
||||||
return origRawColor[key];
|
return origRawColor[key];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const unpatches = [
|
const unpatches = [
|
||||||
before("isThemeDark", isThemeModule, callback),
|
|
||||||
before("isThemeLight", isThemeModule, callback),
|
|
||||||
before("updateTheme", NativeThemeModule, callback),
|
before("updateTheme", NativeThemeModule, callback),
|
||||||
instead("resolveSemanticColor", tokenReference.default.meta ?? tokenReference.default.internal, (args: any[], orig: any) => {
|
instead("resolveSemanticColor", tokenReference.default.meta ?? tokenReference.default.internal, (args: any[], orig: any) => {
|
||||||
if (!_colorRef.current) return orig(...args);
|
if (!_colorRef.current) return orig(...args);
|
||||||
const currentRef = _colorRef.current;
|
|
||||||
if (args[0] !== _colorRef.key) return orig(...args);
|
if (args[0] !== _colorRef.key) return orig(...args);
|
||||||
|
|
||||||
// Use the captured reference value
|
args[0] = _colorRef.current.reference;
|
||||||
args[0] = currentRef.reference;
|
|
||||||
|
|
||||||
const [name, colorDef] = extractInfo(currentRef.reference, args[1]);
|
const [name, colorDef] = extractInfo(_colorRef.current!.reference, args[1]);
|
||||||
|
|
||||||
// If extractInfo couldn't resolve a color definition, fall back to the original resolver
|
let semanticDef = _colorRef.current.semantic[name];
|
||||||
if (!name || !colorDef) return orig(...args);
|
if (!semanticDef && _colorRef.current.spec === 2 && name in SEMANTIC_FALLBACK_MAP) {
|
||||||
|
semanticDef = _colorRef.current.semantic[SEMANTIC_FALLBACK_MAP[name]];
|
||||||
let semanticDef = currentRef.semantic[name];
|
|
||||||
if (!semanticDef && currentRef.spec === 2 && name in SEMANTIC_FALLBACK_MAP) {
|
|
||||||
semanticDef = currentRef.semantic[SEMANTIC_FALLBACK_MAP[name]];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (semanticDef?.value) {
|
if (semanticDef?.value) {
|
||||||
if (semanticDef.opacity === 1) return semanticDef.value;
|
return semanticDef.opacity === 1
|
||||||
return getChroma()(semanticDef.value).alpha(semanticDef.opacity).hex();
|
? semanticDef.value
|
||||||
|
: chroma(semanticDef.value).alpha(semanticDef.opacity).hex();
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawKey = colorDef.raw;
|
const rawValue = _colorRef.current.raw[colorDef.raw];
|
||||||
const rawValue = rawKey ? currentRef.raw[rawKey] : undefined;
|
|
||||||
if (rawValue) {
|
if (rawValue) {
|
||||||
// Set opacity if needed
|
// Set opacity if needed
|
||||||
return (colorDef.opacity === 1) ? rawValue : getChroma()(rawValue).alpha(colorDef.opacity).hex();
|
return colorDef.opacity === 1 ? rawValue : chroma(rawValue).alpha(colorDef.opacity).hex();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to default
|
// Fallback to default
|
||||||
return orig(...args);
|
return orig(...args);
|
||||||
}),
|
}),
|
||||||
() => {
|
() => {
|
||||||
|
Object.defineProperty(themeTypes, "DARKER", {
|
||||||
|
configurable: true, writable: true, value: origDarker
|
||||||
|
});
|
||||||
|
Object.defineProperty(themeTypes, "LIGHT", {
|
||||||
|
configurable: true, writable: true, value: origLight
|
||||||
|
});
|
||||||
Object.defineProperty(tokenReference, "RawColor", {
|
Object.defineProperty(tokenReference, "RawColor", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
writable: true,
|
writable: true,
|
||||||
|
|
@ -95,13 +101,7 @@ export default function patchDefinitionAndResolver() {
|
||||||
function extractInfo(themeName: string, colorObj: any): [name: string, colorDef: any] {
|
function extractInfo(themeName: string, colorObj: any): [name: string, colorDef: any] {
|
||||||
// @ts-ignore - assigning to extractInfo._sym
|
// @ts-ignore - assigning to extractInfo._sym
|
||||||
const propName = colorObj[extractInfo._sym ??= Object.getOwnPropertySymbols(colorObj)[0]];
|
const propName = colorObj[extractInfo._sym ??= Object.getOwnPropertySymbols(colorObj)[0]];
|
||||||
const entry = tokenReference.SemanticColor[propName];
|
const colorDef = tokenReference.SemanticColor[propName];
|
||||||
|
|
||||||
if (!entry || typeof entry !== "object") {
|
return [propName, colorDef[themeName]];
|
||||||
return [propName, undefined];
|
|
||||||
}
|
|
||||||
|
|
||||||
const colorDef = Object.prototype.hasOwnProperty.call(entry, themeName) ? entry[themeName] : undefined;
|
|
||||||
|
|
||||||
return [propName, colorDef];
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,46 +24,35 @@ export const _colorRef: InternalColorRef = {
|
||||||
current: null,
|
current: null,
|
||||||
key: `bn-theme-${_inc}`,
|
key: `bn-theme-${_inc}`,
|
||||||
origRaw: origRawColor,
|
origRaw: origRawColor,
|
||||||
lastSetDiscordTheme: "dark"
|
lastSetDiscordTheme: "darker"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function updateBunnyColor(colorManifest: ColorManifest | null, { update = true } ) {
|
export function updateBunnyColor(colorManifest: ColorManifest | null, { update = true }) {
|
||||||
if (settings.safeMode?.enabled) return;
|
if (settings.safeMode?.enabled) return;
|
||||||
|
|
||||||
const internalDef = colorManifest ? parseColorManifest(colorManifest) : null;
|
const internalDef = colorManifest ? parseColorManifest(colorManifest) : null;
|
||||||
|
const ref = Object.assign(_colorRef, {
|
||||||
|
current: internalDef,
|
||||||
|
key: `bn-theme-${++_inc}`,
|
||||||
|
lastSetDiscordTheme: !ThemeStore.theme.startsWith("bn-theme-")
|
||||||
|
? ThemeStore.theme
|
||||||
|
: _colorRef.lastSetDiscordTheme
|
||||||
|
});
|
||||||
|
|
||||||
const keyName = internalDef && internalDef.reference === "light" ? `bn-theme-${++_inc}` : `dark`;
|
if (internalDef != null) {
|
||||||
|
tokenRef.Theme[ref.key.toUpperCase()] = ref.key;
|
||||||
|
FormDivider.DIVIDER_COLORS[ref.key] = FormDivider.DIVIDER_COLORS[ref.current!.reference];
|
||||||
|
|
||||||
const tsTheme = typeof ThemeStore.theme === "string" ? ThemeStore.theme : undefined;
|
Object.keys(tokenRef.Shadow).forEach(k => tokenRef.Shadow[k][ref.key] = tokenRef.Shadow[k][ref.current!.reference]);
|
||||||
const currentDiscordTheme = tsTheme === "darker" ? "dark" : (tsTheme ?? "");
|
Object.keys(tokenRef.SemanticColor).forEach(k => {
|
||||||
|
tokenRef.SemanticColor[k][ref.key] = {
|
||||||
const lastSet = (currentDiscordTheme && !currentDiscordTheme.startsWith("bn-theme-") && currentDiscordTheme !== keyName)
|
...tokenRef.SemanticColor[k][ref.current!.reference]
|
||||||
? currentDiscordTheme
|
};
|
||||||
: _colorRef.lastSetDiscordTheme;
|
|
||||||
|
|
||||||
var ref = Object.assign(_colorRef, {
|
|
||||||
current: internalDef,
|
|
||||||
key: keyName,
|
|
||||||
lastSetDiscordTheme: lastSet
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
if (internalDef != null) {
|
|
||||||
tokenRef.Theme[ref.key.toUpperCase()] = ref.key;
|
|
||||||
FormDivider.DIVIDER_COLORS[ref.key] = FormDivider.DIVIDER_COLORS[ref.current!.reference];
|
|
||||||
|
|
||||||
Object.keys(tokenRef.Shadow).forEach(k => tokenRef.Shadow[k][ref.key] = tokenRef.Shadow[k][ref.current!.reference]);
|
|
||||||
Object.keys(tokenRef.SemanticColor).forEach(k => {
|
|
||||||
tokenRef.SemanticColor[k][ref.key] = {
|
|
||||||
...tokenRef.SemanticColor[k][ref.current!.reference]
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (update) {
|
if (update) {
|
||||||
AppearanceManager.setShouldSyncAppearanceSettings(false);
|
AppearanceManager.setShouldSyncAppearanceSettings(false);
|
||||||
const fallback = (_colorRef.lastSetDiscordTheme && !_colorRef.lastSetDiscordTheme.startsWith("bn-theme-"))
|
AppearanceManager.updateTheme(internalDef != null ? ref.key : ref.lastSetDiscordTheme);
|
||||||
? _colorRef.lastSetDiscordTheme
|
|
||||||
: "dark";
|
|
||||||
AppearanceManager.updateTheme(internalDef != null ? ref.key : fallback);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue