Merge branch 'io' into merge-upstream

This commit is contained in:
riku6460
2023-11-09 17:43:42 +09:00
59 changed files with 534 additions and 410 deletions

View File

@@ -24,8 +24,16 @@ const props = withDefaults(defineProps<{
function loadShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
try {
gl.shaderSource(shader, source);
gl.compileShader(shader);
} catch (error) {
alert(
`failed to compile shader: ${error} ${gl.getShaderInfoLog(shader)}`,
);
gl.deleteShader(shader);
return null;
}
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
alert(

View File

@@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと -->
<section>
<!-- フォルダの中にはカスタム絵文字だけUnicode絵文字もこっち -->
<section v-if="!hasChildSection" v-panel style="border-radius: 6px; border-bottom: 0.5px solid var(--divider);">
<header class="_acrylic" @click="shown = !shown">
<i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> ({{ emojis.length }})
<i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> (<i class="ti ti-icons"></i>:{{ emojis.length }})
</header>
<div v-if="shown" class="body">
<button
@@ -23,15 +24,52 @@ SPDX-License-Identifier: AGPL-3.0-only
</button>
</div>
</section>
<!-- フォルダの中にはカスタム絵文字やフォルダがある -->
<section v-else v-panel style="border-radius: 6px; border-bottom: 0.5px solid var(--divider);">
<header class="_acrylic" @click="shown = !shown">
<i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> (<i class="ti ti-folder"></i>:{{ customEmojiTree.length }} <i class="ti ti-icons"></i>:{{ emojis.length }})
</header>
<div v-if="shown" style="padding-left: 9px;">
<MkEmojiPickerSection
v-for="child in customEmojiTree"
:key="`custom:${child.category}`"
:initialShown="initialShown"
:emojis="computed(() => customEmojis.filter(e => e.category === child.category).map(e => `:${e.name}:`))"
:hasChildSection="child.children.length !== 0"
:customEmojiTree="child.children"
@chosen="nestedChosen"
>
{{ child.category || i18n.ts.other }}
</MkEmojiPickerSection>
</div>
<div v-if="shown" class="body">
<button
v-for="emoji in emojis"
:key="emoji"
:data-emoji="emoji"
class="_button item"
@pointerenter="computeButtonTitle"
@click="emit('chosen', emoji, $event)"
>
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
</button>
</div>
</section>
</template>
<script lang="ts" setup>
import { ref, computed, Ref } from 'vue';
import { getEmojiName } from '@/scripts/emojilist.js';
import { CustomEmojiFolderTree, getEmojiName } from '@/scripts/emojilist.js';
import { i18n } from '../i18n.js';
import { customEmojis } from '@/custom-emojis.js';
import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue';
const props = defineProps<{
emojis: string[] | Ref<string[]>;
initialShown?: boolean;
hasChildSection?: boolean;
customEmojiTree?: CustomEmojiFolderTree[];
}>();
const emit = defineEmits<{
@@ -49,4 +87,7 @@ function computeButtonTitle(ev: MouseEvent): void {
elm.title = getEmojiName(emoji) ?? emoji;
}
function nestedChosen(emoji: any, ev?: MouseEvent) {
emit('chosen', emoji, ev);
}
</script>

View File

@@ -73,18 +73,20 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-once class="group">
<header class="_acrylic">{{ i18n.ts.customEmojis }}</header>
<XSection
v-for="category in customEmojiCategories"
:key="`custom:${category}`"
v-for="child in customEmojiFolderRoot.children"
:key="`custom:${child.category}`"
:initialShown="false"
:emojis="computed(() => customEmojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category).filter(filterAvailable).map(e => `:${e.name}:`))"
:emojis="computed(() => customEmojis.filter(e => child.category === '' ? (e.category === 'null' || !e.category) : e.category === child.category).filter(filterAvailable).map(e => `:${e.name}:`))"
:hasChildSection="child.children.length !== 0"
:customEmojiTree="child.children"
@chosen="chosen"
>
{{ category || i18n.ts.other }}
{{ child.category || i18n.ts.other }}
</XSection>
</div>
<div v-once class="group">
<header class="_acrylic">{{ i18n.ts.emoji }}</header>
<XSection v-for="category in categories" :key="category" :emojis="emojiCharByCategory.get(category) ?? []" @chosen="chosen">{{ category }}</XSection>
<XSection v-for="category in categories" :key="category" :emojis="emojiCharByCategory.get(category) ?? []" :hasChildSection="false" @chosen="chosen">{{ category }}</XSection>
</div>
</div>
<div class="tabs">
@@ -100,7 +102,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref, shallowRef, computed, watch, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import XSection from '@/components/MkEmojiPicker.section.vue';
import { emojilist, emojiCharByCategory, UnicodeEmojiDef, unicodeEmojiCategories as categories, getEmojiName } from '@/scripts/emojilist.js';
import { emojilist, emojiCharByCategory, UnicodeEmojiDef, unicodeEmojiCategories as categories, getEmojiName, CustomEmojiFolderTree } from '@/scripts/emojilist.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import * as os from '@/os.js';
import { isTouchUsing } from '@/scripts/touch.js';
@@ -144,6 +146,39 @@ const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]);
const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index');
const customEmojiFolderRoot: CustomEmojiFolderTree = { category: "", children: [] };
function parseAndMergeCategories(input: string, root: CustomEmojiFolderTree): CustomEmojiFolderTree {
const parts = (input && input !== 'null' ? input : '').split(' / ');
let currentNode: CustomEmojiFolderTree = root;
for (const part of parts) {
const path = currentNode.category ? `${currentNode.category} / ${part}` : part;
let existingNode = currentNode.children.find((node) => node.category === path);
if (!existingNode) {
const newNode: CustomEmojiFolderTree = {
category: path,
children: [],
};
currentNode.children.push(newNode);
existingNode = newNode;
}
currentNode = existingNode;
}
return currentNode;
}
customEmojiCategories.value.forEach(ec => {
if (ec !== null) {
parseAndMergeCategories(ec, customEmojiFolderRoot);
}
});
parseAndMergeCategories('', customEmojiFolderRoot);
watch(q, () => {
if (emojisEl.value) emojisEl.value.scrollTop = 0;
@@ -573,8 +608,7 @@ defineExpose({
position: sticky;
top: 0;
left: 0;
height: 32px;
line-height: 32px;
line-height: 28px;
z-index: 1;
padding: 0 8px;
font-size: 12px;

View File

@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onUnmounted, onDeactivated, onMounted, computed, shallowRef, onActivated } from 'vue';
import { onUnmounted, onActivated, onMounted, computed, shallowRef } from 'vue';
import MkPagination, { Paging } from '@/components/MkPagination.vue';
import XNotification from '@/components/MkNotification.vue';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
@@ -64,7 +64,7 @@ function onNotification(notification) {
}
if (!isMuted) {
pagingComponent.value.prepend(notification);
pagingComponent.value?.prepend(notification);
}
}
@@ -85,16 +85,14 @@ onMounted(() => {
onActivated(() => {
pagingComponent.value?.reload();
connection = useStream().useChannel('main');
connection.on('notification', onNotification);
});
onUnmounted(() => {
if (connection) connection.dispose();
});
onDeactivated(() => {
if (connection) connection.dispose();
defineExpose({
reload,
});
</script>

View File

@@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import MkLoading from '@/components/global/MkLoading.vue';
import { onMounted, onUnmounted, watch } from 'vue';
import { deviceKind } from '@/scripts/device-kind.js';
import { i18n } from '@/i18n.js';
@@ -31,6 +32,7 @@ import { getScrollContainer } from '@/scripts/scroll.js';
const SCROLL_STOP = 10;
const MAX_PULL_DISTANCE = Infinity;
const FIRE_THRESHOLD = 230;
const FIRE_THRESHOLD_RATIO = 1.1;
const RELEASE_TRANSITION_DURATION = 200;
const PULL_BRAKE_BASE = 1.5;
const PULL_BRAKE_FACTOR = 170;
@@ -39,9 +41,11 @@ let isPullStart = $ref(false);
let isPullEnd = $ref(false);
let isRefreshing = $ref(false);
let pullDistance = $ref(0);
let moveRatio = $ref(0);
let supportPointerDesktop = false;
let startScreenY: number | null = null;
let startClientX: number | null = null;
const rootEl = $shallowRef<HTMLDivElement>();
let scrollEl: HTMLElement | null = null;
@@ -65,11 +69,20 @@ function getScreenY(event) {
return event.touches[0].screenY;
}
function getClientX(event) {
if (supportPointerDesktop) {
return event.clientX;
}
return event.touches[0].clientX;
}
function moveStart(event) {
if (!isPullStart && !isRefreshing && !disabled) {
if (!isPullStart && !isRefreshing && !disabled && scrollEl?.scrollTop === 0) {
isPullStart = true;
startScreenY = getScreenY(event);
startClientX = getClientX(event);
pullDistance = 0;
moveRatio = 0;
}
}
@@ -112,6 +125,7 @@ async function closeContent() {
function moveEnd() {
if (isPullStart && !isRefreshing) {
startScreenY = null;
startClientX = null;
if (isPullEnd) {
isPullEnd = false;
isRefreshing = true;
@@ -128,6 +142,7 @@ function moveEnd() {
}
function moving(event: TouchEvent | PointerEvent) {
if (!isPullStart && scrollEl?.scrollTop === 0) moveStart(event);
if (!isPullStart || isRefreshing || disabled) return;
if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance)) {
@@ -137,19 +152,23 @@ function moving(event: TouchEvent | PointerEvent) {
return;
}
if (startScreenY === null) {
if (startScreenY === null || startClientX === null) {
startScreenY = getScreenY(event);
startClientX = getClientX(event);
}
const moveScreenY = getScreenY(event);
const moveClientX = getClientX(event);
const moveHeight = moveScreenY - startScreenY!;
const moveWidth = moveClientX - startClientX!;
pullDistance = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE);
moveRatio = Math.max(Math.abs(moveHeight), 1) / Math.max(Math.abs(moveWidth), 1);
if (pullDistance > 0) {
if (pullDistance > 0 && moveRatio > FIRE_THRESHOLD_RATIO) {
if (event.cancelable) event.preventDefault();
}
isPullEnd = pullDistance >= FIRE_THRESHOLD;
isPullEnd = pullDistance >= FIRE_THRESHOLD && moveRatio > FIRE_THRESHOLD_RATIO;
}
/**
@@ -169,47 +188,30 @@ function setDisabled(value) {
}
function onScrollContainerScroll() {
const scrollPos = scrollEl!.scrollTop;
// When at the top of the page, disable vertical overscroll so passive touch listeners can take over.
if (scrollPos === 0) {
if (scrollEl?.scrollTop === 0) {
scrollEl!.style.touchAction = 'pan-x pan-down pinch-zoom';
registerEventListenersForReadyToPull();
} else {
scrollEl!.style.touchAction = 'auto';
unregisterEventListenersForReadyToPull();
}
}
function registerEventListenersForReadyToPull() {
if (rootEl == null) return;
rootEl.addEventListener('touchstart', moveStart, { passive: true });
rootEl.addEventListener('touchmove', moving, { passive: false }); // passive: falseにしないとpreventDefaultが使えない
}
function unregisterEventListenersForReadyToPull() {
if (rootEl == null) return;
rootEl.removeEventListener('touchstart', moveStart);
rootEl.removeEventListener('touchmove', moving);
}
onMounted(() => {
if (rootEl == null) return;
scrollEl = getScrollContainer(rootEl);
if (scrollEl == null) return;
scrollEl.addEventListener('scroll', onScrollContainerScroll, { passive: true });
rootEl.addEventListener('touchstart', moveStart, { passive: true });
rootEl.addEventListener('touchmove', moving, { passive: false }); // passive: falseにしないとpreventDefaultが使えない
rootEl.addEventListener('touchend', moveEnd, { passive: true });
registerEventListenersForReadyToPull();
});
onUnmounted(() => {
if (scrollEl) scrollEl.removeEventListener('scroll', onScrollContainerScroll);
unregisterEventListenersForReadyToPull();
if (rootEl == null) return;
rootEl.removeEventListener('touchstart', moveStart);
rootEl.removeEventListener('touchmove', moving);
rootEl.removeEventListener('touchend', moveEnd);
});
defineExpose({

View File

@@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<div v-else>
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substring(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
<div v-if="thumbnail" :class="$style.thumbnail" :style="defaultStore.state.enableDataSaverMode ? '' : `background-image: url('${thumbnail}')`">
<div v-if="thumbnail" :class="[$style.thumbnail, { [$style.thumbnailBlur]: sensitive }]" :style="defaultStore.state.enableDataSaverMode ? '' : `background-image: url('${thumbnail}')`">
</div>
<article :class="$style.body">
<header :class="$style.header">
@@ -118,6 +118,7 @@ let description = $ref<string | null>(null);
let thumbnail = $ref<string | null>(null);
let icon = $ref<string | null>(null);
let sitename = $ref<string | null>(null);
let sensitive = $ref<boolean | undefined>(undefined);
let player = $ref({
url: null,
width: null,
@@ -170,6 +171,7 @@ window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLa
icon = info.icon;
sitename = info.sitename;
player = info.player;
sensitive = info.sensitive;
});
function adjustTweetHeight(message: any) {
@@ -320,6 +322,11 @@ onUnmounted(() => {
margin-top: 6px;
}
.thumbnailBlur {
filter: blur(8px);
clip-path: inset(0);
}
@container (max-width: 400px) {
.link {
font-size: 12px;

View File

@@ -124,6 +124,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFolder>
<MkButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</MkButton>
<div>
<MkButton v-if="iAmModerator" inline danger style="margin-right: 8px;" @click="deleteUserAvatar"><i class="ti ti-user-circle"></i> {{ i18n.ts.deleteUserAvatar }}</MkButton>
<MkButton v-if="iAmModerator" inline danger @click="deleteUserBanner"><i class="ti ti-photo"></i> {{ i18n.ts.deleteUserBanner }}</MkButton>
</div>
</div>
</FormSection>
</div>
@@ -325,6 +330,44 @@ async function toggleSuspend(v) {
}
}
async function deleteUserAvatar() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.deleteUserAvatarConfirm,
});
if (confirm.canceled) return;
const process = async () => {
await os.api('admin/delete-user-avatar', { userId: user.id });
os.success();
};
await process().catch(err => {
os.alert({
type: 'error',
text: err.toString(),
});
});
refreshUser();
}
async function deleteUserBanner() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.deleteUserBannerConfirm,
});
if (confirm.canceled) return;
const process = async () => {
await os.api('admin/delete-user-banner', { userId: user.id });
os.success();
};
await process().catch(err => {
os.alert({
type: 'error',
text: err.toString(),
});
});
refreshUser();
}
async function deleteAllFiles() {
const confirm = await os.confirm({
type: 'warning',

View File

@@ -39,6 +39,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.sensitiveWords }}</template>
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
</MkTextarea>
<MkTextarea v-model="urlPreviewDenyList">
<template #label>{{ i18n.ts.urlPreviewDenyList }}</template>
<template #caption>{{ i18n.ts.urlPreviewDenyListDescription }}</template>
</MkTextarea>
</div>
</FormSuspense>
</MkSpacer>
@@ -75,6 +80,7 @@ let sensitiveWords: string = $ref('');
let preservedUsernames: string = $ref('');
let tosUrl: string | null = $ref(null);
let privacyPolicyUrl: string | null = $ref(null);
let urlPreviewDenyList: string = $ref('');
async function init() {
const meta = await os.api('admin/meta');
@@ -84,6 +90,7 @@ async function init() {
preservedUsernames = meta.preservedUsernames.join('\n');
tosUrl = meta.tosUrl;
privacyPolicyUrl = meta.privacyPolicyUrl;
urlPreviewDenyList = meta.urlPreviewDenyList.join('\n');
}
function save() {
@@ -94,6 +101,7 @@ function save() {
privacyPolicyUrl,
sensitiveWords: sensitiveWords.split('\n'),
preservedUsernames: preservedUsernames.split('\n'),
urlPreviewDenyList: urlPreviewDenyList.split('\n'),
}).then(() => {
fetchInstance();
});

View File

@@ -43,3 +43,8 @@ export function getEmojiName(char: string): string | null {
return emojilist[idx].name;
}
}
export interface CustomEmojiFolderTree {
category: string;
children: CustomEmojiFolderTree[];
}

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<XColumn :menu="menu" :column="column" :isStacked="isStacked">
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="() => timeline.reloadTimeline()">
<template #header>
<i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template>

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<XColumn :menu="menu" :column="column" :isStacked="isStacked">
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="() => timeline.reloadTimeline()">
<template #header>
<i class="ti ti-device-tv"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template>

View File

@@ -57,6 +57,7 @@ const props = withDefaults(defineProps<{
isStacked?: boolean;
naked?: boolean;
menu?: MenuItem[];
refresher?: () => Promise<void>;
}>(), {
isStacked: false,
naked: false,
@@ -178,6 +179,18 @@ function getMenu() {
},
}];
if (props.refresher) {
items = [{
icon: 'ti ti-refresh',
text: i18n.ts.reload,
action: () => {
if (props.refresher) {
props.refresher();
}
},
}, ...items];
}
if (props.menu) {
items.unshift(null);
items = props.menu.concat(items);

View File

@@ -4,10 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<XColumn :column="column" :isStacked="isStacked">
<XColumn :column="column" :isStacked="isStacked" :refresher="() => reloadTimeline()">
<template #header><i class="ti ti-mail" style="margin-right: 8px;"></i>{{ column.name }}</template>
<MkNotes :pagination="pagination"/>
<MkNotes ref="tlComponent" :pagination="pagination"/>
</XColumn>
</template>
@@ -16,6 +16,7 @@ import { } from 'vue';
import XColumn from './column.vue';
import { Column } from './deck-store.js';
import MkNotes from '@/components/MkNotes.vue';
import { reloadStream } from '@/stream.js';
defineProps<{
column: Column;
@@ -29,4 +30,15 @@ const pagination = {
visibility: 'specified',
},
};
const tlComponent: InstanceType<typeof MkNotes> = $ref();
function reloadTimeline() {
return new Promise<void>((res) => {
tlComponent.pagingComponent?.reload().then(() => {
reloadStream();
res();
});
});
}
</script>

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<XColumn :menu="menu" :column="column" :isStacked="isStacked">
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="() => timeline.reloadTimeline()">
<template #header>
<i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template>

View File

@@ -4,10 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<XColumn :column="column" :isStacked="isStacked">
<XColumn :column="column" :isStacked="isStacked" :refresher="() => reloadTimeline()">
<template #header><i class="ti ti-at" style="margin-right: 8px;"></i>{{ column.name }}</template>
<MkNotes :pagination="pagination"/>
<MkNotes ref="tlComponent" :pagination="pagination"/>
</XColumn>
</template>
@@ -16,12 +16,24 @@ import { } from 'vue';
import XColumn from './column.vue';
import { Column } from './deck-store.js';
import MkNotes from '@/components/MkNotes.vue';
import { reloadStream } from '@/stream.js';
defineProps<{
column: Column;
isStacked: boolean;
}>();
const tlComponent: InstanceType<typeof MkNotes> = $ref();
function reloadTimeline() {
return new Promise<void>((res) => {
tlComponent.pagingComponent?.reload().then(() => {
reloadStream();
res();
});
});
}
const pagination = {
endpoint: 'notes/mentions' as const,
limit: 10,

View File

@@ -4,10 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<XColumn :column="column" :isStacked="isStacked" :menu="menu">
<XColumn :column="column" :isStacked="isStacked" :menu="menu" :refresher="() => notificationsComponent.reload()">
<template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name }}</template>
<XNotifications :excludeTypes="props.column.excludeTypes"/>
<XNotifications ref="notificationsComponent" :excludeTypes="props.column.excludeTypes"/>
</XColumn>
</template>
@@ -24,6 +24,8 @@ const props = defineProps<{
isStacked: boolean;
}>();
let notificationsComponent = $shallowRef<InstanceType<typeof XNotifications>>();
function func() {
os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSelectWindow.vue')), {
excludeTypes: props.column.excludeTypes,

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<XColumn :menu="menu" :column="column" :isStacked="isStacked">
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="() => timeline.reloadTimeline()">
<template #header>
<i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template>

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<XColumn :menu="menu" :column="column" :isStacked="isStacked">
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="() => timeline.reloadTimeline()">
<template #header>
<i v-if="column.tl === 'home'" class="ti ti-home"></i>
<i v-else-if="column.tl === 'local'" class="ti ti-planet"></i>
@@ -49,6 +49,7 @@ const props = defineProps<{
}>();
let disabled = $ref(false);
let timeline = $shallowRef<InstanceType<typeof MkTimeline>>();
const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable));
const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable));