Merge tag '2023.12.0-beta.5' into merge-upstream

This commit is contained in:
riku6460
2023-12-21 07:03:04 +09:00
150 changed files with 4706 additions and 2076 deletions

View File

@@ -135,10 +135,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="tab === 'roles'" class="_gaps">
<MkButton primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
<div v-for="role in info.roles" :key="role.id" :class="$style.roleItem">
<div v-for="role in info.roles" :key="role.id">
<div :class="$style.roleItemMain">
<MkRolePreview :class="$style.role" :role="role" :forModeration="true"/>
<button class="_button" :class="$style.roleToggle" @click="toggleRoleItem(role)"><i class="ti ti-chevron-down"></i></button>
<button class="_button" @click="toggleRoleItem(role)"><i class="ti ti-chevron-down"></i></button>
<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button>
<button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
</div>
@@ -330,41 +330,41 @@ async function toggleSuspend(v) {
}
async function unsetUserAvatar() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unsetUserAvatarConfirm,
});
if (confirm.canceled) return;
const process = async () => {
await os.api('admin/unset-user-avatar', { userId: user.value.id });
os.success();
};
await process().catch(err => {
os.alert({
type: 'error',
text: err.toString(),
});
});
refreshUser();
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unsetUserAvatarConfirm,
});
if (confirm.canceled) return;
const process = async () => {
await os.api('admin/unset-user-avatar', { userId: user.value.id });
os.success();
};
await process().catch(err => {
os.alert({
type: 'error',
text: err.toString(),
});
});
refreshUser();
}
async function unsetUserBanner() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unsetUserBannerConfirm,
});
if (confirm.canceled) return;
const process = async () => {
await os.api('admin/unset-user-banner', { userId: user.value.id });
os.success();
};
await process().catch(err => {
os.alert({
type: 'error',
text: err.toString(),
});
});
refreshUser();
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unsetUserBannerConfirm,
});
if (confirm.canceled) return;
const process = async () => {
await os.api('admin/unset-user-banner', { userId: user.value.id });
os.success();
};
await process().catch(err => {
os.alert({
type: 'error',
text: err.toString(),
});
});
refreshUser();
}
async function deleteAllFiles() {
@@ -639,9 +639,6 @@ definePageMetadata(computed(() => ({
}
}
.roleItem {
}
.roleItemMain {
display: flex;
}

View File

@@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput ref="announceTitleEl" v-model="announcement.title" :large="false">
<template #label>{{ i18n.ts.title }}&nbsp;<button v-tooltip="i18n.ts.emoji" :class="['_button']" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button></template>
</MkInput>
<MkTextarea v-model="announcement.text">
<MkTextarea v-model="announcement.text" mfmAutocomplete :mfmPreview="true">
<template #label>{{ i18n.ts.text }}</template>
</MkTextarea>
<MkInput v-model="announcement.imageUrl" type="url">
@@ -100,7 +100,6 @@ import * as misskey from 'misskey-js';
import XHeader from './_header_.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkInfo from '@/components/MkInfo.vue';
@@ -114,6 +113,7 @@ const announceTitleEl = shallowRef<HTMLInputElement | null>(null);
const user = ref<misskey.entities.UserLite | null>(null);
const offset = ref(0);
const hasMore = ref(false);
import MkTextarea from '@/components/MkTextarea.vue';
const announcements = ref<any[]>([]);

View File

@@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTime :time="log.createdAt"/>
</template>
<div :class="$style.root">
<div>
<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
<div style="flex: 1;">{{ i18n.ts.moderator }}: <MkA :to="`/admin/user/${log.userId}`" class="_link">@{{ log.user?.username }}</MkA></div>
<div style="flex: 1;">{{ i18n.ts.dateAndTime }}: <MkTime :time="log.createdAt" mode="detail"/></div>
@@ -134,9 +134,6 @@ const props = defineProps<{
</script>
<style lang="scss" module>
.root {
}
.avatar {
width: 18px;
height: 18px;

View File

@@ -591,6 +591,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.avatarDecorationLimit, 'avatarDecorationLimit'])">
<template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
<template #suffix>
<span v-if="role.policies.avatarDecorationLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.avatarDecorationLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.avatarDecorationLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.avatarDecorationLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.avatarDecorationLimit.value" type="number" :min="0">
<template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
</MkInput>
<MkRange v-model="role.policies.avatarDecorationLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
</div>
</FormSlot>
</div>
@@ -609,7 +629,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue';
import FormSlot from '@/components/form/slot.vue';
import { i18n } from '@/i18n.js';
import { ROLE_POLICIES } from '@/const';
import { ROLE_POLICIES } from '@/const.js';
import { instance } from '@/instance.js';
import { deepClone } from '@/scripts/clone.js';

View File

@@ -216,6 +216,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.avatarDecorationLimit, 'avatarDecorationLimit'])">
<template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
<template #suffix>{{ policies.avatarDecorationLimit }}</template>
<MkInput v-model="policies.avatarDecorationLimit" type="number" :min="0">
</MkInput>
</MkFolder>
<MkButton primary rounded @click="updateBaseRole">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>

View File

@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkTextarea v-model="description">
<MkTextarea v-model="description" mfmAutocomplete :mfmPreview="true">
<template #label>{{ i18n.ts.description }}</template>
</MkTextarea>
@@ -70,7 +70,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref, watch, defineAsyncComponent } from 'vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkColorInput from '@/components/MkColorInput.vue';
@@ -81,6 +80,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkTextarea from '@/components/MkTextarea.vue';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));

View File

@@ -101,6 +101,7 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{
type: 'string',
required: false,
multiline: true,
treatAsMfm: true,
label: i18n.ts.description,
default: clip.value.description,
},

View File

@@ -15,9 +15,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts._play.summary }}</template>
</MkTextarea>
<MkButton primary @click="selectPreset">{{ i18n.ts.selectFromPresets }}<i class="ti ti-chevron-down"></i></MkButton>
<MkTextarea v-model="script" class="_monospace" tall spellcheck="false">
<MkCodeEditor v-model="script" lang="is">
<template #label>{{ i18n.ts._play.script }}</template>
</MkTextarea>
</MkCodeEditor>
<div class="_buttons">
<MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
<MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton>
@@ -40,6 +40,7 @@ import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkTextarea from '@/components/MkTextarea.vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import { useRouter } from '@/router.js';

View File

@@ -165,12 +165,8 @@ async function run() {
return new Promise(ok => {
os.inputText({
title: q,
}).then(({ canceled, result: a }) => {
if (canceled) {
ok('');
} else {
ok(a);
}
}).then(({ result: a }) => {
ok(a ?? '');
});
});
},

View File

@@ -60,6 +60,7 @@ async function create() {
type: 'string',
required: false,
multiline: true,
treatAsMfm: true,
label: i18n.ts.description,
},
isPublic: {

View File

@@ -60,7 +60,7 @@ function setFilter(ev) {
action: () => {
includeTypes.value = null;
},
}, null, ...typeItems] : typeItems;
}, { type: 'divider' }, ...typeItems] : typeItems;
os.popupMenu(items, ev.currentTarget ?? ev.target);
}

View File

@@ -9,16 +9,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><i class="ti ti-align-left"></i> {{ i18n.ts._pages.blocks.text }}</template>
<section>
<textarea v-model="text" :class="$style.textarea"></textarea>
<textarea ref="inputEl" v-model="text" :class="$style.textarea"></textarea>
</section>
</XContainer>
</template>
<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
import { watch, ref } from 'vue';
import { watch, ref, shallowRef, onMounted, onUnmounted } from 'vue';
import XContainer from '../page-editor.container.vue';
import { i18n } from '@/i18n.js';
import { Autocomplete } from '@/scripts/autocomplete.js';
const props = defineProps<{
modelValue: any
@@ -28,7 +29,10 @@ const emit = defineEmits<{
(ev: 'update:modelValue', value: any): void;
}>();
let autocomplete: Autocomplete;
const text = ref(props.modelValue.text ?? '');
const inputEl = shallowRef<HTMLTextAreaElement | null>(null);
watch(text, () => {
emit('update:modelValue', {
@@ -36,6 +40,14 @@ watch(text, () => {
text: text.value,
});
});
onMounted(() => {
autocomplete = new Autocomplete(inputEl.value, text);
});
onUnmounted(() => {
autocomplete.detach();
});
</script>
<style lang="scss" module>

View File

@@ -26,9 +26,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkKeyValue>
</FormSplit>
<MkTextarea v-model="valueForEditor" tall class="_monospace">
<MkCodeEditor v-model="valueForEditor" lang="json5">
<template #label>{{ i18n.ts.value }} (JSON)</template>
</MkTextarea>
</MkCodeEditor>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
@@ -52,7 +52,7 @@ import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkButton from '@/components/MkButton.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import FormSplit from '@/components/form/split.vue';
import FormInfo from '@/components/MkInfo.vue';

View File

@@ -7,15 +7,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m">
<FormInfo warn>{{ i18n.ts.customCssWarn }}</FormInfo>
<MkTextarea v-model="localCustomCss" manualSave tall class="_monospace" style="tab-size: 2;">
<MkCodeEditor v-model="localCustomCss" manualSave lang="css">
<template #label>CSS</template>
</MkTextarea>
</MkCodeEditor>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import FormInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { unisonReload } from '@/scripts/unison-reload.js';

View File

@@ -0,0 +1,274 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps_m">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-pin"></i></template>
<template #label>{{ i18n.ts.pinned }} ({{ i18n.ts.reaction }})</template>
<template #caption>{{ i18n.ts.pinnedEmojisForReactionSettingDescription }}</template>
<div class="_gaps">
<div>
<div v-panel style="border-radius: 6px;">
<Sortable
v-model="pinnedEmojisForReaction"
:class="$style.emojis"
:itemKey="item => item"
:animation="150"
:delay="100"
:delayOnTouchOnly="true"
>
<template #item="{element}">
<button class="_button" :class="$style.emojisItem" @click="removeReaction(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
<button class="_button" :class="$style.emojisAdd" @click="chooseReaction">
<i class="ti ti-plus"></i>
</button>
</template>
</Sortable>
</div>
<div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div>
</div>
<div class="_buttons">
<MkButton inline @click="previewReaction"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton inline danger @click="setDefaultReaction"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
<MkButton inline danger @click="overwriteFromPinnedEmojis"><i class="ti ti-copy"></i> {{ i18n.ts.overwriteFromPinnedEmojis }}</MkButton>
</div>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-pin"></i></template>
<template #label>{{ i18n.ts.pinned }} ({{ i18n.ts.general }})</template>
<template #caption>{{ i18n.ts.pinnedEmojisSettingDescription }}</template>
<div class="_gaps">
<div>
<div v-panel style="border-radius: 6px;">
<Sortable
v-model="pinnedEmojis"
:class="$style.emojis"
:itemKey="item => item"
:animation="150"
:delay="100"
:delayOnTouchOnly="true"
>
<template #item="{element}">
<button class="_button" :class="$style.emojisItem" @click="removeEmoji(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
<button class="_button" :class="$style.emojisAdd" @click="chooseEmoji">
<i class="ti ti-plus"></i>
</button>
</template>
</Sortable>
</div>
<div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div>
</div>
<div class="_buttons">
<MkButton inline @click="previewEmoji"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton inline danger @click="setDefaultEmoji"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
<MkButton inline danger @click="overwriteFromPinnedEmojisForReaction"><i class="ti ti-copy"></i> {{ i18n.ts.overwriteFromPinnedEmojisForReaction }}</MkButton>
</div>
</div>
</MkFolder>
<FormSection>
<template #label>{{ i18n.ts.emojiPickerDisplay }}</template>
<div class="_gaps_m">
<MkRadios v-model="emojiPickerScale">
<template #label>{{ i18n.ts.size }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
</MkRadios>
<MkRadios v-model="emojiPickerWidth">
<template #label>{{ i18n.ts.numberOfColumn }}</template>
<option :value="1">5</option>
<option :value="2">6</option>
<option :value="3">7</option>
<option :value="4">8</option>
<option :value="5">9</option>
</MkRadios>
<MkRadios v-model="emojiPickerHeight">
<template #label>{{ i18n.ts.height }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
<option :value="4">{{ i18n.ts.large }}+</option>
</MkRadios>
<MkSwitch v-model="emojiPickerUseDrawerForMobile">
{{ i18n.ts.useDrawerReactionPickerForMobile }}
<template #caption>{{ i18n.ts.needReloadToApply }}</template>
</MkSwitch>
</div>
</FormSection>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, Ref, watch } from 'vue';
import Sortable from 'vuedraggable';
import MkRadios from '@/components/MkRadios.vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import * as os from '@/os.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { deepClone } from '@/scripts/clone.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
import MkEmoji from '@/components/global/MkEmoji.vue';
import MkFolder from '@/components/MkFolder.vue';
const pinnedEmojisForReaction: Ref<string[]> = ref(deepClone(defaultStore.state.reactions));
const pinnedEmojis: Ref<string[]> = ref(deepClone(defaultStore.state.pinnedEmojis));
const emojiPickerScale = computed(defaultStore.makeGetterSetter('emojiPickerScale'));
const emojiPickerWidth = computed(defaultStore.makeGetterSetter('emojiPickerWidth'));
const emojiPickerHeight = computed(defaultStore.makeGetterSetter('emojiPickerHeight'));
const emojiPickerUseDrawerForMobile = computed(defaultStore.makeGetterSetter('emojiPickerUseDrawerForMobile'));
const removeReaction = (reaction: string, ev: MouseEvent) => remove(pinnedEmojisForReaction, reaction, ev);
const chooseReaction = (ev: MouseEvent) => pickEmoji(pinnedEmojisForReaction, ev);
const setDefaultReaction = () => setDefault(pinnedEmojisForReaction);
const removeEmoji = (reaction: string, ev: MouseEvent) => remove(pinnedEmojis, reaction, ev);
const chooseEmoji = (ev: MouseEvent) => pickEmoji(pinnedEmojis, ev);
const setDefaultEmoji = () => setDefault(pinnedEmojis);
function previewReaction(ev: MouseEvent) {
reactionPicker.show(getHTMLElement(ev));
}
function previewEmoji(ev: MouseEvent) {
emojiPicker.show(getHTMLElement(ev));
}
async function overwriteFromPinnedEmojis() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.overwriteContentConfirm,
});
if (canceled) {
return;
}
pinnedEmojisForReaction.value = [...pinnedEmojis.value];
}
async function overwriteFromPinnedEmojisForReaction() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.overwriteContentConfirm,
});
if (canceled) {
return;
}
pinnedEmojis.value = [...pinnedEmojisForReaction.value];
}
function remove(itemsRef: Ref<string[]>, reaction: string, ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.remove,
action: () => {
itemsRef.value = itemsRef.value.filter(x => x !== reaction);
},
}], getHTMLElement(ev));
}
async function setDefault(itemsRef: Ref<string[]>) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.resetAreYouSure,
});
if (canceled) return;
itemsRef.value = deepClone(defaultStore.def.reactions.default);
}
async function pickEmoji(itemsRef: Ref<string[]>, ev: MouseEvent) {
os.pickEmoji(getHTMLElement(ev), {
showPinned: false,
}).then(it => {
const emoji = it as string;
if (!itemsRef.value.includes(emoji)) {
itemsRef.value.push(emoji);
}
});
}
function getHTMLElement(ev: MouseEvent): HTMLElement {
const target = ev.currentTarget ?? ev.target;
return target as HTMLElement;
}
watch(pinnedEmojisForReaction, () => {
defaultStore.set('reactions', pinnedEmojisForReaction.value);
}, {
deep: true,
});
watch(pinnedEmojis, () => {
defaultStore.set('pinnedEmojis', pinnedEmojis.value);
}, {
deep: true,
});
definePageMetadata({
title: i18n.ts.emojiPicker,
icon: 'ti ti-mood-happy',
});
</script>
<style lang="scss" module>
.tab {
margin: calc(var(--margin) / 2) 0;
padding: calc(var(--margin) / 2) 0;
background: var(--bg);
}
.emojis {
padding: 12px;
font-size: 1.1em;
}
.emojisItem {
display: inline-block;
padding: 8px;
cursor: move;
}
.emojisAdd {
display: inline-block;
padding: 8px;
}
.editorCaption {
font-size: 0.85em;
padding: 8px 0 0 0;
color: var(--fgTransparentWeak);
}
</style>

View File

@@ -123,6 +123,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch>
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
<MkSwitch v-model="enableSeasonalScreenEffect">{{ i18n.ts.seasonalScreenEffect }}</MkSwitch>
</div>
<div>
<MkRadios v-model="emojiStyle">
@@ -291,11 +292,12 @@ const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificati
const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn'));
const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline'));
const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications'));
const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enableSeasonalScreenEffect'));
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
miLocalStorage.removeItem('locale');
miLocalStorage.removeItem('localeVersion');
miLocalStorage.removeItem('localeVersion');
});
watch(fontSize, () => {
@@ -331,6 +333,7 @@ watch([
keepScreenOn,
hideMutedNotes,
disableStreamingTimeline,
enableSeasonalScreenEffect,
], async () => {
await reloadAsk();
});

View File

@@ -74,9 +74,9 @@ const menuDef = computed(() => [{
active: currentPage.value?.route.name === 'privacy',
}, {
icon: 'ti ti-mood-happy',
text: i18n.ts.reaction,
to: '/settings/reaction',
active: currentPage.value?.route.name === 'reaction',
text: i18n.ts.emojiPicker,
to: '/settings/emoji-picker',
active: currentPage.value?.route.name === 'emojiPicker',
}, {
icon: 'ti ti-cloud',
text: i18n.ts.drive,
@@ -236,7 +236,7 @@ provideMetadataReceiver((info) => {
childInfo.value = null;
} else {
childInfo.value = info;
INFO.value.needWideArea = info.value?.needWideArea ?? undefined;
INFO.value.needWideArea = info.value.needWideArea ?? undefined;
}
});

View File

@@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m">
<FormInfo warn>{{ i18n.ts._plugin.installWarn }}</FormInfo>
<MkTextarea v-model="code" tall>
<MkCodeEditor v-model="code" lang="is">
<template #label>{{ i18n.ts.code }}</template>
</MkTextarea>
</MkCodeEditor>
<div>
<MkButton :disabled="code == null" primary inline @click="install"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton>
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { nextTick, ref, computed } from 'vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import MkButton from '@/components/MkButton.vue';
import FormInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';

View File

@@ -83,10 +83,10 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'useReactionPickerForContextMenu',
'showGapBetweenNotesInTimeline',
'instanceTicker',
'reactionPickerSize',
'reactionPickerWidth',
'reactionPickerHeight',
'reactionPickerUseDrawerForMobile',
'emojiPickerScale',
'emojiPickerWidth',
'emojiPickerHeight',
'emojiPickerUseDrawerForMobile',
'defaultSideView',
'menuDisplay',
'reportError',
@@ -406,7 +406,7 @@ function menu(ev: MouseEvent, profileId: string) {
icon: 'ti ti-download',
href: URL.createObjectURL(new Blob([JSON.stringify(profiles.value[profileId], null, 2)], { type: 'application/json' })),
download: `${profiles.value[profileId].name}.json`,
}, null, {
}, { type: 'divider' }, {
text: ts.rename,
icon: 'ti ti-forms',
action: () => rename(profileId),
@@ -414,7 +414,7 @@ function menu(ev: MouseEvent, profileId: string) {
text: ts._preferencesBackups.save,
icon: 'ti ti-device-floppy',
action: () => save(profileId),
}, null, {
}, { type: 'divider' }, {
text: ts.delete,
icon: 'ti ti-trash',
action: () => deleteProfile(profileId),

View File

@@ -1,114 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="400"
:height="450"
@close="cancel"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.avatarDecorations }}</template>
<div>
<MkSpacer :marginMin="20" :marginMax="28">
<div style="text-align: center;">
<div :class="$style.name">{{ decoration.name }}</div>
<MkAvatar style="width: 64px; height: 64px; margin-bottom: 20px;" :user="$i" :decoration="{ url: decoration.url, angle, flipH }" forceShowDecoration/>
</div>
<div class="_gaps_s">
<MkRange v-model="angle" continuousUpdate :min="-0.5" :max="0.5" :step="0.025" :textConverter="(v) => `${Math.floor(v * 360)}°`">
<template #label>{{ i18n.ts.angle }}</template>
</MkRange>
<MkSwitch v-model="flipH">
<template #label>{{ i18n.ts.flip }}</template>
</MkSwitch>
</div>
</MkSpacer>
<div :class="$style.footer" class="_buttonsCenter">
<MkButton v-if="using" primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.update }}</MkButton>
<MkButton v-if="using" rounded @click="detach"><i class="ti ti-x"></i> {{ i18n.ts.detach }}</MkButton>
<MkButton v-else primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.attach }}</MkButton>
</div>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { shallowRef, ref, computed } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkRange from '@/components/MkRange.vue';
import { $i } from '@/account.js';
const props = defineProps<{
decoration: {
id: string;
url: string;
}
}>();
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const using = computed(() => $i.avatarDecorations.some(x => x.id === props.decoration.id));
const angle = ref(using.value ? $i.avatarDecorations.find(x => x.id === props.decoration.id).angle ?? 0 : 0);
const flipH = ref(using.value ? $i.avatarDecorations.find(x => x.id === props.decoration.id).flipH ?? false : false);
function cancel() {
dialog.value.close();
}
async function attach() {
const decoration = {
id: props.decoration.id,
angle: angle.value,
flipH: flipH.value,
};
await os.apiWithDialog('i/update', {
avatarDecorations: [decoration],
});
$i.avatarDecorations = [decoration];
dialog.value.close();
}
async function detach() {
await os.apiWithDialog('i/update', {
avatarDecorations: [],
});
$i.avatarDecorations = [];
dialog.value.close();
}
</script>
<style lang="scss" module>
.name {
position: relative;
z-index: 10;
font-weight: bold;
margin-bottom: 28px;
}
.footer {
position: sticky;
bottom: 0;
left: 0;
padding: 12px;
border-top: solid 0.5px var(--divider);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
}
</style>

View File

@@ -0,0 +1,69 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
:class="[$style.root, { [$style.active]: active }]"
@click="emit('click')"
>
<div :class="$style.name"><MkCondensedLine :minScale="0.5">{{ decoration.name }}</MkCondensedLine></div>
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="[{ url: decoration.url, angle, flipH, offsetX, offsetY }]" forceShowDecoration/>
<i v-if="decoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => decoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id))" :class="$style.lock" class="ti ti-lock"></i>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
import { $i } from '@/account.js';
const props = defineProps<{
active?: boolean;
decoration: {
id: string;
url: string;
name: string;
roleIdsThatCanBeUsedThisDecoration: string[];
};
angle?: number;
flipH?: boolean;
offsetX?: number;
offsetY?: number;
}>();
const emit = defineEmits<{
(ev: 'click'): void;
}>();
</script>
<style lang="scss" module>
.root {
cursor: pointer;
padding: 16px 16px 28px 16px;
border: solid 2px var(--divider);
border-radius: 8px;
text-align: center;
font-size: 90%;
overflow: clip;
contain: content;
}
.active {
background-color: var(--accentedBg);
border-color: var(--accent);
}
.name {
position: relative;
z-index: 10;
font-weight: bold;
margin-bottom: 20px;
}
.lock {
position: absolute;
bottom: 12px;
right: 12px;
}
</style>

View File

@@ -0,0 +1,157 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="400"
:height="450"
@close="cancel"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.avatarDecorations }}</template>
<div>
<MkSpacer :marginMin="20" :marginMax="28">
<div style="text-align: center;">
<div :class="$style.name">{{ decoration.name }}</div>
<MkAvatar style="width: 64px; height: 64px; margin-bottom: 20px;" :user="$i" :decorations="decorationsForPreview" forceShowDecoration/>
</div>
<div class="_gaps_s">
<MkRange v-model="angle" continuousUpdate :min="-0.5" :max="0.5" :step="0.025" :textConverter="(v) => `${Math.floor(v * 360)}°`">
<template #label>{{ i18n.ts.angle }}</template>
</MkRange>
<MkRange v-model="offsetX" continuousUpdate :min="-0.25" :max="0.25" :step="0.025" :textConverter="(v) => `${Math.floor(v * 100)}%`">
<template #label>X {{ i18n.ts.position }}</template>
</MkRange>
<MkRange v-model="offsetY" continuousUpdate :min="-0.25" :max="0.25" :step="0.025" :textConverter="(v) => `${Math.floor(v * 100)}%`">
<template #label>Y {{ i18n.ts.position }}</template>
</MkRange>
<MkSwitch v-model="flipH">
<template #label>{{ i18n.ts.flip }}</template>
</MkSwitch>
</div>
</MkSpacer>
<div :class="$style.footer" class="_buttonsCenter">
<MkButton v-if="usingIndex != null" primary rounded @click="update"><i class="ti ti-check"></i> {{ i18n.ts.update }}</MkButton>
<MkButton v-if="usingIndex != null" rounded @click="detach"><i class="ti ti-x"></i> {{ i18n.ts.detach }}</MkButton>
<MkButton v-else :disabled="exceeded" primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.attach }}</MkButton>
</div>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { shallowRef, ref, computed } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkRange from '@/components/MkRange.vue';
import { $i } from '@/account.js';
const props = defineProps<{
usingIndex: number | null;
decoration: {
id: string;
url: string;
name: string;
};
}>();
const emit = defineEmits<{
(ev: 'closed'): void;
(ev: 'attach', payload: {
angle: number;
flipH: boolean;
offsetX: number;
offsetY: number;
}): void;
(ev: 'update', payload: {
angle: number;
flipH: boolean;
offsetX: number;
offsetY: number;
}): void;
(ev: 'detach'): void;
}>();
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const exceeded = computed(() => ($i.policies.avatarDecorationLimit - $i.avatarDecorations.length) <= 0);
const angle = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].angle : null) ?? 0);
const flipH = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].flipH : null) ?? false);
const offsetX = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].offsetX : null) ?? 0);
const offsetY = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].offsetY : null) ?? 0);
const decorationsForPreview = computed(() => {
const decoration = {
id: props.decoration.id,
url: props.decoration.url,
angle: angle.value,
flipH: flipH.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
};
const decorations = [...$i.avatarDecorations];
if (props.usingIndex != null) {
decorations[props.usingIndex] = decoration;
} else {
decorations.push(decoration);
}
return decorations;
});
function cancel() {
dialog.value.close();
}
async function update() {
emit('update', {
angle: angle.value,
flipH: flipH.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
});
dialog.value.close();
}
async function attach() {
emit('attach', {
angle: angle.value,
flipH: flipH.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
});
dialog.value.close();
}
async function detach() {
emit('detach');
dialog.value.close();
}
</script>
<style lang="scss" module>
.name {
position: relative;
z-index: 10;
font-weight: bold;
margin-bottom: 28px;
}
.footer {
position: sticky;
bottom: 0;
left: 0;
padding: 12px;
border-top: solid 0.5px var(--divider);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
}
</style>

View File

@@ -0,0 +1,131 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="!loading" class="_gaps">
<MkInfo>{{ i18n.t('_profile.avatarDecorationMax', { max: $i.policies.avatarDecorationLimit }) }} ({{ i18n.t('remainingN', { n: $i.policies.avatarDecorationLimit - $i.avatarDecorations.length }) }})</MkInfo>
<div v-if="$i.avatarDecorations.length > 0" v-panel :class="$style.current" class="_gaps_s">
<div>{{ i18n.ts.inUse }}</div>
<div :class="$style.decorations">
<XDecoration
v-for="(avatarDecoration, i) in $i.avatarDecorations"
:decoration="avatarDecorations.find(d => d.id === avatarDecoration.id)"
:angle="avatarDecoration.angle"
:flipH="avatarDecoration.flipH"
:offsetX="avatarDecoration.offsetX"
:offsetY="avatarDecoration.offsetY"
:active="true"
@click="openDecoration(avatarDecoration, i)"
/>
</div>
<MkButton danger @click="detachAllDecorations">{{ i18n.ts.detachAll }}</MkButton>
</div>
<div :class="$style.decorations">
<XDecoration
v-for="avatarDecoration in avatarDecorations"
:key="avatarDecoration.id"
:decoration="avatarDecoration"
@click="openDecoration(avatarDecoration)"
/>
</div>
</div>
<div v-else>
<MkLoading/>
</div>
</template>
<script lang="ts" setup>
import { ref, defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
import XDecoration from './profile.avatar-decoration.decoration.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
import MkInfo from '@/components/MkInfo.vue';
const loading = ref(true);
const avatarDecorations = ref<Misskey.entities.GetAvatarDecorationsResponse>([]);
os.api('get-avatar-decorations').then(_avatarDecorations => {
avatarDecorations.value = _avatarDecorations;
loading.value = false;
});
function openDecoration(avatarDecoration, index?: number) {
os.popup(defineAsyncComponent(() => import('./profile.avatar-decoration.dialog.vue')), {
decoration: avatarDecoration,
usingIndex: index,
}, {
'attach': async (payload) => {
const decoration = {
id: avatarDecoration.id,
angle: payload.angle,
flipH: payload.flipH,
offsetX: payload.offsetX,
offsetY: payload.offsetY,
};
const update = [...$i.avatarDecorations, decoration];
await os.apiWithDialog('i/update', {
avatarDecorations: update,
});
$i.avatarDecorations = update;
},
'update': async (payload) => {
const decoration = {
id: avatarDecoration.id,
angle: payload.angle,
flipH: payload.flipH,
offsetX: payload.offsetX,
offsetY: payload.offsetY,
};
const update = [...$i.avatarDecorations];
update[index] = decoration;
await os.apiWithDialog('i/update', {
avatarDecorations: update,
});
$i.avatarDecorations = update;
},
'detach': async () => {
const update = [...$i.avatarDecorations];
update.splice(index, 1);
await os.apiWithDialog('i/update', {
avatarDecorations: update,
});
$i.avatarDecorations = update;
},
}, 'closed');
}
function detachAllDecorations() {
os.confirm({
type: 'warning',
text: i18n.ts.areYouSure,
}).then(async ({ canceled }) => {
if (canceled) return;
await os.apiWithDialog('i/update', {
avatarDecorations: [],
});
$i.avatarDecorations = [];
});
}
</script>
<style lang="scss" module>
.current {
padding: 16px;
border-radius: var(--radius);
}
.decorations {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
grid-gap: 12px;
}
</style>

View File

@@ -13,11 +13,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton primary rounded :class="$style.bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton>
</div>
<MkInput v-model="profile.name" :max="30" manualSave>
<MkInput v-model="profile.name" :max="30" manualSave :mfmAutocomplete="['emoji']">
<template #label>{{ i18n.ts._profile.name }}</template>
</MkInput>
<MkTextarea v-model="profile.description" :max="500" tall manualSave>
<MkTextarea v-model="profile.description" :max="500" tall manualSave mfmAutocomplete :mfmPreview="true">
<template #label>{{ i18n.ts._profile.description }}</template>
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
</MkTextarea>
@@ -87,18 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-sparkles"></i></template>
<template #label>{{ i18n.ts.avatarDecorations }}</template>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-gap: 12px;">
<div
v-for="avatarDecoration in avatarDecorations"
:key="avatarDecoration.id"
:class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]"
@click="openDecoration(avatarDecoration)"
>
<div :class="$style.avatarDecorationName"><MkCondensedLine :minScale="0.5">{{ avatarDecoration.name }}</MkCondensedLine></div>
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decoration="{ url: avatarDecoration.url }" forceShowDecoration/>
<i v-if="avatarDecoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => avatarDecoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id))" :class="$style.avatarDecorationLock" class="ti ti-lock"></i>
</div>
</div>
<XAvatarDecoration/>
</MkFolder>
<MkFolder>
@@ -122,10 +111,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch, defineAsyncComponent, onMounted, onUnmounted } from 'vue';
import { computed, reactive, ref, watch, defineAsyncComponent } from 'vue';
import Misskey from 'misskey-js';
import XAvatarDecoration from './profile.avatar-decoration.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
import FormSplit from '@/components/form/split.vue';
@@ -140,11 +130,11 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { defaultStore } from '@/store.js';
import MkInfo from '@/components/MkInfo.vue';
import MkTextarea from '@/components/MkTextarea.vue';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance'));
const avatarDecorations = ref<any[]>([]);
const profile = reactive({
name: $i.name,
@@ -165,10 +155,6 @@ watch(() => profile, () => {
const fields = ref($i?.fields.map(field => ({ id: Math.random().toString(), name: field.name, value: field.value })) ?? []);
const fieldEditMode = ref(false);
os.api('get-avatar-decorations').then(_avatarDecorations => {
avatarDecorations.value = _avatarDecorations;
});
function addField() {
fields.value.push({
id: Math.random().toString(),
@@ -267,12 +253,6 @@ function changeBanner(ev) {
});
}
function openDecoration(avatarDecoration) {
os.popup(defineAsyncComponent(() => import('./profile.avatar-decoration-dialog.vue')), {
decoration: avatarDecoration,
}, {}, 'closed');
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
@@ -367,33 +347,4 @@ definePageMetadata({
.dragItemForm {
flex-grow: 1;
}
.avatarDecoration {
cursor: pointer;
padding: 16px 16px 28px 16px;
border: solid 2px var(--divider);
border-radius: 8px;
text-align: center;
font-size: 90%;
overflow: clip;
contain: content;
}
.avatarDecorationActive {
background-color: var(--accentedBg);
border-color: var(--accent);
}
.avatarDecorationName {
position: relative;
z-index: 10;
font-weight: bold;
margin-bottom: 20px;
}
.avatarDecorationLock {
position: absolute;
bottom: 12px;
right: 12px;
}
</style>

View File

@@ -1,159 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps_m">
<FromSlot>
<template #label>{{ i18n.ts.reactionSettingDescription }}</template>
<div v-panel style="border-radius: 6px;">
<Sortable v-model="reactions" :class="$style.reactions" :itemKey="item => item" :animation="150" :delay="100" :delayOnTouchOnly="true">
<template #item="{element}">
<button class="_button" :class="$style.reactionsItem" @click="remove(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
<button class="_button" :class="$style.reactionsAdd" @click="chooseEmoji"><i class="ti ti-plus"></i></button>
</template>
</Sortable>
</div>
<template #caption>{{ i18n.ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ i18n.ts.preview }}</button></template>
</FromSlot>
<MkRadios v-model="reactionPickerSize">
<template #label>{{ i18n.ts.size }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
</MkRadios>
<MkRadios v-model="reactionPickerWidth">
<template #label>{{ i18n.ts.numberOfColumn }}</template>
<option :value="1">5</option>
<option :value="2">6</option>
<option :value="3">7</option>
<option :value="4">8</option>
<option :value="5">9</option>
</MkRadios>
<MkRadios v-model="reactionPickerHeight">
<template #label>{{ i18n.ts.height }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
<option :value="4">{{ i18n.ts.large }}+</option>
</MkRadios>
<MkSwitch v-model="reactionPickerUseDrawerForMobile">
{{ i18n.ts.useDrawerReactionPickerForMobile }}
<template #caption>{{ i18n.ts.needReloadToApply }}</template>
</MkSwitch>
<FormSection>
<div class="_buttons">
<MkButton inline @click="preview"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton inline danger @click="setDefault"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
</div>
</FormSection>
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, watch, ref, computed } from 'vue';
import Sortable from 'vuedraggable';
import MkRadios from '@/components/MkRadios.vue';
import FromSlot from '@/components/form/slot.vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import * as os from '@/os.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { deepClone } from '@/scripts/clone.js';
const reactions = ref(deepClone(defaultStore.state.reactions));
const reactionPickerSize = computed(defaultStore.makeGetterSetter('reactionPickerSize'));
const reactionPickerWidth = computed(defaultStore.makeGetterSetter('reactionPickerWidth'));
const reactionPickerHeight = computed(defaultStore.makeGetterSetter('reactionPickerHeight'));
const reactionPickerUseDrawerForMobile = computed(defaultStore.makeGetterSetter('reactionPickerUseDrawerForMobile'));
function save() {
defaultStore.set('reactions', reactions.value);
}
function remove(reaction, ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.remove,
action: () => {
reactions.value = reactions.value.filter(x => x !== reaction);
},
}], ev.currentTarget ?? ev.target);
}
function preview(ev: MouseEvent) {
os.popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
asReactionPicker: true,
src: ev.currentTarget ?? ev.target,
}, {}, 'closed');
}
async function setDefault() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.resetAreYouSure,
});
if (canceled) return;
reactions.value = deepClone(defaultStore.def.reactions.default);
}
function chooseEmoji(ev: MouseEvent) {
os.pickEmoji(ev.currentTarget ?? ev.target, {
showPinned: false,
}).then(emoji => {
if (!reactions.value.includes(emoji)) {
reactions.value.push(emoji);
}
});
}
watch(reactions, () => {
save();
}, {
deep: true,
});
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata({
title: i18n.ts.reaction,
icon: 'ti ti-mood-happy',
action: {
icon: 'ti ti-eye',
handler: preview,
},
});
</script>
<style lang="scss" module>
.reactions {
padding: 12px;
font-size: 1.1em;
}
.reactionsItem {
display: inline-block;
padding: 8px;
cursor: move;
}
.reactionsAdd {
display: inline-block;
padding: 8px;
}
</style>

