Compare commits

..

No commits in common. "542ecffadb7f351d34e6e1ed03d1f6ef0b762665" and "655bb5c2bdbfb437273c042e8cb73dbafb35bedf" have entirely different histories.

11 changed files with 104 additions and 248 deletions

8
locales/index.d.ts vendored
View File

@ -9584,14 +9584,6 @@ export interface Locale extends ILocale {
"disableFederationDescription": string;
};
"_postForm": {
/**
*
*/
"quitInspiteOfThereAreUnuploadedFilesConfirm": string;
/**
* 稿
*/
"uploaderTip": string;
/**
* ...
*/

View File

@ -2522,8 +2522,6 @@ _visibility:
disableFederationDescription: "他サーバーへの配信を行いません"
_postForm:
quitInspiteOfThereAreUnuploadedFilesConfirm: "アップロードされていないファイルがありますが、破棄してフォームを閉じますか?"
uploaderTip: "ファイルはまだアップロードされていません。ファイルのメニューから、リネームや画像のクロップ、ウォーターマークの付与、圧縮の有無などを設定できます。ファイルはノート投稿時に自動でアップロードされます。"
replyPlaceholder: "このノートに返信..."
quotePlaceholder: "このノートを引用..."
channelPlaceholder: "チャンネルに投稿..."

View File

@ -73,10 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
<div v-if="uploader.items.value.length > 0" style="padding: 12px;">
<MkTip k="postFormUploader">
{{ i18n.ts._postForm.uploaderTip }}
</MkTip>
<MkUploaderItems :items="uploader.items.value" @showMenu="(item, ev) => showPerUploadItemMenu(item, ev)" @showMenuViaContextmenu="(item, ev) => showPerUploadItemMenuViaContextmenu(item, ev)"/>
<MkUploaderItems :items="uploader.items.value" @showMenu="(item, ev) => uploader.showMenu(ev, item)"/>
</div>
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/>
@ -84,17 +81,19 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<footer :class="$style.footer">
<div :class="$style.footerLeft">
<button v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.upload + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromPc"><i class="ti ti-photo-plus"></i></button>
<button v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.fromDrive + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromDrive"><i class="ti ti-cloud-download"></i></button>
<button v-tooltip="i18n.ts.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFromPc"><i class="ti ti-photo-plus"></i></button>
<button v-tooltip="i18n.ts.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFromDrive"><i class="ti ti-cloud-download"></i></button>
<button v-tooltip="i18n.ts.poll" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: poll }]" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button>
<button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button>
<button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button>
<button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button>
<button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ti ti-palette"></i></button>
<button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button>
<button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button>
<button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
<button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ti ti-palette"></i></button>
</div>
<div :class="$style.footerRight">
<button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
<button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.footerButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button>
<!--<button v-tooltip="i18n.ts.more" class="_button" :class="$style.footerButton" @click="showingOptions = !showingOptions"><i class="ti ti-dots"></i></button>-->
</div>
</footer>
<datalist id="hashtags">
@ -115,7 +114,6 @@ import type { ShallowRef } from 'vue';
import type { PostFormProps } from '@/types/post-form.js';
import type { MenuItem } from '@/types/menu.js';
import type { PollEditorModelValue } from '@/components/MkPollEditor.vue';
import type { UploaderItem } from '@/composables/use-uploader.js';
import MkNotePreview from '@/components/MkNotePreview.vue';
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
import XTextCounter from '@/components/MkPostForm.TextCounter.vue';
@ -127,7 +125,7 @@ import { formatTimeString } from '@/utility/format-time-string.js';
import { Autocomplete } from '@/utility/autocomplete.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { chooseDriveFile } from '@/utility/drive.js';
import { selectFile } from '@/utility/drive.js';
import { store } from '@/store.js';
import MkInfo from '@/components/MkInfo.vue';
import { i18n } from '@/i18n.js';
@ -210,12 +208,9 @@ const renoteTargetNote: ShallowRef<PostFormProps['renote'] | null> = shallowRef(
const postFormActions = getPluginHandlers('post_form_action');
const uploader = useUploader({
multiple: true,
});
uploader.events.on('itemUploaded', ctx => {
files.value.push(ctx.item.uploaded!);
uploader.removeItem(ctx.item);
multiple: props.multiple,
folderId: props.folderId,
features: props.features,
});
const draftKey = computed((): string => {
@ -275,7 +270,7 @@ const cwTextLength = computed((): number => {
const maxCwTextLength = 100;
const canPost = computed((): boolean => {
return !props.mock && !posting.value && !posted.value && !uploader.uploading.value && uploader.readyForUpload.value &&
return !props.mock && !posting.value && !posted.value && !uploader.uploading.value &&
(
1 <= textLength.value ||
1 <= files.value.length ||
@ -461,14 +456,6 @@ function chooseFileFromPc(ev: MouseEvent) {
});
}
function chooseFileFromDrive(ev: MouseEvent) {
if (props.mock) return;
chooseDriveFile({ multiple: true }).then(driveFiles => {
files.value.push(...driveFiles);
});
}
function detachFile(id) {
files.value = files.value.filter(x => x.id !== id);
}
@ -592,10 +579,6 @@ function showOtherSettings() {
toggleReactionAcceptance();
},
}, { type: 'divider' }, {
type: 'switch',
text: i18n.ts.preview,
ref: showPreview,
}, {
icon: 'ti ti-trash',
text: i18n.ts.reset,
danger: true,
@ -1081,16 +1064,6 @@ function openAccountMenu(ev: MouseEvent) {
}, ev);
}
function showPerUploadItemMenu(item: UploaderItem, ev: MouseEvent) {
const menu = uploader.getMenu(item);
os.popupMenu(menu, ev.currentTarget ?? ev.target);
}
function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) {
const menu = uploader.getMenu(item);
os.contextMenu(menu, ev);
}
onMounted(() => {
if (props.autofocus) {
focus();
@ -1159,23 +1132,8 @@ onMounted(() => {
});
});
async function canClose() {
if (!uploader.allItemsUploaded.value) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts._postForm.quitInspiteOfThereAreUnuploadedFilesConfirm,
okText: i18n.ts.yes,
cancelText: i18n.ts.no,
});
if (canceled) return false;
}
return true;
}
defineExpose({
clear,
canClose,
});
</script>

View File

@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkModal
ref="modal"
:preferType="'dialog'"
@click="_close()"
@click="modal?.close()"
@closed="onModalClosed()"
@esc="_close()"
@esc="modal?.close()"
>
<MkPostForm
ref="form"
@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
autofocus
freezeAfterPosted
@posted="onPosted"
@cancel="_close()"
@esc="_close()"
@cancel="modal?.close()"
@esc="modal?.close()"
/>
</MkModal>
</template>
@ -43,7 +43,6 @@ const emit = defineEmits<{
}>();
const modal = useTemplateRef('modal');
const form = useTemplateRef('form');
function onPosted() {
modal.value?.close({
@ -51,12 +50,6 @@ function onPosted() {
});
}
async function _close() {
const canClose = await form.value?.canClose();
if (!canClose) return;
modal.value?.close();
}
function onModalClosed() {
emit('closed');
}

View File

@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._uploader.tip }}
</MkTip>
<MkUploaderItems :items="items" @showMenu="(item, ev) => showPerItemMenu(item, ev)" @showMenuViaContextmenu="(item, ev) => showPerItemMenuViaContextmenu(item, ev)"/>
<MkUploaderItems :items="items" @showMenu="(item, ev) => uploader.showMenu(ev, item)"/>
<div v-if="props.multiple">
<MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton>
@ -39,8 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #footer>
<div class="_buttonsCenter">
<MkButton v-if="uploader.uploading.value" rounded @click="abortWithConfirm()"><i class="ti ti-x"></i> {{ i18n.ts.abort }}</MkButton>
<MkButton v-else-if="!firstUploadAttempted" primary rounded :disabled="!uploader.readyForUpload.value" @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton>
<MkButton v-if="isUploading" rounded @click="abortWithConfirm()"><i class="ti ti-x"></i> {{ i18n.ts.abort }}</MkButton>
<MkButton v-else-if="!firstUploadAttempted" primary rounded @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton>
<MkButton v-if="canRetry" rounded @click="upload()"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton>
<MkButton v-if="canDone" rounded @click="done()"><i class="ti ti-arrow-right"></i> {{ i18n.ts.done }}</MkButton>
@ -52,7 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import type { UploaderFeatures, UploaderItem } from '@/composables/use-uploader.js';
import type { UploaderFeatures } from '@/composables/use-uploader.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
@ -93,7 +93,8 @@ onMounted(() => {
const items = uploader.items;
const firstUploadAttempted = ref(false);
const canRetry = computed(() => firstUploadAttempted.value && uploader.readyForUpload.value);
const isUploading = computed(() => items.value.some(item => item.uploading));
const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.preprocessing) && items.value.some(item => item.uploaded == null));
const canDone = computed(() => items.value.some(item => item.uploaded != null));
const overallProgress = computed(() => {
const max = items.value.length;
@ -151,7 +152,7 @@ async function abortWithConfirm() {
}
async function done() {
if (!uploader.allItemsUploaded.value) {
if (items.value.some(item => item.uploaded == null)) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts._uploader.doneConfirm,
@ -169,16 +170,6 @@ async function chooseFile(ev: MouseEvent) {
const newFiles = await os.chooseFileFromPc({ multiple: true });
uploader.addFiles(newFiles);
}
function showPerItemMenu(item: UploaderItem, ev: MouseEvent) {
const menu = uploader.getMenu(item);
os.popupMenu(menu, ev.currentTarget ?? ev.target);
}
function showPerItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) {
const menu = uploader.getMenu(item);
os.contextMenu(menu, ev);
}
</script>
<style lang="scss" module>

View File

@ -11,13 +11,12 @@ SPDX-License-Identifier: AGPL-3.0-only
v-panel
:class="[$style.item, { [$style.itemWaiting]: item.preprocessing, [$style.itemCompleted]: item.uploaded, [$style.itemFailed]: item.uploadFailed }]"
:style="{ '--p': item.progress != null ? `${item.progress.value / item.progress.max * 100}%` : '0%' }"
@contextmenu.prevent.stop="onContextmenu(item, $event)"
>
<div :class="$style.itemInner">
<div :class="$style.itemActionWrapper">
<MkButton :iconOnly="true" rounded @click="emit('showMenu', item, $event)"><i class="ti ti-dots"></i></MkButton>
</div>
<div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ item.thumbnail })` }" @click="onThumbnailClick(item, $event)"></div>
<div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ item.thumbnail })` }"></div>
<div :class="$style.itemBody">
<div><MkCondensedLine :minScale="2 / 3">{{ item.name }}</MkCondensedLine></div>
<div :class="$style.itemInfo">
@ -39,7 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { isLink } from '@@/js/is-link.js';
import type { UploaderItem } from '@/composables/use-uploader.js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
@ -51,19 +49,7 @@ const props = defineProps<{
const emit = defineEmits<{
(ev: 'showMenu', item: UploaderItem, event: MouseEvent): void;
(ev: 'showMenuViaContextmenu', item: UploaderItem, event: MouseEvent): void;
}>();
function onContextmenu(item: UploaderItem, ev: MouseEvent) {
if (ev.target && isLink(ev.target as HTMLElement)) return;
if (window.getSelection()?.toString() !== '') return;
emit('showMenuViaContextmenu', item, ev);
}
function onThumbnailClick(item: UploaderItem, ev: MouseEvent) {
// TODO: preview when item is image
}
</script>
<style lang="scss" module>

View File

@ -7,10 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="!store.r.tips.value[props.k]" :class="[$style.root, { [$style.warn]: warn }]" class="_selectable _gaps_s">
<div style="font-weight: bold;"><i class="ti ti-bulb"></i> {{ i18n.ts.tip }}:</div>
<div><slot></slot></div>
<div>
<MkButton inline primary rounded small @click="_closeTip()"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton>
<button class="_button" style="padding: 8px; margin-left: 4px;" @click="showMenu"><i class="ti ti-dots"></i></button>
</div>
<MkButton primary rounded small @click="closeTip()"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton>
</div>
</template>
@ -18,30 +15,19 @@ SPDX-License-Identifier: AGPL-3.0-only
import { i18n } from '@/i18n.js';
import { store } from '@/store.js';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { TIPS, hideAllTips, closeTip } from '@/tips.js';
const props = withDefaults(defineProps<{
k: typeof TIPS[number];
k: keyof (typeof store['s']['tips']);
warn?: boolean;
}>(), {
warn: false,
});
function _closeTip() {
closeTip(props.k);
}
function showMenu(ev: MouseEvent) {
os.popupMenu([{
icon: 'ti ti-bulb-off',
text: i18n.ts.hideAllTips,
danger: true,
action: () => {
hideAllTips();
os.success();
},
}], ev.currentTarget ?? ev.target);
function closeTip() {
store.set('tips', {
...store.r.tips.value,
[props.k]: true,
});
}
</script>

View File

@ -6,7 +6,6 @@
import * as Misskey from 'misskey-js';
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
import isAnimated from 'is-file-animated';
import { EventEmitter } from 'eventemitter3';
import { computed, markRaw, onMounted, onUnmounted, ref, triggerRef } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import { genId } from '@/utility/id.js';
@ -98,10 +97,6 @@ export function useUploader(options: {
} = {}) {
const $i = ensureSignin();
const events = new EventEmitter<{
'itemUploaded': (ctx: { item: UploaderItem; }) => void;
}>();
const uploaderFeatures = computed<Required<UploaderFeatures>>(() => {
return {
effect: options.features?.effect ?? true,
@ -147,31 +142,25 @@ export function useUploader(options: {
items.value.splice(items.value.indexOf(item), 1);
}
function getMenu(item: UploaderItem): MenuItem[] {
function showMenu(ev: MouseEvent, item: UploaderItem) {
const menu: MenuItem[] = [];
if (
!item.preprocessing &&
!item.uploading &&
!item.uploaded
) {
menu.push({
icon: 'ti ti-cursor-text',
text: i18n.ts.rename,
action: async () => {
const { result, canceled } = await os.inputText({
type: 'text',
title: i18n.ts.rename,
placeholder: item.name,
default: item.name,
});
if (canceled) return;
if (result.trim() === '') return;
menu.push({
icon: 'ti ti-cursor-text',
text: i18n.ts.rename,
action: async () => {
const { result, canceled } = await os.inputText({
type: 'text',
title: i18n.ts.rename,
placeholder: item.name,
default: item.name,
});
if (canceled) return;
if (result.trim() === '') return;
item.name = result;
},
});
}
item.name = result;
},
});
if (
uploaderFeatures.value.crop &&
@ -343,12 +332,6 @@ export function useUploader(options: {
if (!item.preprocessing && !item.uploading && !item.uploaded) {
menu.push({
type: 'divider',
}, {
icon: 'ti ti-upload',
text: i18n.ts.upload,
action: () => {
uploadOne(item);
},
}, {
icon: 'ti ti-x',
text: i18n.ts.remove,
@ -371,46 +354,7 @@ export function useUploader(options: {
});
}
return menu;
}
async function uploadOne(item: UploaderItem): Promise<void> {
item.uploadFailed = false;
item.uploading = true;
const { filePromise, abort } = uploadFile(item.preprocessedFile ?? item.file, {
name: item.uploadName ?? item.name,
folderId: options.folderId,
onProgress: (progress) => {
if (item.progress == null) {
item.progress = { max: progress.total, value: progress.loaded };
} else {
item.progress.value = progress.loaded;
item.progress.max = progress.total;
}
},
});
item.abort = () => {
item.abort = null;
abort();
item.uploading = false;
item.uploadFailed = true;
};
await filePromise.then((file) => {
item.uploaded = file;
item.abort = null;
events.emit('itemUploaded', { item });
}).catch(err => {
item.uploadFailed = true;
item.progress = null;
if (!(err instanceof UploadAbortedError)) {
throw err;
}
}).finally(() => {
item.uploading = false;
});
os.popupMenu(menu, ev.currentTarget ?? ev.target);
}
async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる
@ -427,7 +371,41 @@ export function useUploader(options: {
continue;
}
await uploadOne(item);
item.uploadFailed = false;
item.uploading = true;
const { filePromise, abort } = uploadFile(item.preprocessedFile ?? item.file, {
name: item.uploadName ?? item.name,
folderId: options.folderId,
onProgress: (progress) => {
if (item.progress == null) {
item.progress = { max: progress.total, value: progress.loaded };
} else {
item.progress.value = progress.loaded;
item.progress.max = progress.total;
}
},
});
item.abort = () => {
item.abort = null;
abort();
item.uploading = false;
item.uploadFailed = true;
};
await filePromise.then((file) => {
item.uploaded = file;
item.abort = null;
}).catch(err => {
item.uploadFailed = true;
item.progress = null;
if (!(err instanceof UploadAbortedError)) {
throw err;
}
}).finally(() => {
item.uploading = false;
});
}
}
@ -525,11 +503,8 @@ export function useUploader(options: {
removeItem,
abortAll,
upload,
getMenu,
showMenu,
uploading: computed(() => items.value.some(item => item.uploading)),
readyForUpload: computed(() => items.value.length > 0 && items.value.some(item => item.uploaded == null) && !items.value.some(item => item.uploading || item.preprocessing)),
allItemsUploaded: computed(() => items.value.every(item => item.uploaded != null)),
events,
};
}

View File

@ -161,7 +161,7 @@ import { prefer } from '@/preferences.js';
import MkRolePreview from '@/components/MkRolePreview.vue';
import { signout } from '@/signout.js';
import { migrateOldSettings } from '@/pref-migrate.js';
import { hideAllTips as _hideAllTips, resetAllTips as _resetAllTips } from '@/tips.js';
import { store, TIPS } from '@/store.js';
const $i = ensureSignin();
@ -205,12 +205,16 @@ function migrate() {
}
function resetAllTips() {
_resetAllTips();
store.set('tips', {});
os.success();
}
function hideAllTips() {
_hideAllTips();
const v = {};
for (const k of TIPS) {
v[k] = true;
}
store.set('tips', v);
os.success();
}

View File

@ -10,11 +10,22 @@ import darkTheme from '@@/themes/d-green-lime.json5';
import { hemisphere } from '@@/js/intl-const.js';
import type { DeviceKind } from '@/utility/device-kind.js';
import type { Plugin } from '@/plugin.js';
import type { TIPS } from '@/tips.js';
import { miLocalStorage } from '@/local-storage.js';
import { Pizzax } from '@/lib/pizzax.js';
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
export const TIPS = [
'drive',
'uploader',
'clips',
'userLists',
'tl.home',
'tl.local',
'tl.social',
'tl.global',
'abuses',
] as const;
/**
* (not)
*/

View File

@ -1,38 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { store } from '@/store.js';
export const TIPS = [
'drive',
'uploader',
'postFormUploader',
'clips',
'userLists',
'tl.home',
'tl.local',
'tl.social',
'tl.global',
'abuses',
] as const;
export function closeTip(tip: typeof TIPS[number]) {
store.set('tips', {
...store.r.tips.value,
[tip]: true,
});
}
export function resetAllTips() {
store.set('tips', {});
}
export function hideAllTips() {
const v = {};
for (const k of TIPS) {
v[k] = true;
}
store.set('tips', v);
}