mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-08-10 08:10:52 +00:00
4.20.57
* Added: Setting to hide viewer counts from the side bar. * Changed: Allow use of time formats when creating a timeout action. (Closes #978) * Changed: Highlight My Message highlights can now be displayed in either Twitch style or FFZ style, in addition to disabling the highlight. FFZ Style remains the default. (Closes #972) * Fixed: Current Channel profile rules not functioning, either on normal channel pages or on mod view pages. (Closes #957) * Fixed: Current Category and Title profile rules not working on mod view. * Fixed: Channel color not being detected correctly. (Also Closes #972) * Fixed: Download Clip not displaying on clip pages. (Closes #960) * Fixed: Remove debug logging from `resizeInput` * Fixed: Popups, including channel rules, not rendering correctly in portrait mode. (Closes #979) * Fixed: Rendering for certain elements using `tw-pill` * Fixed: Vue balloon elements not rendering correctly.
This commit is contained in:
parent
9a2a6f2acf
commit
046de0bb8a
16 changed files with 365 additions and 31 deletions
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "frankerfacez",
|
||||
"author": "Dan Salvato LLC",
|
||||
"version": "4.20.56",
|
||||
"version": "4.20.57",
|
||||
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
|
|
|
@ -7,11 +7,11 @@
|
|||
|
||||
<input
|
||||
id="edit_duration"
|
||||
v-model.number="value.duration"
|
||||
v-model="value.duration_rich"
|
||||
:placeholder="defaults.duration"
|
||||
class="tw-border-radius-medium tw-font-size-6 tw-full-width tw-input tw-pd-x-1 tw-pd-y-05 tw-mg-y-05"
|
||||
type="number"
|
||||
@input="$emit('input', value)"
|
||||
type="text"
|
||||
@input="update()"
|
||||
>
|
||||
</div>
|
||||
|
||||
|
@ -32,8 +32,48 @@
|
|||
|
||||
<script>
|
||||
|
||||
const DUR_MATCH = /(\d+)(mo|d|h|m|s)?/gi,
|
||||
MULTIPLIERS = {
|
||||
m: 60,
|
||||
h: 3600,
|
||||
d: 86400,
|
||||
mo: 86400 * 28,
|
||||
s: 1
|
||||
};
|
||||
|
||||
function durationToSeconds(dur) {
|
||||
let seconds = 0;
|
||||
let match;
|
||||
|
||||
while(match = DUR_MATCH.exec(dur)) { // eslint-disable-line no-cond-assign
|
||||
const val = parseInt(match[1], 10),
|
||||
unit = (match[2] || 's').toLowerCase(),
|
||||
multiplier = MULTIPLIERS[unit] || 1;
|
||||
|
||||
if ( isNaN(val) )
|
||||
return NaN;
|
||||
|
||||
seconds += val * multiplier;
|
||||
}
|
||||
|
||||
return seconds;
|
||||
}
|
||||
|
||||
|
||||
export default {
|
||||
props: ['value', 'defaults']
|
||||
props: ['value', 'defaults'],
|
||||
|
||||
created() {
|
||||
if ( this.value.duration_rich == null )
|
||||
this.value.duration_rich = this.value.duration;
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
this.value.duration = durationToSeconds(this.value.duration_rich);
|
||||
this.$emit('input', this.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
|
@ -1,6 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
import {createElement} from 'utilities/dom';
|
||||
import {durationForChat} from 'utilities/time';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
@ -12,7 +12,7 @@ import {duration_to_string, durationForURL} from 'utilities/time';
|
|||
import Tooltip from 'utilities/tooltip';
|
||||
import Module from 'utilities/module';
|
||||
|
||||
const CLIP_URL = /^https:\/\/[^/]+\.twitch\.tv\/.+?\.mp4$/;
|
||||
const CLIP_URL = /^https:\/\/[^/]+\.(?:twitch\.tv|twitchcdn\.net)\/.+?\.mp4(?:\?.*)?$/;
|
||||
|
||||
export default class Metadata extends Module {
|
||||
constructor(...args) {
|
||||
|
@ -265,6 +265,9 @@ export default class Metadata extends Module {
|
|||
if ( ! src || ! CLIP_URL.test(src) )
|
||||
return;
|
||||
|
||||
if ( this.settings.get('metadata.clip-download.force') )
|
||||
return src;
|
||||
|
||||
const user = this.resolve('site').getUser?.(),
|
||||
is_self = user?.id == data.channel.id;
|
||||
|
||||
|
|
|
@ -502,7 +502,7 @@ export default class Channel extends Module {
|
|||
updateRoot(el) {
|
||||
const root = this.fine.getReactInstance(el);
|
||||
|
||||
let channel = null, state = root?.return?.memoizedState, i = 0;
|
||||
let channel = null, state = root?.return?.return?.return?.memoizedState, i = 0;
|
||||
while(state != null && channel == null && i < 50 ) {
|
||||
state = state?.next;
|
||||
channel = state?.memoizedState?.current?.previousData?.result?.data?.user;
|
||||
|
|
|
@ -364,11 +364,16 @@ export default class ChatHook extends Module {
|
|||
});
|
||||
|
||||
this.settings.add('chat.points.allow-highlight', {
|
||||
default: true,
|
||||
default: 2,
|
||||
ui: {
|
||||
path: 'Chat > Channel Points >> Appearance',
|
||||
title: 'Highlight the message in chat when someone redeems Highlight My Message.',
|
||||
component: 'setting-check-box'
|
||||
component: 'setting-select-box',
|
||||
data: [
|
||||
{value: 0, title: 'Disabled'},
|
||||
{value: 1, title: 'Twitch Style'},
|
||||
{value: 2, title: 'FFZ Style'}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -92,7 +92,6 @@ export default class Input extends Module {
|
|||
Twilight.CHAT_ROUTES
|
||||
);
|
||||
|
||||
|
||||
this.EmoteSuggestions = this.fine.define(
|
||||
'tab-emote-suggestions',
|
||||
n => n && n.getMatchedEmotes,
|
||||
|
@ -296,8 +295,8 @@ export default class Input extends Module {
|
|||
const t = this;
|
||||
|
||||
const originalOnKeyDown = inst.onKeyDown,
|
||||
originalOnMessageSend = inst.onMessageSend,
|
||||
old_resize = inst.resizeInput;
|
||||
originalOnMessageSend = inst.onMessageSend;
|
||||
//old_resize = inst.resizeInput;
|
||||
|
||||
inst.resizeInput = function(msg) {
|
||||
if ( msg ) {
|
||||
|
@ -308,13 +307,6 @@ export default class Input extends Module {
|
|||
i = Math.ceil((inst.chatInputRef.scrollHeight - t) / height),
|
||||
a = Math.min(1 + i, 4);
|
||||
|
||||
window._style = style;
|
||||
window._height = height;
|
||||
window.t = t;
|
||||
window.i = i;
|
||||
window.a = a;
|
||||
console.log('resize', height, t, i, a)
|
||||
|
||||
inst.setState({
|
||||
numInputRows: a
|
||||
});
|
||||
|
|
|
@ -349,6 +349,11 @@ export default class ChatLine extends Module {
|
|||
let mod_mode = this.props.deletedMessageDisplay;
|
||||
let show, show_class, mod_action = null;
|
||||
|
||||
const highlight_mode = t.chat.context.get('chat.points.allow-highlight'),
|
||||
highlight = highlight_mode > 0 && msg.ffz_type === 'points' && msg.ffz_reward && isHighlightedReward(msg.ffz_reward),
|
||||
twitch_highlight = highlight && highlight_mode == 1,
|
||||
ffz_highlight = highlight && highlight_mode == 2;
|
||||
|
||||
if ( ! this.props.isCurrentUserModerator && mod_mode == 'DETAILED' )
|
||||
mod_mode = 'LEGACY';
|
||||
|
||||
|
@ -532,7 +537,7 @@ other {# messages were deleted by a moderator.}
|
|||
: null,
|
||||
show ?
|
||||
e('span', {
|
||||
className:'message',
|
||||
className:`message ${twitch_highlight ? 'chat-line__message-body--highlighted' : ''}`,
|
||||
style: is_action ? { color } : null
|
||||
}, t.chat.renderTokens(tokens, e, (reply_mode !== 0 && has_replies) ? this.props.reply : null))
|
||||
:
|
||||
|
@ -813,10 +818,7 @@ other {# messages were deleted by a moderator.}
|
|||
t.i18n.formatNumber(getRewardCost(msg.ffz_reward))
|
||||
]);
|
||||
|
||||
const can_highlight = t.chat.context.get('chat.points.allow-highlight'),
|
||||
highlight = can_highlight && isHighlightedReward(msg.ffz_reward);
|
||||
|
||||
cls = `ffz-notice-line ffz--points-line tw-pd-l-1 tw-pd-y-05 tw-pd-r-2${highlight ? ' ffz--points-highlight' : ''}${show_class ? ' ffz--deleted-message' : ''}${twitch_clickable ? ' tw-relative' : ''}`;
|
||||
cls = `ffz-notice-line ffz--points-line tw-pd-l-1 tw-pd-y-05 tw-pd-r-2${ffz_highlight ? ' ffz-custom-color ffz--points-highlight' : ''}${show_class ? ' ffz--deleted-message' : ''}${twitch_clickable ? ' tw-relative' : ''}`;
|
||||
out = [
|
||||
e('div', {className: 'tw-c-text-alt-2'}, [
|
||||
out ? null : t.actions.renderInline(msg, this.props.showModerationIcons, u, r, e),
|
||||
|
|
|
@ -24,6 +24,12 @@ export default class SettingsMenu extends Module {
|
|||
n => n.renderUniversalOptions && n.onBadgesChanged,
|
||||
Twilight.CHAT_ROUTES
|
||||
);
|
||||
|
||||
/*this.ChatIdentityContainer = this.fine.define(
|
||||
'chat-identity-container',
|
||||
n => n.hideChatIdentityMenu && n.toggleBalloonRef,
|
||||
Twilight.CHAT_ROUTES
|
||||
);*/
|
||||
}
|
||||
|
||||
async onEnable() {
|
||||
|
|
|
@ -14,6 +14,7 @@ const CLASSES = {
|
|||
//'unfollow': '.follow-btn__follow-btn--following,.follow-btn--following',
|
||||
'top-discover': '.navigation-link[data-a-target="discover-link"]',
|
||||
'side-nav': '.side-nav,#sideNav',
|
||||
'side-nav-viewers': '.side-nav-card__live-status',
|
||||
'side-rec-channels': '.side-nav .recommended-channels,.side-nav .side-nav-section + .side-nav-section:not(.online-friends)',
|
||||
//'side-rec-friends': '.side-nav .recommended-friends',
|
||||
'side-friends': '.side-nav .online-friends',
|
||||
|
@ -144,6 +145,16 @@ export default class CSSTweaks extends Module {
|
|||
}
|
||||
});
|
||||
|
||||
this.settings.add('layout.side-nav.hide-viewers', {
|
||||
default: false,
|
||||
ui: {
|
||||
path: 'Appearance > Layout >> Side Navigation',
|
||||
title: 'Hide Channel View Counts',
|
||||
component: 'setting-check-box'
|
||||
},
|
||||
changed: val => this.toggleHide('side-nav-viewers', val)
|
||||
});
|
||||
|
||||
this.settings.add('layout.side-nav.show-avatars', {
|
||||
default: true,
|
||||
ui: {
|
||||
|
@ -409,6 +420,7 @@ export default class CSSTweaks extends Module {
|
|||
}
|
||||
|
||||
onEnable() {
|
||||
this.toggleHide('side-nav-viewers', this.settings.get('layout.side-nav.hide-viewers'));
|
||||
this.toggle('hide-native-uptime', this.settings.get('metadata.uptime.no-native'));
|
||||
this.toggle('hide-native-viewers', this.settings.get('metadata.viewers.no-native'));
|
||||
this.toggle('chat-fix', this.settings.get('layout.use-chat-fix'));
|
||||
|
|
|
@ -66,7 +66,7 @@
|
|||
.right-column {
|
||||
display: unset !important;
|
||||
position: fixed !important;
|
||||
z-index: 10000;
|
||||
z-index: 4000;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
|
|
235
src/sites/twitch-twilight/modules/mod-view.jsx
Normal file
235
src/sites/twitch-twilight/modules/mod-view.jsx
Normal file
|
@ -0,0 +1,235 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Mod View Module
|
||||
// ============================================================================
|
||||
|
||||
import Module from 'utilities/module';
|
||||
import { Color } from 'utilities/color';
|
||||
import {debounce} from 'utilities/object';
|
||||
import {createElement, ClickOutside, setChildren} from 'utilities/dom';
|
||||
|
||||
|
||||
export default class ModView extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.inject('i18n');
|
||||
this.inject('settings');
|
||||
this.inject('site.channel');
|
||||
this.inject('site.css_tweaks');
|
||||
this.inject('site.fine');
|
||||
this.inject('site.elemental');
|
||||
this.inject('site.router');
|
||||
this.inject('site.twitch_data');
|
||||
this.inject('metadata');
|
||||
this.inject('socket');
|
||||
|
||||
this.should_enable = true;
|
||||
|
||||
this._cached_channel = null;
|
||||
|
||||
this.Root = this.elemental.define(
|
||||
'mod-view-root', '.moderation-view-page',
|
||||
['mod-view'],
|
||||
{attributes: true}, 1
|
||||
);
|
||||
|
||||
this.ModInfoBar = this.elemental.define(
|
||||
'mod-info-bar', '.modview-player-widget__stream-info .simplebar-content',
|
||||
['mod-view'],
|
||||
{childNodes: true, subtree: true}, 1
|
||||
);
|
||||
|
||||
this.checkRoot = debounce(this.checkRoot, 250);
|
||||
}
|
||||
|
||||
onEnable() {
|
||||
this.Root.on('mount', this.updateRoot, this);
|
||||
this.Root.on('mutate', this.updateRoot, this);
|
||||
this.Root.on('unmount', this.removeRoot, this);
|
||||
this.Root.each(el => this.updateRoot(el));
|
||||
|
||||
this.ModInfoBar.on('mount', this.updateBar, this);
|
||||
this.ModInfoBar.on('mutate', this.updateBar, this);
|
||||
this.ModInfoBar.on('unmount', this.removeBar, this);
|
||||
this.ModInfoBar.each(el => this.updateBar(el));
|
||||
|
||||
this.router.on(':route', this.checkNavigation, this);
|
||||
this.checkNavigation();
|
||||
}
|
||||
|
||||
updateSubscription(login) {
|
||||
if ( this._subbed_login === login )
|
||||
return;
|
||||
|
||||
if ( this._subbed_login ) {
|
||||
this.socket.unsubscribe(this, `channel.${this._subbed_login}`);
|
||||
this._subbed_login = null;
|
||||
}
|
||||
|
||||
if ( login ) {
|
||||
this.socket.subscribe(this, `channel.${login}`);
|
||||
this._subbed_login = login;
|
||||
}
|
||||
}
|
||||
|
||||
checkNavigation() {
|
||||
if ( this.router.current_name === 'mod-view' ) {
|
||||
this.channel.updateChannelColor();
|
||||
this.checkRoot();
|
||||
}
|
||||
}
|
||||
|
||||
checkRoot() {
|
||||
this.Root.each(el => this.updateRoot(el));
|
||||
}
|
||||
|
||||
updateRoot(el) {
|
||||
const root = this.fine.getReactInstance(el);
|
||||
|
||||
let channel = null, state = root?.child?.memoizedState, i = 0;
|
||||
while(state != null && channel == null && i < 50 ) {
|
||||
state = state?.next;
|
||||
channel = state?.memoizedState?.current?.previousData?.result?.data?.user;
|
||||
i++;
|
||||
}
|
||||
|
||||
if ( channel?.id && this._cached_channel != channel.id ) {
|
||||
this._cached_channel = channel.id;
|
||||
this.updateSubscription(channel.login);
|
||||
|
||||
this.getChannelColor(el, channel.id).then(color => {
|
||||
this.channel.updateChannelColor(color);
|
||||
this.settings.updateContext({
|
||||
channelColor: color
|
||||
});
|
||||
}).catch(() => {
|
||||
this.channel.updateChannelColor();
|
||||
this.settings.updateContext({
|
||||
channelColor: null
|
||||
});
|
||||
});
|
||||
|
||||
this.settings.updateContext({
|
||||
channel: channel.login,
|
||||
channelID: channel.id
|
||||
});
|
||||
|
||||
} else
|
||||
this.removeRoot();
|
||||
|
||||
}
|
||||
|
||||
getChannelColor(el, channel_id, no_promise) {
|
||||
const cache = el._ffz_color_cache = el._ffz_color_cache || {};
|
||||
if ( channel_id === cache.channel_id ) {
|
||||
if ( Date.now() - cache.saved < 60000 ) {
|
||||
if ( no_promise )
|
||||
return cache.data;
|
||||
return Promise.resolve(cache.data);
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise(async (s, f) => {
|
||||
if ( cache.updating ) {
|
||||
cache.updating.push([s, f]);
|
||||
return ;
|
||||
}
|
||||
|
||||
cache.channel_id = channel_id;
|
||||
cache.updating = [[s,f]];
|
||||
let data, err;
|
||||
|
||||
try {
|
||||
data = await this.twitch_data.getChannelColor(channel_id);
|
||||
} catch(error) {
|
||||
data = null;
|
||||
err = error;
|
||||
}
|
||||
|
||||
const waiters = cache.updating;
|
||||
cache.updating = null;
|
||||
|
||||
if ( cache.channel_id !== channel_id ) {
|
||||
err = new Error('Outdated');
|
||||
cache.channel_id = null;
|
||||
cache.data = null;
|
||||
cache.saved = 0;
|
||||
for(const pair of waiters)
|
||||
pair[1](err);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
cache.data = data;
|
||||
cache.saved = Date.now();
|
||||
|
||||
for(const pair of waiters)
|
||||
err ? pair[1](err) : pair[0](data);
|
||||
});
|
||||
}
|
||||
|
||||
removeRoot() {
|
||||
this._cached_channel = null;
|
||||
this.updateSubscription();
|
||||
this.channel.updateChannelColor();
|
||||
this.settings.updateContext({
|
||||
channel: null,
|
||||
channelID: null,
|
||||
channelColor: null
|
||||
});
|
||||
}
|
||||
|
||||
updateBar(el) {
|
||||
const container = el.closest('.modview-player-widget__stream-info'),
|
||||
root = container && this.fine.getReactInstance(container);
|
||||
|
||||
let channel = null, state = root?.return?.memoizedState, i = 0;
|
||||
while(state != null && channel == null && i < 50 ) {
|
||||
state = state?.next;
|
||||
channel = state?.memoizedState?.current?.previousData?.result?.data?.channel;
|
||||
i++;
|
||||
}
|
||||
|
||||
const bcast = channel?.lastBroadcast,
|
||||
title = bcast?.title,
|
||||
game = bcast?.game;
|
||||
|
||||
if ( channel?.id && channel.id != this._cached_channel )
|
||||
this.checkRoot();
|
||||
|
||||
if ( title != el._cached_title || game?.id != el._cached_game ) {
|
||||
el._cached_title = title;
|
||||
el._cached_game = game?.id;
|
||||
|
||||
this.settings.updateContext({
|
||||
category: game?.name,
|
||||
categoryID: game?.id,
|
||||
title
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
removeBar(el) {
|
||||
this.settings.updateContext({
|
||||
category: null,
|
||||
categoryID: null,
|
||||
title: null
|
||||
});
|
||||
|
||||
if ( el._ffz_cont )
|
||||
el._ffz_cont.classList.remove('ffz--meta-tray');
|
||||
|
||||
el._ffz_cont = null;
|
||||
if ( el._ffz_meta_timers ) {
|
||||
for(const val of Object.values(el._ffz_meta_timers))
|
||||
clearTimeout(val);
|
||||
|
||||
el._ffz_meta_timers = null;
|
||||
}
|
||||
|
||||
el._ffz_update = null;
|
||||
}
|
||||
|
||||
}
|
|
@ -1,17 +1,22 @@
|
|||
.ffz-top-nav {
|
||||
.tw-pill {
|
||||
padding: .4rem;
|
||||
border-radius: 1000px;
|
||||
padding: 0.3rem 0.8em;
|
||||
font-size: 75%;
|
||||
background-color: var(--color-background-pill);
|
||||
color: var(--color-text-overlay);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ffz-menu__pill {
|
||||
top: -.5rem;
|
||||
right: 0;
|
||||
right: -.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ffz-menu__extra-pill {
|
||||
bottom: -.5rem;
|
||||
right: 0;
|
||||
bottom: -.75rem;
|
||||
right: -.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template lang="html">
|
||||
<div
|
||||
:class="classes"
|
||||
class="tw-balloon tw-block tw-absolute"
|
||||
class="tw-balloon tw-block tw-absolute tw-z-above"
|
||||
>
|
||||
<div class="tw-balloon__tail tw-overflow-hidden tw-absolute">
|
||||
<div
|
||||
|
@ -30,7 +30,18 @@ export default {
|
|||
|
||||
computed: {
|
||||
classes() {
|
||||
return `tw-c-${this.color} ${this.size ? `tw-balloon--${this.size}` : ''} ${this.dir ? this.dir.split('-').map(x => `tw-balloon--${x}`).join(' ') : ''}`;
|
||||
let dir = '';
|
||||
if ( this.dir ) {
|
||||
dir = this.dir.split('-').map(d => {
|
||||
if ( d === 'up' || d === 'down' )
|
||||
return `tw-tooltip--${d}`;
|
||||
if ( d === 'left' || d === 'right' )
|
||||
return `tw-tooltip--align-${d}`;
|
||||
return '';
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
return `tw-c-${this.color} ${this.size ? `tw-balloon--${this.size}` : ''} ${dir}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,6 +42,19 @@ export function print_duration(seconds) {
|
|||
}
|
||||
|
||||
|
||||
export function durationForChat(elapsed) {
|
||||
const seconds = elapsed % 60;
|
||||
let minutes = Math.floor(elapsed / 60);
|
||||
let hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
minutes = minutes % 60;
|
||||
hours = hours % 24;
|
||||
|
||||
return `${days > 0 ? `${days}d` : ''}${hours > 0 ? `${hours}h` : ''}${minutes > 0 ? `${minutes}m` : ''}${seconds > 0 ? `${seconds}s` : ''}`;
|
||||
}
|
||||
|
||||
|
||||
export function durationForURL(elapsed) {
|
||||
const seconds = elapsed % 60;
|
||||
let minutes = Math.floor(elapsed / 60);
|
||||
|
|
|
@ -50,6 +50,15 @@ textarea.tw-input {
|
|||
}
|
||||
|
||||
.ffz--widget {
|
||||
.tw-pill {
|
||||
border-radius: 1000px;
|
||||
padding: 0.3rem 0.8em;
|
||||
font-size: 75%;
|
||||
background-color: var(--color-background-pill);
|
||||
color: var(--color-text-overlay);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
input, select {
|
||||
min-width: 20rem;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue