Merge branch 'develop' into mahjong
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { type SharedOptions, rest } from 'msw';
|
||||
import { type SharedOptions, http, HttpResponse } from 'msw';
|
||||
|
||||
export const onUnhandledRequest = ((req, print) => {
|
||||
if (req.url.hostname !== 'localhost' || /^\/(?:client-assets\/|fluent-emojis?\/|iframe.html$|node_modules\/|src\/|sb-|static-assets\/|vite\/)/.test(req.url.pathname)) {
|
||||
@@ -13,19 +13,31 @@ export const onUnhandledRequest = ((req, print) => {
|
||||
}) satisfies SharedOptions['onUnhandledRequest'];
|
||||
|
||||
export const commonHandlers = [
|
||||
rest.get('/fluent-emoji/:codepoints.png', async (req, res, ctx) => {
|
||||
const { codepoints } = req.params;
|
||||
http.get('/fluent-emoji/:codepoints.png', async ({ params }) => {
|
||||
const { codepoints } = params;
|
||||
const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob());
|
||||
return res(ctx.set('Content-Type', 'image/png'), ctx.body(value));
|
||||
return new HttpResponse(value, {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
});
|
||||
}),
|
||||
rest.get('/fluent-emojis/:codepoints.png', async (req, res, ctx) => {
|
||||
const { codepoints } = req.params;
|
||||
http.get('/fluent-emojis/:codepoints.png', async ({ params }) => {
|
||||
const { codepoints } = params;
|
||||
const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob());
|
||||
return res(ctx.set('Content-Type', 'image/png'), ctx.body(value));
|
||||
return new HttpResponse(value, {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
});
|
||||
}),
|
||||
rest.get('/twemoji/:codepoints.svg', async (req, res, ctx) => {
|
||||
const { codepoints } = req.params;
|
||||
http.get('/twemoji/:codepoints.svg', async ({ params }) => {
|
||||
const { codepoints } = params;
|
||||
const value = await fetch(`https://unpkg.com/@discordapp/twemoji@15.0.2/dist/svg/${codepoints}.svg`).then((response) => response.blob());
|
||||
return res(ctx.set('Content-Type', 'image/svg+xml'), ctx.body(value));
|
||||
return new HttpResponse(value, {
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
@@ -123,8 +123,8 @@
|
||||
"happy-dom": "10.0.3",
|
||||
"intersection-observer": "0.12.2",
|
||||
"micromatch": "4.0.5",
|
||||
"msw": "2.1.2",
|
||||
"msw-storybook-addon": "1.10.0",
|
||||
"msw": "2.1.7",
|
||||
"msw-storybook-addon": "2.0.0-beta.1",
|
||||
"nodemon": "3.0.3",
|
||||
"prettier": "3.2.4",
|
||||
"react": "18.2.0",
|
||||
|
@@ -6,7 +6,7 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { abuseUserReport } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAbuseReport from './MkAbuseReport.vue';
|
||||
@@ -44,9 +44,9 @@ export const Default = {
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.post('/api/admin/resolve-abuse-user-report', async (req, res, ctx) => {
|
||||
action('POST /api/admin/resolve-abuse-user-report')(await req.json());
|
||||
return res(ctx.json({}));
|
||||
http.post('/api/admin/resolve-abuse-user-report', async ({ request }) => {
|
||||
action('POST /api/admin/resolve-abuse-user-report')(await request.json());
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
@@ -6,7 +6,7 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { userDetailed } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAbuseReportWindow from './MkAbuseReportWindow.vue';
|
||||
@@ -44,9 +44,9 @@ export const Default = {
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.post('/api/users/report-abuse', async (req, res, ctx) => {
|
||||
action('POST /api/users/report-abuse')(await req.json());
|
||||
return res(ctx.json({}));
|
||||
http.post('/api/users/report-abuse', async ({ request }) => {
|
||||
action('POST /api/users/report-abuse')(await request.json());
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { userDetailed } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAchievements from './MkAchievements.vue';
|
||||
@@ -39,8 +39,8 @@ export const Empty = {
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.post('/api/users/achievements', (req, res, ctx) => {
|
||||
return res(ctx.json([]));
|
||||
http.post('/api/users/achievements', () => {
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -52,8 +52,8 @@ export const All = {
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.post('/api/users/achievements', (req, res, ctx) => {
|
||||
return res(ctx.json(ACHIEVEMENT_TYPES.map((name) => ({ name, unlockedAt: 0 }))));
|
||||
http.post('/api/users/achievements', () => {
|
||||
return HttpResponse.json(ACHIEVEMENT_TYPES.map((name) => ({ name, unlockedAt: 0 })));
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
@@ -8,7 +8,7 @@ import { action } from '@storybook/addon-actions';
|
||||
import { expect } from '@storybook/jest';
|
||||
import { userEvent, waitFor, within } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { userDetailed } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAutocomplete from './MkAutocomplete.vue';
|
||||
@@ -99,11 +99,11 @@ export const User = {
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.post('/api/users/search-by-username-and-host', (req, res, ctx) => {
|
||||
return res(ctx.json([
|
||||
http.post('/api/users/search-by-username-and-host', () => {
|
||||
return HttpResponse.json([
|
||||
userDetailed('44', 'mizuki', 'misskey-hub.net', 'Mizuki'),
|
||||
userDetailed('49', 'momoko', 'misskey-hub.net', 'Momoko'),
|
||||
]));
|
||||
]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -132,12 +132,12 @@ export const Hashtag = {
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.post('/api/hashtags/search', (req, res, ctx) => {
|
||||
return res(ctx.json([
|
||||
http.post('/api/hashtags/search', () => {
|
||||
return HttpResponse.json([
|
||||
'気象警報注意報',
|
||||
'気象警報',
|
||||
'気象情報',
|
||||
]));
|
||||
]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { userDetailed } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAvatars from './MkAvatars.vue';
|
||||
@@ -38,12 +38,12 @@ export const Default = {
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.post('/api/users/show', (req, res, ctx) => {
|
||||
return res(ctx.json([
|
||||
http.post('/api/users/show', () => {
|
||||
return HttpResponse.json([
|
||||
userDetailed('17'),
|
||||
userDetailed('20'),
|
||||
userDetailed('18'),
|
||||
]));
|
||||
]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
@@ -5,14 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div :class="[$style.codeBlockRoot, { [$style.codeEditor]: codeEditor }]" v-html="html"></div>
|
||||
<div :class="[$style.codeBlockRoot, { [$style.codeEditor]: codeEditor }, (darkMode ? $style.dark : $style.light)]" v-html="html"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { bundledLanguagesInfo } from 'shiki';
|
||||
import type { BuiltinLanguage } from 'shiki';
|
||||
import { getHighlighter } from '@/scripts/code-highlighter.js';
|
||||
import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
const props = defineProps<{
|
||||
code: string;
|
||||
@@ -21,11 +22,23 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const highlighter = await getHighlighter();
|
||||
|
||||
const darkMode = defaultStore.reactiveState.darkMode;
|
||||
const codeLang = ref<BuiltinLanguage | 'aiscript'>('js');
|
||||
|
||||
const [lightThemeName, darkThemeName] = await Promise.all([
|
||||
getTheme('light', true),
|
||||
getTheme('dark', true),
|
||||
]);
|
||||
|
||||
const html = computed(() => highlighter.codeToHtml(props.code, {
|
||||
lang: codeLang.value,
|
||||
theme: 'dark-plus',
|
||||
themes: {
|
||||
fallback: 'dark-plus',
|
||||
light: lightThemeName,
|
||||
dark: darkThemeName,
|
||||
},
|
||||
defaultColor: false,
|
||||
cssVariablePrefix: '--shiki-',
|
||||
}));
|
||||
|
||||
async function fetchLanguage(to: string): Promise<void> {
|
||||
@@ -64,6 +77,16 @@ watch(() => props.lang, (to) => {
|
||||
margin: .5em 0;
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--divider);
|
||||
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
|
||||
|
||||
color: var(--shiki-fallback);
|
||||
background-color: var(--shiki-fallback-bg);
|
||||
|
||||
& span {
|
||||
color: var(--shiki-fallback);
|
||||
background-color: var(--shiki-fallback-bg);
|
||||
}
|
||||
|
||||
& pre,
|
||||
& code {
|
||||
@@ -71,6 +94,26 @@ watch(() => props.lang, (to) => {
|
||||
}
|
||||
}
|
||||
|
||||
.light.codeBlockRoot :global(.shiki) {
|
||||
color: var(--shiki-light);
|
||||
background-color: var(--shiki-light-bg);
|
||||
|
||||
& span {
|
||||
color: var(--shiki-light);
|
||||
background-color: var(--shiki-light-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.dark.codeBlockRoot :global(.shiki) {
|
||||
color: var(--shiki-dark);
|
||||
background-color: var(--shiki-dark-bg);
|
||||
|
||||
& span {
|
||||
color: var(--shiki-dark);
|
||||
background-color: var(--shiki-dark-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.codeBlockRoot.codeEditor {
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
@@ -79,6 +122,7 @@ watch(() => props.lang, (to) => {
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
min-height: 130px;
|
||||
pointer-events: none;
|
||||
min-width: calc(100% - 24px);
|
||||
@@ -90,6 +134,11 @@ watch(() => props.lang, (to) => {
|
||||
text-rendering: inherit;
|
||||
text-transform: inherit;
|
||||
white-space: pre;
|
||||
|
||||
& span {
|
||||
display: inline-block;
|
||||
min-height: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -53,7 +53,6 @@ function copy() {
|
||||
}
|
||||
|
||||
.codeBlockCopyButton {
|
||||
color: #D4D4D4;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
@@ -67,8 +66,7 @@ function copy() {
|
||||
.codeBlockFallbackRoot {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
color: #D4D4D4;
|
||||
background: #1E1E1E;
|
||||
background: var(--bg);
|
||||
padding: 1em;
|
||||
margin: .5em 0;
|
||||
overflow: auto;
|
||||
@@ -93,8 +91,8 @@ function copy() {
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-top: 4px;
|
||||
color: #D4D4D4;
|
||||
background: #1E1E1E;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.codePlaceholderContainer {
|
||||
|
@@ -196,10 +196,11 @@ watch(v, newValue => {
|
||||
resize: none;
|
||||
text-align: left;
|
||||
color: transparent;
|
||||
caret-color: rgb(225, 228, 232);
|
||||
caret-color: var(--fg);
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
outline: 0;
|
||||
min-width: calc(100% - 24px);
|
||||
height: 100%;
|
||||
@@ -210,6 +211,6 @@ watch(v, newValue => {
|
||||
}
|
||||
|
||||
.textarea::selection {
|
||||
color: #fff;
|
||||
color: var(--bg);
|
||||
}
|
||||
</style>
|
||||
|
@@ -18,8 +18,7 @@ const props = defineProps<{
|
||||
display: inline-block;
|
||||
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
|
||||
overflow-wrap: anywhere;
|
||||
color: #D4D4D4;
|
||||
background: #1E1E1E;
|
||||
background: var(--bg);
|
||||
padding: .1em;
|
||||
border-radius: .3em;
|
||||
}
|
||||
|
@@ -118,6 +118,7 @@ import { i18n } from '@/i18n.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-emojis.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
showPinned?: boolean;
|
||||
@@ -126,6 +127,7 @@ const props = withDefaults(defineProps<{
|
||||
asDrawer?: boolean;
|
||||
asWindow?: boolean;
|
||||
asReactionPicker?: boolean; // 今は使われてないが将来的に使いそう
|
||||
targetNote?: Misskey.entities.Note;
|
||||
}>(), {
|
||||
showPinned: true,
|
||||
});
|
||||
@@ -340,7 +342,7 @@ watch(q, () => {
|
||||
});
|
||||
|
||||
function filterAvailable(emoji: Misskey.entities.EmojiSimple): boolean {
|
||||
return ((emoji.roleIdsThatCanBeUsedThisEmojiAsReaction == null || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || ($i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction?.includes(r.id)))) ?? false;
|
||||
return !props.targetNote || checkReactionPermissions($i!, props.targetNote, emoji);
|
||||
}
|
||||
|
||||
function focus() {
|
||||
|
@@ -24,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:showPinned="showPinned"
|
||||
:pinnedEmojis="pinnedEmojis"
|
||||
:asReactionPicker="asReactionPicker"
|
||||
:targetNote="targetNote"
|
||||
:asDrawer="type === 'drawer'"
|
||||
:max-height="maxHeight"
|
||||
@chosen="chosen"
|
||||
@@ -32,6 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { shallowRef } from 'vue';
|
||||
import MkModal from '@/components/MkModal.vue';
|
||||
import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
|
||||
@@ -43,6 +45,7 @@ const props = withDefaults(defineProps<{
|
||||
showPinned?: boolean;
|
||||
pinnedEmojis?: string[],
|
||||
asReactionPicker?: boolean;
|
||||
targetNote?: Misskey.entities.Note;
|
||||
choseAndClose?: boolean;
|
||||
}>(), {
|
||||
manualShowing: null,
|
||||
|
@@ -13,12 +13,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:front="true"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<MkEmojiPicker :showPinned="showPinned" :asReactionPicker="asReactionPicker" asWindow :class="$style.picker" @chosen="chosen"/>
|
||||
<MkEmojiPicker :showPinned="showPinned" :asReactionPicker="asReactionPicker" :targetNote="targetNote" asWindow :class="$style.picker" @chosen="chosen"/>
|
||||
</MkWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
|
||||
|
||||
@@ -26,6 +27,7 @@ withDefaults(defineProps<{
|
||||
src?: HTMLElement;
|
||||
showPinned?: boolean;
|
||||
asReactionPicker?: boolean;
|
||||
targetNote?: Misskey.entities.Note
|
||||
}>(), {
|
||||
showPinned: true,
|
||||
});
|
||||
|
@@ -25,11 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, computed, nextTick, watch } from 'vue';
|
||||
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { isHorizontalSwipeSwiping as isSwiping } from '@/scripts/touch.js';
|
||||
|
||||
const rootEl = shallowRef<HTMLDivElement>();
|
||||
|
||||
@@ -49,16 +49,16 @@ const shouldAnimate = computed(() => defaultStore.reactiveState.enableHorizontal
|
||||
// ▼ しきい値 ▼ //
|
||||
|
||||
// スワイプと判定される最小の距離
|
||||
const MIN_SWIPE_DISTANCE = 50;
|
||||
const MIN_SWIPE_DISTANCE = 20;
|
||||
|
||||
// スワイプ時の動作を発火する最小の距離
|
||||
const SWIPE_DISTANCE_THRESHOLD = 125;
|
||||
const SWIPE_DISTANCE_THRESHOLD = 70;
|
||||
|
||||
// スワイプを中断するY方向の移動距離
|
||||
const SWIPE_ABORT_Y_THRESHOLD = 75;
|
||||
|
||||
// スワイプできる最大の距離
|
||||
const MAX_SWIPE_DISTANCE = 150;
|
||||
const MAX_SWIPE_DISTANCE = 120;
|
||||
|
||||
// ▲ しきい値 ▲ //
|
||||
|
||||
@@ -68,7 +68,6 @@ let startScreenY: number | null = null;
|
||||
const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === tabModel.value));
|
||||
|
||||
const pullDistance = ref(0);
|
||||
const isSwiping = ref(false);
|
||||
const isSwipingForClass = ref(false);
|
||||
let swipeAborted = false;
|
||||
|
||||
@@ -77,6 +76,8 @@ function touchStart(event: TouchEvent) {
|
||||
|
||||
if (event.touches.length !== 1) return;
|
||||
|
||||
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
|
||||
|
||||
startScreenX = event.touches[0].screenX;
|
||||
startScreenY = event.touches[0].screenY;
|
||||
}
|
||||
@@ -90,6 +91,8 @@ function touchMove(event: TouchEvent) {
|
||||
|
||||
if (swipeAborted) return;
|
||||
|
||||
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
|
||||
|
||||
let distanceX = event.touches[0].screenX - startScreenX;
|
||||
let distanceY = event.touches[0].screenY - startScreenY;
|
||||
|
||||
@@ -139,6 +142,8 @@ function touchEnd(event: TouchEvent) {
|
||||
|
||||
if (!isSwiping.value) return;
|
||||
|
||||
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
|
||||
|
||||
const distance = event.changedTouches[0].screenX - startScreenX;
|
||||
|
||||
if (Math.abs(distance) > SWIPE_DISTANCE_THRESHOLD) {
|
||||
@@ -162,6 +167,24 @@ function touchEnd(event: TouchEvent) {
|
||||
}, 400);
|
||||
}
|
||||
|
||||
/** 横スワイプに関与する可能性のある要素を調べる */
|
||||
function hasSomethingToDoWithXSwipe(el: HTMLElement) {
|
||||
if (['INPUT', 'TEXTAREA'].includes(el.tagName)) return true;
|
||||
if (el.isContentEditable) return true;
|
||||
if (el.scrollWidth > el.clientWidth) return true;
|
||||
|
||||
const style = window.getComputedStyle(el);
|
||||
if (['absolute', 'fixed', 'sticky'].includes(style.position)) return true;
|
||||
if (['scroll', 'auto'].includes(style.overflowX)) return true;
|
||||
if (style.touchAction === 'pan-x') return true;
|
||||
|
||||
if (el.parentElement && el.parentElement !== rootEl.value) {
|
||||
return hasSomethingToDoWithXSwipe(el.parentElement);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const transitionName = ref<'swipeAnimationLeft' | 'swipeAnimationRight' | undefined>(undefined);
|
||||
|
||||
watch(tabModel, (newTab, oldTab) => {
|
||||
@@ -182,6 +205,7 @@ watch(tabModel, (newTab, oldTab) => {
|
||||
|
||||
<style lang="scss" module>
|
||||
.transitionRoot {
|
||||
touch-action: pan-y pinch-zoom;
|
||||
display: grid;
|
||||
grid-template-columns: 100%;
|
||||
overflow: clip;
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { userDetailed, inviteCode } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkInviteCode from './MkInviteCode.vue';
|
||||
@@ -39,8 +39,8 @@ export const Default = {
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.post('/api/users/show', (req, res, ctx) => {
|
||||
return res(ctx.json(userDetailed(req.params.userId as string)));
|
||||
http.post('/api/users/show', ({ params }) => {
|
||||
return HttpResponse.json(userDetailed(params.userId as string));
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
@@ -119,6 +119,7 @@ function close() {
|
||||
margin-top: 12px;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.5em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
> .indicatorWithValue {
|
||||
|
@@ -253,7 +253,7 @@ const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entiti
|
||||
const isMyRenote = $i && ($i.id === note.value.userId);
|
||||
const showContent = ref(false);
|
||||
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
|
||||
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value) : null);
|
||||
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null);
|
||||
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
|
||||
const collapsed = ref(appearNote.value.cw == null && isLong);
|
||||
const isDeleted = ref(false);
|
||||
@@ -385,7 +385,7 @@ function react(viaKeyboard = false): void {
|
||||
}
|
||||
} else {
|
||||
blur();
|
||||
reactionPicker.show(reactButton.value ?? null, reaction => {
|
||||
reactionPicker.show(reactButton.value ?? null, note.value, reaction => {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
||||
if (props.mock) {
|
||||
|
@@ -277,7 +277,7 @@ const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : fals
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
const translating = ref(false);
|
||||
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
|
||||
const urls = parsed ? extractUrlFromMfm(parsed) : null;
|
||||
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null;
|
||||
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance);
|
||||
const conversation = ref<Misskey.entities.Note[]>([]);
|
||||
const replies = ref<Misskey.entities.Note[]>([]);
|
||||
@@ -385,7 +385,7 @@ function react(viaKeyboard = false): void {
|
||||
}
|
||||
} else {
|
||||
blur();
|
||||
reactionPicker.show(reactButton.value ?? null, reaction => {
|
||||
reactionPicker.show(reactButton.value ?? null, note.value, reaction => {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
|
@@ -26,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { getScrollContainer } from '@/scripts/scroll.js';
|
||||
import { isHorizontalSwipeSwiping } from '@/scripts/touch.js';
|
||||
|
||||
const SCROLL_STOP = 10;
|
||||
const MAX_PULL_DISTANCE = Infinity;
|
||||
@@ -129,7 +130,7 @@ function moveEnd() {
|
||||
function moving(event: TouchEvent | PointerEvent) {
|
||||
if (!isPullStart.value || isRefreshing.value || disabled) return;
|
||||
|
||||
if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value)) {
|
||||
if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value) || isHorizontalSwipeSwiping.value) {
|
||||
pullDistance.value = 0;
|
||||
isPullEnd.value = false;
|
||||
moveEnd();
|
||||
@@ -148,6 +149,10 @@ function moving(event: TouchEvent | PointerEvent) {
|
||||
if (event.cancelable) event.preventDefault();
|
||||
}
|
||||
|
||||
if (pullDistance.value > SCROLL_STOP) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
isPullEnd.value = pullDistance.value >= FIRE_THRESHOLD;
|
||||
}
|
||||
|
||||
|
@@ -32,6 +32,8 @@ import { claimAchievement } from '@/scripts/achievements.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as sound from '@/scripts/sound.js';
|
||||
import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js';
|
||||
import { customEmojis } from '@/custom-emojis.js';
|
||||
|
||||
const props = defineProps<{
|
||||
reaction: string;
|
||||
@@ -48,13 +50,19 @@ const emit = defineEmits<{
|
||||
|
||||
const buttonEl = shallowRef<HTMLElement>();
|
||||
|
||||
const canToggle = computed(() => !props.reaction.match(/@\w/) && $i);
|
||||
const isCustomEmoji = computed(() => props.reaction.includes(':'));
|
||||
const emoji = computed(() => isCustomEmoji.value ? customEmojis.value.find(emoji => emoji.name === props.reaction.replace(/:/g, '').replace(/@\./, '')) : null);
|
||||
|
||||
const canToggle = computed(() => {
|
||||
return !props.reaction.match(/@\w/) && $i
|
||||
&& (emoji.value && checkReactionPermissions($i, props.note, emoji.value))
|
||||
|| !isCustomEmoji.value;
|
||||
});
|
||||
const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':'));
|
||||
|
||||
async function toggleReaction() {
|
||||
if (!canToggle.value) return;
|
||||
|
||||
// TODO: その絵文字を使う権限があるかどうか確認
|
||||
|
||||
const oldReaction = props.note.myReaction;
|
||||
if (oldReaction) {
|
||||
const confirm = await os.confirm({
|
||||
@@ -101,8 +109,8 @@ async function toggleReaction() {
|
||||
}
|
||||
|
||||
async function menu(ev) {
|
||||
if (!canToggle.value) return;
|
||||
if (!props.reaction.includes(':')) return;
|
||||
if (!canGetInfo.value) return;
|
||||
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.info,
|
||||
icon: 'ti ti-info-circle',
|
||||
|
@@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
<div :class="$style.caption"><slot name="caption"></slot></div>
|
||||
|
||||
<MkButton v-if="manualSave && changed" primary @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
|
||||
<MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -138,6 +138,7 @@ function show() {
|
||||
active: computed(() => v.value === option.props?.value),
|
||||
action: () => {
|
||||
v.value = option.props?.value;
|
||||
changed.value = true;
|
||||
emit('changeByUser', v.value);
|
||||
},
|
||||
});
|
||||
@@ -288,6 +289,10 @@ function show() {
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.save {
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
|
@@ -18,8 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch, onUnmounted, provide, ref, shallowRef } from 'vue';
|
||||
import Misskey from 'misskey-js';
|
||||
import { Connection } from 'misskey-js/built/streaming.js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkNotes from '@/components/MkNotes.vue';
|
||||
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
|
||||
import { useStream } from '@/stream.js';
|
||||
@@ -87,8 +86,8 @@ function prepend(note) {
|
||||
}
|
||||
}
|
||||
|
||||
let connection: Connection;
|
||||
let connection2: Connection;
|
||||
let connection: Misskey.ChannelConnection | null = null;
|
||||
let connection2: Misskey.ChannelConnection | null = null;
|
||||
let paginationQuery: Paging | null = null;
|
||||
|
||||
const stream = useStream();
|
||||
@@ -151,7 +150,7 @@ function connectChannel() {
|
||||
roleId: props.role,
|
||||
});
|
||||
}
|
||||
if (props.src !== 'directs' && props.src !== 'mentions') connection.on('note', prepend);
|
||||
if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend);
|
||||
}
|
||||
|
||||
function disconnectChannel() {
|
||||
|
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
v-if="player.url.startsWith('http://') || player.url.startsWith('https://')"
|
||||
sandbox="allow-popups allow-scripts allow-storage-access-by-user-activation allow-same-origin"
|
||||
scrolling="no"
|
||||
:allow="player.allow.join(';')"
|
||||
:allow="player.allow == null ? 'autoplay;encrypted-media;fullscreen' : player.allow.filter(x => ['autoplay', 'clipboard-write', 'fullscreen', 'encrypted-media', 'picture-in-picture', 'web-share'].includes(x)).join(';')"
|
||||
:class="$style.playerIframe"
|
||||
:src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')"
|
||||
:style="{ border: 0 }"
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import { userDetailed } from '../../.storybook/fakes.js';
|
||||
import MkUserSetupDialog_Follow from './MkUserSetupDialog.Follow.vue';
|
||||
@@ -38,17 +38,17 @@ export const Default = {
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.post('/api/users', (req, res, ctx) => {
|
||||
return res(ctx.json([
|
||||
http.post('/api/users', () => {
|
||||
return HttpResponse.json([
|
||||
userDetailed('44'),
|
||||
userDetailed('49'),
|
||||
]));
|
||||
]);
|
||||
}),
|
||||
rest.post('/api/pinned-users', (req, res, ctx) => {
|
||||
return res(ctx.json([
|
||||
http.post('/api/pinned-users', () => {
|
||||
return HttpResponse.json([
|
||||
userDetailed('44'),
|
||||
userDetailed('49'),
|
||||
]));
|
||||
]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Misskey from 'misskey-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import XUser from '@/components/MkUserSetupDialog.User.vue';
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import { userDetailed } from '../../.storybook/fakes.js';
|
||||
import MkUserSetupDialog from './MkUserSetupDialog.vue';
|
||||
@@ -38,17 +38,17 @@ export const Default = {
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.post('/api/users', (req, res, ctx) => {
|
||||
return res(ctx.json([
|
||||
http.post('/api/users', () => {
|
||||
return HttpResponse.json([
|
||||
userDetailed('44'),
|
||||
userDetailed('49'),
|
||||
]));
|
||||
]);
|
||||
}),
|
||||
rest.post('/api/pinned-users', (req, res, ctx) => {
|
||||
return res(ctx.json([
|
||||
http.post('/api/pinned-users', () => {
|
||||
return HttpResponse.json([
|
||||
userDetailed('44'),
|
||||
userDetailed('49'),
|
||||
]));
|
||||
]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
@@ -7,7 +7,7 @@
|
||||
import { expect } from '@storybook/jest';
|
||||
import { userEvent, waitFor, within } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { commonHandlers } from '../../../.storybook/mocks.js';
|
||||
import MkUrl from './MkUrl.vue';
|
||||
export const Default = {
|
||||
@@ -59,8 +59,8 @@ export const Default = {
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.get('/url', (req, res, ctx) => {
|
||||
return res(ctx.json({
|
||||
http.get('/url', () => {
|
||||
return HttpResponse.json({
|
||||
title: 'Misskey Hub',
|
||||
icon: 'https://misskey-hub.net/favicon.ico',
|
||||
description: 'Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。',
|
||||
@@ -74,7 +74,7 @@ export const Default = {
|
||||
sitename: 'misskey-hub.net',
|
||||
sensitive: false,
|
||||
url: 'https://misskey-hub.net/',
|
||||
}));
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
@@ -4,7 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<KeepAlive :max="defaultStore.state.numberOfPageCache">
|
||||
<KeepAlive
|
||||
:max="defaultStore.state.numberOfPageCache"
|
||||
:exclude="pageCacheController"
|
||||
>
|
||||
<Suspense :timeout="0">
|
||||
<component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/>
|
||||
|
||||
@@ -16,9 +19,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { inject, onBeforeUnmount, provide, ref, shallowRef } from 'vue';
|
||||
import { IRouter, Resolved } from '@/nirax.js';
|
||||
import { inject, onBeforeUnmount, provide, ref, shallowRef, computed, nextTick } from 'vue';
|
||||
import { IRouter, Resolved, RouteDef } from '@/nirax.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import MkLoadingPage from '@/pages/_loading_.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
router?: IRouter;
|
||||
@@ -46,20 +51,47 @@ function resolveNested(current: Resolved, d = 0): Resolved | null {
|
||||
}
|
||||
|
||||
const current = resolveNested(router.current)!;
|
||||
const currentPageComponent = shallowRef(current.route.component);
|
||||
const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage);
|
||||
const currentPageProps = ref(current.props);
|
||||
const key = ref(current.route.path + JSON.stringify(Object.fromEntries(current.props)));
|
||||
|
||||
function onChange({ resolved, key: newKey }) {
|
||||
const current = resolveNested(resolved);
|
||||
if (current == null) return;
|
||||
if (current == null || 'redirect' in current.route) return;
|
||||
currentPageComponent.value = current.route.component;
|
||||
currentPageProps.value = current.props;
|
||||
key.value = current.route.path + JSON.stringify(Object.fromEntries(current.props));
|
||||
|
||||
nextTick(() => {
|
||||
// ページ遷移完了後に再びキャッシュを有効化
|
||||
if (clearCacheRequested.value) {
|
||||
clearCacheRequested.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
router.addListener('change', onChange);
|
||||
|
||||
// #region キャッシュ制御
|
||||
|
||||
/**
|
||||
* キャッシュクリアが有効になったら、全キャッシュをクリアする
|
||||
*
|
||||
* keepAlive側にwatcherがあるのですぐ消えるとはおもうけど、念のためページ遷移完了まではキャッシュを無効化しておく。
|
||||
* キャッシュ有効時向けにexcludeを使いたい場合は、pageCacheControllerに並列に突っ込むのではなく、下に追記すること
|
||||
*/
|
||||
const pageCacheController = computed(() => clearCacheRequested.value ? /.*/ : undefined);
|
||||
const clearCacheRequested = ref(false);
|
||||
|
||||
globalEvents.on('requestClearPageCache', () => {
|
||||
if (_DEV_) console.log('clear page cache requested');
|
||||
if (!clearCacheRequested.value) {
|
||||
clearCacheRequested.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
router.removeListener('change', onChange);
|
||||
});
|
||||
|
@@ -4,6 +4,10 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
||||
// TODO: 型付け
|
||||
export const globalEvents = new EventEmitter();
|
||||
export const globalEvents = new EventEmitter<{
|
||||
themeChanged: () => void;
|
||||
clientNotification: (notification: Misskey.entities.Notification) => void;
|
||||
requestClearPageCache: () => void;
|
||||
}>();
|
||||
|
@@ -213,13 +213,13 @@ const patronsWithIcon = [{
|
||||
icon: 'https://assets.misskey-hub.net/patrons/302dce2898dd457ba03c3f7dc037900b.jpg',
|
||||
}, {
|
||||
name: 'taichan',
|
||||
icon: 'https://assets.misskey-hub.net/patrons/f981ab0159fb4e2c998e05f7263e1cd9.png',
|
||||
icon: 'https://assets.misskey-hub.net/patrons/f981ab0159fb4e2c998e05f7263e1cd9.jpg',
|
||||
}, {
|
||||
name: '猫吉よりお',
|
||||
icon: 'https://assets.misskey-hub.net/patrons/a11518b3b34b4536a4bdd7178ba76a7b.png',
|
||||
icon: 'https://assets.misskey-hub.net/patrons/a11518b3b34b4536a4bdd7178ba76a7b.jpg',
|
||||
}, {
|
||||
name: '有栖かずみ',
|
||||
icon: 'https://assets.misskey-hub.net/patrons/9240e8e0ba294a8884143e99ac7ed6a0.png',
|
||||
icon: 'https://assets.misskey-hub.net/patrons/9240e8e0ba294a8884143e99ac7ed6a0.jpg',
|
||||
}];
|
||||
|
||||
const patrons = [
|
||||
|
@@ -148,9 +148,9 @@ function save() {
|
||||
themeColor: themeColor.value === '' ? null : themeColor.value,
|
||||
defaultLightTheme: defaultLightTheme.value === '' ? null : defaultLightTheme.value,
|
||||
defaultDarkTheme: defaultDarkTheme.value === '' ? null : defaultDarkTheme.value,
|
||||
infoImageUrl: infoImageUrl.value,
|
||||
notFoundImageUrl: notFoundImageUrl.value,
|
||||
serverErrorImageUrl: serverErrorImageUrl.value,
|
||||
infoImageUrl: infoImageUrl.value === '' ? null : infoImageUrl.value,
|
||||
notFoundImageUrl: notFoundImageUrl.value === '' ? null : notFoundImageUrl.value,
|
||||
serverErrorImageUrl: serverErrorImageUrl.value === '' ? null : serverErrorImageUrl.value,
|
||||
manifestJsonOverride: manifestJsonOverride.value === '' ? '{}' : JSON.stringify(JSON5.parse(manifestJsonOverride.value)),
|
||||
}).then(() => {
|
||||
fetchInstance();
|
||||
|
@@ -893,7 +893,6 @@ function getGameImageDriveFile() {
|
||||
formData.append('file', blob);
|
||||
formData.append('name', `bubble-game-${Date.now()}.png`);
|
||||
formData.append('isSensitive', 'false');
|
||||
formData.append('comment', 'null');
|
||||
formData.append('i', $i.token);
|
||||
if (defaultStore.state.uploadFolder) {
|
||||
formData.append('folderId', defaultStore.state.uploadFolder);
|
||||
|
@@ -157,7 +157,7 @@ const chooseEmoji = (ev: MouseEvent) => pickEmoji(pinnedEmojis, ev);
|
||||
const setDefaultEmoji = () => setDefault(pinnedEmojis);
|
||||
|
||||
function previewReaction(ev: MouseEvent) {
|
||||
reactionPicker.show(getHTMLElement(ev));
|
||||
reactionPicker.show(getHTMLElement(ev), null);
|
||||
}
|
||||
|
||||
function previewEmoji(ev: MouseEvent) {
|
||||
|
@@ -125,6 +125,7 @@ import { langmap } from '@/scripts/langmap.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { claimAchievement } from '@/scripts/achievements.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
|
||||
@@ -173,6 +174,7 @@ function saveFields() {
|
||||
os.apiWithDialog('i/update', {
|
||||
fields: fields.value.filter(field => field.name !== '' && field.value !== '').map(field => ({ name: field.name, value: field.value })),
|
||||
});
|
||||
globalEvents.emit('requestClearPageCache');
|
||||
}
|
||||
|
||||
function save() {
|
||||
@@ -191,6 +193,7 @@ function save() {
|
||||
isBot: !!profile.isBot,
|
||||
isCat: !!profile.isCat,
|
||||
});
|
||||
globalEvents.emit('requestClearPageCache');
|
||||
claimAchievement('profileFilled');
|
||||
if (profile.name === 'syuilo' || profile.name === 'しゅいろ') {
|
||||
claimAchievement('setNameToSyuilo');
|
||||
@@ -222,6 +225,7 @@ function changeAvatar(ev) {
|
||||
});
|
||||
$i.avatarId = i.avatarId;
|
||||
$i.avatarUrl = i.avatarUrl;
|
||||
globalEvents.emit('requestClearPageCache');
|
||||
claimAchievement('profileFilled');
|
||||
});
|
||||
}
|
||||
@@ -248,6 +252,7 @@ function changeBanner(ev) {
|
||||
});
|
||||
$i.bannerId = i.bannerId;
|
||||
$i.bannerUrl = i.bannerUrl;
|
||||
globalEvents.emit('requestClearPageCache');
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -88,6 +88,18 @@ import { uniqueBy } from '@/scripts/array.js';
|
||||
import { fetchThemes, getThemes } from '@/theme-store.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { unisonReload } from '@/scripts/unison-reload.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
async function reloadAsk() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'info',
|
||||
text: i18n.ts.reloadToApplySetting,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
unisonReload();
|
||||
}
|
||||
|
||||
const installedThemes = ref(getThemes());
|
||||
const builtinThemes = getBuiltinThemesRef();
|
||||
@@ -124,6 +136,7 @@ const lightThemeId = computed({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const darkMode = computed(defaultStore.makeGetterSetter('darkMode'));
|
||||
const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode'));
|
||||
const wallpaper = ref(miLocalStorage.getItem('wallpaper'));
|
||||
@@ -141,7 +154,7 @@ watch(wallpaper, () => {
|
||||
} else {
|
||||
miLocalStorage.setItem('wallpaper', wallpaper.value);
|
||||
}
|
||||
location.reload();
|
||||
reloadAsk();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { userDetailed } from '../../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../../.storybook/mocks.js';
|
||||
import home_ from './home.vue';
|
||||
@@ -39,12 +39,13 @@ export const Default = {
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.post('/api/users/notes', (req, res, ctx) => {
|
||||
return res(ctx.json([]));
|
||||
http.post('/api/users/notes', () => {
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
rest.get('/api/charts/user/notes', (req, res, ctx) => {
|
||||
const length = Math.max(Math.min(parseInt(req.url.searchParams.get('limit') ?? '30', 10), 1), 300);
|
||||
return res(ctx.json({
|
||||
http.get('/api/charts/user/notes', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const length = Math.max(Math.min(parseInt(url.searchParams.get('limit') ?? '30', 10), 1), 300);
|
||||
return HttpResponse.json({
|
||||
total: Array.from({ length }, () => 0),
|
||||
inc: Array.from({ length }, () => 0),
|
||||
dec: Array.from({ length }, () => 0),
|
||||
@@ -54,11 +55,12 @@ export const Default = {
|
||||
renote: Array.from({ length }, () => 0),
|
||||
withFile: Array.from({ length }, () => 0),
|
||||
},
|
||||
}));
|
||||
});
|
||||
}),
|
||||
rest.get('/api/charts/user/pv', (req, res, ctx) => {
|
||||
const length = Math.max(Math.min(parseInt(req.url.searchParams.get('limit') ?? '30', 10), 1), 300);
|
||||
return res(ctx.json({
|
||||
http.get('/api/charts/user/pv', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const length = Math.max(Math.min(parseInt(url.searchParams.get('limit') ?? '30', 10), 1), 300);
|
||||
return HttpResponse.json({
|
||||
upv: {
|
||||
user: Array.from({ length }, () => 0),
|
||||
visitor: Array.from({ length }, () => 0),
|
||||
@@ -67,7 +69,7 @@ export const Default = {
|
||||
user: Array.from({ length }, () => 0),
|
||||
visitor: Array.from({ length }, () => 0),
|
||||
},
|
||||
}));
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
@@ -13,6 +13,7 @@ import { get, set } from '@/scripts/idb-proxy.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { useStream } from '@/stream.js';
|
||||
import { deepClone } from '@/scripts/clone.js';
|
||||
import { deepMerge } from '@/scripts/merge.js';
|
||||
|
||||
type StateDef = Record<string, {
|
||||
where: 'account' | 'device' | 'deviceAccount';
|
||||
@@ -84,29 +85,9 @@ export class Storage<T extends StateDef> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* valueにないキーをdefからもらう(再帰的)\
|
||||
* nullはそのまま、undefinedはdefの値
|
||||
**/
|
||||
private mergeObject<X>(value: X, def: X): X {
|
||||
if (this.isPureObject(value) && this.isPureObject(def)) {
|
||||
const result = structuredClone(value) as X;
|
||||
for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) {
|
||||
if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) {
|
||||
result[k] = v;
|
||||
} else if (this.isPureObject(v) && this.isPureObject(result[k])) {
|
||||
const child = structuredClone(result[k]) as X[keyof X] & Record<string | number | symbol, unknown>;
|
||||
result[k] = this.mergeObject<typeof v>(child, v);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private mergeState<X>(value: X, def: X): X {
|
||||
if (this.isPureObject(value) && this.isPureObject(def)) {
|
||||
const merged = this.mergeObject(value, def);
|
||||
const merged = deepMerge(value, def);
|
||||
|
||||
if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged);
|
||||
|
||||
@@ -258,7 +239,7 @@ export class Storage<T extends StateDef> {
|
||||
|
||||
/**
|
||||
* 特定のキーの、簡易的なgetter/setterを作ります
|
||||
* 主にvue場で設定コントロールのmodelとして使う用
|
||||
* 主にvue上で設定コントロールのmodelとして使う用
|
||||
*/
|
||||
public makeGetterSetter<K extends keyof T>(key: K, getter?: (v: T[K]) => unknown, setter?: (v: unknown) => T[K]): {
|
||||
get: () => T[K]['default'];
|
||||
|
@@ -80,6 +80,10 @@ class MainRouterProxy implements IRouter {
|
||||
return this.supplier().resolve(path);
|
||||
}
|
||||
|
||||
init(): void {
|
||||
this.supplier().init();
|
||||
}
|
||||
|
||||
eventNames(): Array<EventEmitter.EventNames<RouterEvent>> {
|
||||
return this.supplier().eventNames();
|
||||
}
|
||||
|
@@ -0,0 +1,8 @@
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
||||
export function checkReactionPermissions(me: Misskey.entities.MeDetailed, note: Misskey.entities.Note, emoji: Misskey.entities.EmojiSimple): boolean {
|
||||
const roleIdsThatCanBeUsedThisEmojiAsReaction = emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [];
|
||||
return !(emoji.localOnly && note.user.host !== me.host)
|
||||
&& !(emoji.isSensitive && (note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote'))
|
||||
&& (roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || me.roles.some(role => roleIdsThatCanBeUsedThisEmojiAsReaction.includes(role.id)));
|
||||
}
|
@@ -8,13 +8,13 @@
|
||||
// あと、Vue RefをIndexedDBに保存しようとしてstructredCloneを使ったらエラーになった
|
||||
// https://github.com/misskey-dev/misskey/pull/8098#issuecomment-1114144045
|
||||
|
||||
type Cloneable = string | number | boolean | null | undefined | { [key: string]: Cloneable } | Cloneable[];
|
||||
export type Cloneable = string | number | boolean | null | undefined | { [key: string]: Cloneable } | { [key: number]: Cloneable } | { [key: symbol]: Cloneable } | Cloneable[];
|
||||
|
||||
export function deepClone<T extends Cloneable>(x: T): T {
|
||||
if (typeof x === 'object') {
|
||||
if (x === null) return x;
|
||||
if (Array.isArray(x)) return x.map(deepClone) as T;
|
||||
const obj = {} as Record<string, Cloneable>;
|
||||
const obj = {} as Record<string | number | symbol, Cloneable>;
|
||||
for (const [k, v] of Object.entries(x)) {
|
||||
obj[k] = v === undefined ? undefined : deepClone(v);
|
||||
}
|
||||
|
@@ -1,9 +1,51 @@
|
||||
import { bundledThemesInfo } from 'shiki';
|
||||
import { getHighlighterCore, loadWasm } from 'shiki/core';
|
||||
import darkPlus from 'shiki/themes/dark-plus.mjs';
|
||||
import type { Highlighter, LanguageRegistration } from 'shiki';
|
||||
import { unique } from './array.js';
|
||||
import { deepClone } from './clone.js';
|
||||
import { deepMerge } from './merge.js';
|
||||
import type { Highlighter, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki';
|
||||
import { ColdDeviceStorage } from '@/store.js';
|
||||
import lightTheme from '@/themes/_light.json5';
|
||||
import darkTheme from '@/themes/_dark.json5';
|
||||
|
||||
let _highlighter: Highlighter | null = null;
|
||||
|
||||
export async function getTheme(mode: 'light' | 'dark', getName: true): Promise<string>;
|
||||
export async function getTheme(mode: 'light' | 'dark', getName?: false): Promise<ThemeRegistration | ThemeRegistrationRaw>;
|
||||
export async function getTheme(mode: 'light' | 'dark', getName = false): Promise<ThemeRegistration | ThemeRegistrationRaw | string | null> {
|
||||
const theme = deepClone(ColdDeviceStorage.get(mode === 'light' ? 'lightTheme' : 'darkTheme'));
|
||||
|
||||
if (theme.base) {
|
||||
const base = [lightTheme, darkTheme].find(x => x.id === theme.base);
|
||||
if (base && base.codeHighlighter) theme.codeHighlighter = Object.assign({}, base.codeHighlighter, theme.codeHighlighter);
|
||||
}
|
||||
|
||||
if (theme.codeHighlighter) {
|
||||
let _res: ThemeRegistration = {};
|
||||
if (theme.codeHighlighter.base === '_none_') {
|
||||
_res = deepClone(theme.codeHighlighter.overrides);
|
||||
} else {
|
||||
const base = await bundledThemesInfo.find(t => t.id === theme.codeHighlighter!.base)?.import() ?? darkPlus;
|
||||
_res = deepMerge(theme.codeHighlighter.overrides ?? {}, 'default' in base ? base.default : base);
|
||||
}
|
||||
if (_res.name == null) {
|
||||
_res.name = theme.id;
|
||||
}
|
||||
_res.type = mode;
|
||||
|
||||
if (getName) {
|
||||
return _res.name;
|
||||
}
|
||||
return _res;
|
||||
}
|
||||
|
||||
if (getName) {
|
||||
return 'dark-plus';
|
||||
}
|
||||
return darkPlus;
|
||||
}
|
||||
|
||||
export async function getHighlighter(): Promise<Highlighter> {
|
||||
if (!_highlighter) {
|
||||
return await initHighlighter();
|
||||
@@ -13,11 +55,17 @@ export async function getHighlighter(): Promise<Highlighter> {
|
||||
|
||||
export async function initHighlighter() {
|
||||
const aiScriptGrammar = await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json');
|
||||
|
||||
|
||||
await loadWasm(import('shiki/onig.wasm?init'));
|
||||
|
||||
// テーマの重複を消す
|
||||
const themes = unique([
|
||||
darkPlus,
|
||||
...(await Promise.all([getTheme('light'), getTheme('dark')])),
|
||||
]);
|
||||
|
||||
const highlighter = await getHighlighterCore({
|
||||
themes: [darkPlus],
|
||||
themes,
|
||||
langs: [
|
||||
import('shiki/langs/javascript.mjs'),
|
||||
{
|
||||
@@ -27,6 +75,20 @@ export async function initHighlighter() {
|
||||
],
|
||||
});
|
||||
|
||||
ColdDeviceStorage.watch('lightTheme', async () => {
|
||||
const newTheme = await getTheme('light');
|
||||
if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) {
|
||||
highlighter.loadTheme(newTheme);
|
||||
}
|
||||
});
|
||||
|
||||
ColdDeviceStorage.watch('darkTheme', async () => {
|
||||
const newTheme = await getTheme('dark');
|
||||
if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) {
|
||||
highlighter.loadTheme(newTheme);
|
||||
}
|
||||
});
|
||||
|
||||
_highlighter = highlighter;
|
||||
|
||||
return highlighter;
|
||||
|
31
packages/frontend/src/scripts/merge.ts
Normal file
31
packages/frontend/src/scripts/merge.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { deepClone } from './clone.js';
|
||||
import type { Cloneable } from './clone.js';
|
||||
|
||||
function isPureObject(value: unknown): value is Record<string | number | symbol, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* valueにないキーをdefからもらう(再帰的)\
|
||||
* nullはそのまま、undefinedはdefの値
|
||||
**/
|
||||
export function deepMerge<X extends Record<string | number | symbol, unknown>>(value: X, def: X): X {
|
||||
if (isPureObject(value) && isPureObject(def)) {
|
||||
const result = deepClone(value as Cloneable) as X;
|
||||
for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) {
|
||||
if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) {
|
||||
result[k] = v;
|
||||
} else if (isPureObject(v) && isPureObject(result[k])) {
|
||||
const child = deepClone(result[k] as Cloneable) as X[keyof X] & Record<string | number | symbol, unknown>;
|
||||
result[k] = deepMerge<typeof v>(child, v);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
@@ -3,6 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { defineAsyncComponent, Ref, ref } from 'vue';
|
||||
import { popup } from '@/os.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
@@ -10,6 +11,7 @@ import { defaultStore } from '@/store.js';
|
||||
class ReactionPicker {
|
||||
private src: Ref<HTMLElement | null> = ref(null);
|
||||
private manualShowing = ref(false);
|
||||
private targetNote: Ref<Misskey.entities.Note | null> = ref(null);
|
||||
private onChosen?: (reaction: string) => void;
|
||||
private onClosed?: () => void;
|
||||
|
||||
@@ -23,6 +25,7 @@ class ReactionPicker {
|
||||
src: this.src,
|
||||
pinnedEmojis: reactionsRef,
|
||||
asReactionPicker: true,
|
||||
targetNote: this.targetNote,
|
||||
manualShowing: this.manualShowing,
|
||||
}, {
|
||||
done: reaction => {
|
||||
@@ -38,8 +41,9 @@ class ReactionPicker {
|
||||
});
|
||||
}
|
||||
|
||||
public show(src: HTMLElement | null, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) {
|
||||
public show(src: HTMLElement | null, targetNote: Misskey.entities.Note | null, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) {
|
||||
this.src.value = src;
|
||||
this.targetNote.value = targetNote;
|
||||
this.manualShowing.value = true;
|
||||
this.onChosen = onChosen;
|
||||
this.onClosed = onClosed;
|
||||
|
@@ -6,6 +6,7 @@
|
||||
import { ref } from 'vue';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { deepClone } from './clone.js';
|
||||
import type { BuiltinTheme } from 'shiki';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import lightTheme from '@/themes/_light.json5';
|
||||
import darkTheme from '@/themes/_dark.json5';
|
||||
@@ -18,6 +19,13 @@ export type Theme = {
|
||||
desc?: string;
|
||||
base?: 'dark' | 'light';
|
||||
props: Record<string, string>;
|
||||
codeHighlighter?: {
|
||||
base: BuiltinTheme;
|
||||
overrides?: Record<string, any>;
|
||||
} | {
|
||||
base: '_none_';
|
||||
overrides: Record<string, any>;
|
||||
};
|
||||
};
|
||||
|
||||
export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
|
||||
@@ -53,7 +61,7 @@ export const getBuiltinThemesRef = () => {
|
||||
return builtinThemes;
|
||||
};
|
||||
|
||||
let timeout = null;
|
||||
let timeout: number | null = null;
|
||||
|
||||
export function applyTheme(theme: Theme, persist = true) {
|
||||
if (timeout) window.clearTimeout(timeout);
|
||||
|
@@ -3,6 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { deviceKind } from '@/scripts/device-kind.js';
|
||||
|
||||
const isTouchSupported = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0;
|
||||
@@ -16,3 +17,6 @@ if (isTouchSupported && !isTouchUsing) {
|
||||
isTouchUsing = true;
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/** (MkHorizontalSwipe) 横スワイプ中か? */
|
||||
export const isHorizontalSwipeSwiping = ref(false);
|
||||
|
@@ -7,6 +7,7 @@ import { markRaw, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { miLocalStorage } from './local-storage.js';
|
||||
import type { SoundType } from '@/scripts/sound.js';
|
||||
import type { BuiltinTheme as ShikiBuiltinTheme } from 'shiki';
|
||||
import { Storage } from '@/pizzax.js';
|
||||
import { hemisphere } from '@/scripts/intl-const.js';
|
||||
|
||||
|
@@ -94,4 +94,8 @@
|
||||
X16: ':alpha<0.7<@panel',
|
||||
X17: ':alpha<0.8<@bg',
|
||||
},
|
||||
|
||||
codeHighlighter: {
|
||||
base: 'one-dark-pro',
|
||||
},
|
||||
}
|
||||
|
@@ -94,4 +94,8 @@
|
||||
X16: ':alpha<0.7<@panel',
|
||||
X17: ':alpha<0.8<@bg',
|
||||
},
|
||||
|
||||
codeHighlighter: {
|
||||
base: 'catppuccin-latte',
|
||||
},
|
||||
}
|
||||
|
@@ -116,6 +116,34 @@ describe('MkUrlPreview', () => {
|
||||
assert.strictEqual(iframe?.allow, 'fullscreen;web-share');
|
||||
});
|
||||
|
||||
test('A Summaly proxy response without allow falls back to the default', async () => {
|
||||
const iframe = await renderAndOpenPreview({
|
||||
url: 'https://example.local',
|
||||
player: {
|
||||
url: 'https://example.local/player',
|
||||
width: null,
|
||||
height: null,
|
||||
allow: undefined as any,
|
||||
},
|
||||
});
|
||||
assert.exists(iframe, 'iframe should exist');
|
||||
assert.strictEqual(iframe?.allow, 'autoplay;encrypted-media;fullscreen');
|
||||
});
|
||||
|
||||
test('Filtering the allow list from the Summaly proxy', async () => {
|
||||
const iframe = await renderAndOpenPreview({
|
||||
url: 'https://example.local',
|
||||
player: {
|
||||
url: 'https://example.local/player',
|
||||
width: null,
|
||||
height: null,
|
||||
allow: ['autoplay', 'camera', 'fullscreen'],
|
||||
},
|
||||
});
|
||||
assert.exists(iframe, 'iframe should exist');
|
||||
assert.strictEqual(iframe?.allow, 'autoplay;fullscreen');
|
||||
});
|
||||
|
||||
test('Having a player width should keep the fixed aspect ratio', async () => {
|
||||
const iframe = await renderAndOpenPreview({
|
||||
url: 'https://example.local',
|
||||
|
Reference in New Issue
Block a user