View File

@@ -5,9 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_gaps_m">
<MkTextarea v-model="installThemeCode">
<MkCodeEditor v-model="installThemeCode" lang="json5">
<template #label>{{ i18n.ts._theme.code }}</template>
</MkTextarea>
</MkCodeEditor>
<div class="_buttons">
<MkButton :disabled="installThemeCode == null" inline @click="() => previewTheme(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, computed } from 'vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import MkButton from '@/components/MkButton.vue';
import { parseThemeCode, previewTheme, installTheme } from '@/scripts/install-theme.js';
import * as os from '@/os.js';

View File

@@ -51,9 +51,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.editCode }}</template>
<div class="_gaps_m">
<MkTextarea v-model="themeCode" tall>
<MkCodeEditor v-model="themeCode" lang="json5">
<template #label>{{ i18n.ts._theme.code }}</template>
</MkTextarea>
</MkCodeEditor>
<MkButton primary @click="applyThemeCode">{{ i18n.ts.apply }}</MkButton>
</div>
</MkFolder>
@@ -80,6 +80,7 @@ import { v4 as uuid } from 'uuid';
import JSON5 from 'json5';
import MkButton from '@/components/MkButton.vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkFolder from '@/components/MkFolder.vue';

View File

@@ -48,6 +48,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { miLocalStorage } from '@/local-storage.js';
import { antennasCache, userListsCache } from '@/cache.js';
import { deviceKind } from '@/scripts/device-kind.js';
import { MenuItem } from '@/types/menu.js';
provide('shouldOmitHeaderTitle', true);
@@ -83,22 +84,40 @@ function top(): void {
async function chooseList(ev: MouseEvent): Promise<void> {
const lists = await userListsCache.fetch();
const items = lists.map(list => ({
type: 'link' as const,
text: list.name,
to: `/timeline/list/${list.id}`,
}));
const items: MenuItem[] = [
...lists.map(list => ({
type: 'link' as const,
text: list.name,
to: `/timeline/list/${list.id}`,
})),
(lists.length === 0 ? undefined : { type: 'divider' }),
{
type: 'link' as const,
icon: 'ti ti-plus',
text: i18n.ts.createNew,
to: '/my/lists',
},
];
os.popupMenu(items, ev.currentTarget ?? ev.target);
}
async function chooseAntenna(ev: MouseEvent): Promise<void> {
const antennas = await antennasCache.fetch();
const items = antennas.map(antenna => ({
type: 'link' as const,
text: antenna.name,
indicate: antenna.hasUnreadNote,
to: `/timeline/antenna/${antenna.id}`,
}));
const items: MenuItem[] = [
...antennas.map(antenna => ({
type: 'link' as const,
text: antenna.name,
indicate: antenna.hasUnreadNote,
to: `/timeline/antenna/${antenna.id}`,
})),
(antennas.length === 0 ? undefined : { type: 'divider' }),
{
type: 'link' as const,
icon: 'ti ti-plus',
text: i18n.ts.createNew,
to: '/my/antennas',
},
];
os.popupMenu(items, ev.currentTarget ?? ev.target);
}
@@ -106,12 +125,21 @@ async function chooseChannel(ev: MouseEvent): Promise<void> {
const channels = await os.api('channels/my-favorites', {
limit: 100,
});
const items = channels.map(channel => ({
type: 'link' as const,
text: channel.name,
indicate: channel.hasUnreadNote,
to: `/channels/${channel.id}`,
}));
const items: MenuItem[] = [
...channels.map(channel => ({
type: 'link' as const,
text: channel.name,
indicate: channel.hasUnreadNote,
to: `/channels/${channel.id}`,
})),
(channels.length === 0 ? undefined : { type: 'divider' }),
{
type: 'link' as const,
icon: 'ti ti-plus',
text: i18n.ts.createNew,
to: '/channels',
},
];
os.popupMenu(items, ev.currentTarget ?? ev.target);
}

View File

@@ -128,12 +128,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
<template v-if="narrow">
<XFiles :key="user.id" :user="user"/>
<XActivity :key="user.id" :user="user"/>
<MkLazy>
<XFiles :key="user.id" :user="user"/>
</MkLazy>
<MkLazy>
<XActivity :key="user.id" :user="user"/>
</MkLazy>
</template>
<div v-if="!disableNotes">
<div style="margin-bottom: 8px;">{{ i18n.ts.featured }}</div>
<MkNotes :class="$style.tl" :noGap="true" :pagination="pagination"/>
<MkLazy>
<XTimeline :user="user"/>
</MkLazy>
</div>
</div>
</div>
@@ -187,6 +192,7 @@ function calcAge(birthdate: string): number {
const XFiles = defineAsyncComponent(() => import('./index.files.vue'));
const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue'));
const props = withDefaults(defineProps<{
user: Misskey.entities.UserDetailed;
@@ -213,14 +219,6 @@ watch(moderationNote, async () => {
await os.api('admin/update-user-note', { userId: props.user.id, text: moderationNote.value });
});
const pagination = {
endpoint: 'users/featured-notes' as const,
limit: 10,
params: computed(() => ({
userId: props.user.id,
})),
};
const style = computed(() => {
if (props.user.bannerUrl == null) return {};
return {
@@ -232,7 +230,7 @@ const age = computed(() => {
return calcAge(props.user.birthday);
});
function menu(ev) {
function menu(ev: MouseEvent) {
const { menu, cleanup } = getUserMenu(user.value, router);
os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup);
}

View File

@@ -4,18 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkSpacer :contentMax="800" style="padding-top: 0">
<MkStickyContainer>
<template #header>
<MkTab v-model="include" :class="$style.tab">
<option :value="null">{{ i18n.ts.notes }}</option>
<option value="all">{{ i18n.ts.all }}</option>
<option value="files">{{ i18n.ts.withFiles }}</option>
</MkTab>
</template>
<MkNotes :noGap="true" :pagination="pagination" :class="$style.tl"/>
</MkStickyContainer>
</MkSpacer>
<MkStickyContainer>
<template #header>
<MkTab v-model="tab" :class="$style.tab">
<option value="featured">{{ i18n.ts.featured }}</option>
<option :value="null">{{ i18n.ts.notes }}</option>
<option value="all">{{ i18n.ts.all }}</option>
<option value="files">{{ i18n.ts.withFiles }}</option>
</MkTab>
</template>
<MkNotes :noGap="true" :pagination="pagination" :class="$style.tl"/>
</MkStickyContainer>
</template>
<script lang="ts" setup>
@@ -29,24 +28,29 @@ const props = defineProps<{
user: Misskey.entities.UserDetailed;
}>();
const include = ref<string | null>('all');
const tab = ref<string | null>('all');
const pagination = {
const pagination = computed(() => tab.value === 'featured' ? {
endpoint: 'users/featured-notes' as const,
limit: 10,
params: {
userId: props.user.id,
},
} : {
endpoint: 'users/notes' as const,
limit: 10,
params: computed(() => ({
params: {
userId: props.user.id,
withRenotes: include.value === 'all',
withReplies: include.value === 'all',
withFiles: include.value === 'files',
withRenotes: tab.value === 'all',
withReplies: tab.value === 'all',
withFiles: tab.value === 'files',
withChannelNotes: true,
})),
};
},
});
</script>
<style lang="scss" module>
.tab {
margin: calc(var(--margin) / 2) 0;
padding: calc(var(--margin) / 2) 0;
background: var(--bg);
}

View File

@@ -9,7 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<div v-if="user">
<XHome v-if="tab === 'home'" :user="user"/>
<XTimeline v-else-if="tab === 'notes'" :user="user"/>
<MkSpacer v-else-if="tab === 'notes'" :contentMax="800" style="padding-top: 0">
<XTimeline :user="user"/>
</MkSpacer>
<XActivity v-else-if="tab === 'activity'" :user="user"/>
<XAchievements v-else-if="tab === 'achievements'" :user="user"/>
<XReactions v-else-if="tab === 'reactions'" :user="user"/>