Merge remote-tracking branch 'misskey-original/develop' into develop

# Conflicts:
#	package.json
#	packages/frontend/src/components/MkEmojiEditDialog.vue
#	packages/frontend/src/components/MkMenu.vue
#	packages/frontend/src/components/MkNote.vue
#	packages/frontend/src/pages/timeline.vue
This commit is contained in:
mattyatea 2024-01-23 12:38:56 +09:00
commit 78a34d3de3
115 changed files with 4837 additions and 2932 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Before After
Before After

Binary file not shown.

Binary file not shown.

View file

@ -39,11 +39,11 @@
"chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1",
"chromatic": "10.1.0",
"chromatic": "10.3.1",
"compare-versions": "6.1.0",
"crc-32": "^1.2.2",
"cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0",
"defu": "^6.1.4",
"escape-regexp": "0.0.1",
"estree-walker": "3.0.3",
"eventemitter3": "5.0.1",
@ -53,18 +53,18 @@
"json5": "2.2.3",
"matter-js": "0.19.0",
"mfm-js": "0.24.0",
"misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"misskey-bubble-game": "workspace:*",
"photoswipe": "5.4.3",
"punycode": "2.3.1",
"rollup": "4.9.1",
"rollup": "4.9.6",
"sanitize-html": "2.11.0",
"sass": "1.69.5",
"sass": "1.70.0",
"shiki": "0.14.7",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
"three": "0.159.0",
"three": "0.160.0",
"throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.8",
@ -72,70 +72,70 @@
"typescript": "5.3.3",
"uuid": "9.0.1",
"v-code-diff": "1.7.2",
"vite": "5.0.10",
"vue": "3.4.3",
"vite": "5.0.12",
"vue": "3.4.15",
"vuedraggable": "next"
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "^1.0.0",
"@misskey-dev/summaly": "^5.0.3",
"@storybook/addon-actions": "7.6.5",
"@storybook/addon-essentials": "7.6.5",
"@storybook/addon-interactions": "7.6.5",
"@storybook/addon-links": "7.6.5",
"@storybook/addon-storysource": "7.6.5",
"@storybook/addons": "7.6.5",
"@storybook/blocks": "7.6.5",
"@storybook/core-events": "7.6.5",
"@storybook/addon-actions": "7.6.10",
"@storybook/addon-essentials": "7.6.10",
"@storybook/addon-interactions": "7.6.10",
"@storybook/addon-links": "7.6.10",
"@storybook/addon-storysource": "7.6.10",
"@storybook/addons": "7.6.10",
"@storybook/blocks": "7.6.10",
"@storybook/core-events": "7.6.10",
"@storybook/jest": "0.2.3",
"@storybook/manager-api": "7.6.5",
"@storybook/preview-api": "7.6.5",
"@storybook/react": "7.6.5",
"@storybook/react-vite": "7.6.5",
"@storybook/manager-api": "7.6.10",
"@storybook/preview-api": "7.6.10",
"@storybook/react": "7.6.10",
"@storybook/react-vite": "7.6.10",
"@storybook/testing-library": "0.2.2",
"@storybook/theming": "7.6.5",
"@storybook/types": "7.6.5",
"@storybook/vue3": "7.6.5",
"@storybook/vue3-vite": "7.6.5",
"@storybook/theming": "7.6.10",
"@storybook/types": "7.6.10",
"@storybook/vue3": "7.6.10",
"@storybook/vue3-vite": "7.6.10",
"@testing-library/vue": "8.0.1",
"@types/escape-regexp": "0.0.3",
"@types/estree": "1.0.5",
"@types/matter-js": "0.19.5",
"@types/matter-js": "0.19.6",
"@types/micromatch": "4.0.6",
"@types/node": "20.10.5",
"@types/node": "20.11.5",
"@types/punycode": "2.1.3",
"@types/sanitize-html": "2.9.5",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/uuid": "9.0.7",
"@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "6.14.0",
"@typescript-eslint/parser": "6.14.0",
"@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1",
"@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.4.3",
"acorn": "8.11.2",
"@vue/runtime-core": "3.4.15",
"acorn": "8.11.3",
"cross-env": "7.0.3",
"cypress": "13.6.1",
"cypress": "13.6.3",
"eslint": "8.56.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-vue": "9.19.2",
"eslint-plugin-vue": "9.20.1",
"fast-glob": "3.3.2",
"happy-dom": "10.0.3",
"intersection-observer": "0.12.2",
"micromatch": "4.0.5",
"msw": "1.3.2",
"msw": "2.1.2",
"msw-storybook-addon": "1.10.0",
"nodemon": "3.0.2",
"prettier": "3.1.1",
"nodemon": "3.0.3",
"prettier": "3.2.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"start-server-and-test": "2.0.3",
"storybook": "7.6.5",
"storybook": "7.6.10",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "0.34.6",
"vitest-fetch-mock": "0.2.2",
"vue-eslint-parser": "9.3.2",
"vue-eslint-parser": "9.4.0",
"vue-tsc": "1.8.27"
}
}

View file

@ -77,9 +77,18 @@ export async function mainBoot() {
if (defaultStore.state.enableSeasonalScreenEffect) {
const month = new Date().getMonth() + 1;
if (month === 12 || month === 1) {
const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
new SnowfallEffect().render();
if (defaultStore.state.hemisphere === 'S') {
// ▼南半球
if (month === 7 || month === 8) {
const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
new SnowfallEffect().render();
}
} else {
// ▼北半球
if (month === 12 || month === 1) {
const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
new SnowfallEffect().render();
}
}
}

View file

@ -4,10 +4,12 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="400"
@close="dialog.close()"
<MkWindow
ref="windowEl"
:initialWidth="400"
:initialHeight="500"
:canResize="false"
@close="windowEl.close()"
@closed="$emit('closed')"
>
<template v-if="emoji" #header>:{{ emoji.name }}:</template>
@ -81,14 +83,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
</MkModalWindow>
</MkWindow>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { DriveFile } from 'misskey-js/built/entities.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkWindow from '@/components/MkWindow.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkInfo from '@/components/MkInfo.vue';
@ -106,18 +108,18 @@ const props = defineProps<{
isRequest: boolean,
}>();
let dialog = ref<InstanceType<typeof MkModalWindow> | null>(null);
let name = ref(props.emoji ? props.emoji.name : '');
let category = ref(props.emoji ? props.emoji.category : '');
let aliases = ref(props.emoji ? props.emoji.aliases.join(' ') : '');
let license = ref(props.emoji ? (props.emoji.license ?? '') : '');
let isSensitive = ref(props.emoji ? props.emoji.isSensitive : false);
let localOnly = ref(props.emoji ? props.emoji.localOnly : false);
let roleIdsThatCanBeUsedThisEmojiAsReaction = ref((props.emoji && props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction) ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
let rolesThatCanBeUsedThisEmojiAsReaction = ref<Misskey.entities.Role[]>([]);
let file = ref<Misskey.entities.DriveFile>();
const windowEl = ref<InstanceType<typeof MkWindow> | null>(null);
const name = ref<string>(props.emoji ? props.emoji.name : '');
const category = ref<string>(props.emoji ? props.emoji.category : '');
const aliases = ref<string>(props.emoji ? props.emoji.aliases.join(' ') : '');
const license = ref<string>(props.emoji ? (props.emoji.license ?? '') : '');
const isSensitive = ref(props.emoji ? props.emoji.isSensitive : false);
const localOnly = ref(props.emoji ? props.emoji.localOnly : false);
const roleIdsThatCanBeUsedThisEmojiAsReaction = ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
const rolesThatCanBeUsedThisEmojiAsReaction = ref<Misskey.entities.Role[]>([]);
const file = ref<Misskey.entities.DriveFile>();
let isRequest = ref(props.isRequest ?? false);
watch((roleIdsThatCanBeUsedThisEmojiAsReaction), async () => {
watch(roleIdsThatCanBeUsedThisEmojiAsReaction, async () => {
rolesThatCanBeUsedThisEmojiAsReaction.value = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.value.map((id) => misskeyApi('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
}, { immediate: true });
const isNotifyIsHome = ref(props.emoji ? props.emoji.isNotifyIsHome : false);
@ -190,7 +192,7 @@ async function done() {
},
});
dialog.value.close();
windowEl.value.close();
} else {
const created = isRequest.value
? await os.apiWithDialog('admin/emoji/add-request', params)
@ -200,7 +202,7 @@ async function done() {
created: created,
});
dialog.value.close();
windowEl.value.close();
}
}
@ -217,7 +219,7 @@ async function del() {
emit('done', {
deleted: true,
});
dialog.value.close();
windowEl.value.close();
});
}
</script>

View file

@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
ref="rootEl"
:class="[$style.transitionRoot, (defaultStore.state.animation && $style.enableAnimation)]"
@touchstart="touchStart"
@touchmove="touchMove"
@touchend="touchEnd"
:class="[$style.transitionRoot]"
@touchstart.passive="touchStart"
@touchmove.passive="touchMove"
@touchend.passive="touchEnd"
>
<Transition
:class="[$style.transitionChildren, { [$style.swiping]: isSwipingForClass }]"
@ -25,6 +25,7 @@ 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';
@ -154,7 +155,7 @@ function touchEnd(event: TouchEvent) {
pullDistance.value = 0;
isSwiping.value = false;
setTimeout(() => {
window.setTimeout(() => {
isSwipingForClass.value = false;
}, 400);
}
@ -178,29 +179,29 @@ watch(tabModel, (newTab, oldTab) => {
</script>
<style lang="scss" module>
.transitionRoot.enableAnimation {
.transitionRoot {
display: grid;
grid-template-columns: 100%;
overflow: clip;
}
.transitionChildren {
grid-area: 1 / 1 / 2 / 2;
transform: translateX(var(--swipe));
.transitionChildren {
grid-area: 1 / 1 / 2 / 2;
transform: translateX(var(--swipe));
&.swipeAnimation_enterActive,
&.swipeAnimation_leaveActive {
transition: transform .3s cubic-bezier(0.65, 0.05, 0.36, 1);
}
&.swipeAnimation_enterActive,
&.swipeAnimation_leaveActive {
transition: transform .3s cubic-bezier(0.65, 0.05, 0.36, 1);
}
&.swipeAnimationRight_leaveTo,
&.swipeAnimationLeft_enterFrom {
transform: translateX(calc(100% + 24px));
}
&.swipeAnimationRight_leaveTo,
&.swipeAnimationLeft_enterFrom {
transform: translateX(calc(100% + 24px));
}
&.swipeAnimationRight_enterFrom,
&.swipeAnimationLeft_leaveTo {
transform: translateX(calc(-100% - 24px));
}
&.swipeAnimationRight_enterFrom,
&.swipeAnimationLeft_leaveTo {
transform: translateX(calc(-100% - 24px));
}
}

View file

@ -156,7 +156,7 @@ function close() {
left: 32px;
color: var(--indicator);
font-size: 8px;
animation: blink 1s infinite;
animation: global-blink 1s infinite;
@media (max-width: 500px) {
top: 16px;

View file

@ -518,7 +518,7 @@ onBeforeUnmount(() => {
right: 18px;
color: var(--indicator);
font-size: 8px;
animation: blink 1s infinite;
animation: global-blink 1s infinite;
}
.divider {

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
v-if="!hardMuted && !muted"
v-if="!hardMuted && muted === false"
v-show="!isDeleted"
ref="el"
v-hotkey="keymap"
@ -142,7 +142,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</article>
</div>
<div v-else-if="muted && !hideMutedNotes" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
</template>
</I18n>
<I18n v-else :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
@ -211,6 +218,7 @@ const emit = defineEmits<{
(ev: 'removeReaction', emoji: string): void;
}>();
const inTimeline = inject<boolean>('inTimeline', false);
const inChannel = inject('inChannel', null);
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
const note = ref(deepClone(props.note));
@ -257,19 +265,27 @@ const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
const collapsed = ref(appearNote.value.cw == null && isLong);
const isDeleted = ref(false);
const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords));
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i.id));
const renoteCollapsed = ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || (appearNote.value.myReaction != null)));
function checkMute(note: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null): boolean {
/* Overload FunctionLint
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute';
*/
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): boolean | 'sensitiveMute' {
if (mutedWords == null) return false;
if (checkWordMute(note, $i, mutedWords)) return true;
if (note.reply && checkWordMute(note.reply, $i, mutedWords)) return true;
if (note.renote && checkWordMute(note.renote, $i, mutedWords)) return true;
if (checkWordMute(noteToCheck, $i, mutedWords)) return true;
if (noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords)) return true;
if (noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords)) return true;
if (checkOnly) return false;
if (inTimeline && !defaultStore.state.tl.filter.withSensitive && noteToCheck.files?.some((v) => v.isSensitive)) return 'sensitiveMute';
return false;
}

View file

@ -49,6 +49,7 @@ const emit = defineEmits<{
(ev: 'queue', count: number): void;
}>();
provide('inTimeline', true);
provide('inChannel', computed(() => props.src === 'channel'));
type TimelineQueryType = {

View file

@ -96,12 +96,12 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) {
if (t == null || typeof t === 'boolean') return null;
return t.match(/^[0-9.]+s$/) ? t : null;
};
const validColor = (c: string | null | undefined): string | null => {
if (c == null) return null;
return c.match(/^[0-9a-f]{3,6}$/i) ? c : null;
};
const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm;
/**
@ -155,7 +155,7 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) {
case 'tada': {
const speed = validTime(token.props.args.speed) ?? '1s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = 'font-size: 150%;' + (useAnim ? `animation: tada ${speed} linear infinite both; animation-delay: ${delay};` : '');
style = 'font-size: 150%;' + (useAnim ? `animation: global-tada ${speed} linear infinite both; animation-delay: ${delay};` : '');
break;
}
case 'jelly': {

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkSpacer :contentMax="600">
<MkSpacer :contentMax="500">
<div :class="$style.root" class="_gaps">
<div style="display: flex; align-items: center; justify-content: center; gap: 10px;">
<span>({{ i18n.ts._reversi.black }})</span>
@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<Mfm :key="'past-turn-of:' + turnUser.id" :text="i18n.tsx._reversi.pastTurnOf({ name: turnUser.name ?? turnUser.username })" :plain="true" :customEmojis="turnUser.emojis"/>
</div>
<div v-if="iAmPlayer && !game.isEnded && !isMyTurn">{{ i18n.ts._reversi.opponentTurn }}<MkEllipsis/><span style="margin-left: 1em; opacity: 0.7;">({{ i18n.tsx.remainingN({ n: opTurnTimerRmain }) }})</span></div>
<div v-if="iAmPlayer && !game.isEnded && isMyTurn"><span style="display: inline-block; font-weight: bold; animation: tada 1s linear infinite both;">{{ i18n.ts._reversi.myTurn }}</span><span style="margin-left: 1em; opacity: 0.7;">({{ i18n.tsx.remainingN({ n: myTurnTimerRmain }) }})</span></div>
<div v-if="iAmPlayer && !game.isEnded && isMyTurn"><span style="display: inline-block; font-weight: bold; animation: global-tada 1s linear infinite both;">{{ i18n.ts._reversi.myTurn }}</span><span style="margin-left: 1em; opacity: 0.7;">({{ i18n.tsx.remainingN({ n: myTurnTimerRmain }) }})</span></div>
<div v-if="game.isEnded && logPos == game.logs.length">
<template v-if="game.winner">
<Mfm :key="'won'" :text="i18n.tsx._reversi.won({ name: game.winner.name ?? game.winner.username })" :plain="true" :customEmojis="game.winner.emojis"/>
@ -35,53 +35,55 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.board">
<div v-if="showBoardLabels" :class="$style.labelsX">
<span v-for="i in game.map[0].length" :class="$style.labelsXLabel">{{ String.fromCharCode(64 + i) }}</span>
</div>
<div style="display: flex;">
<div v-if="showBoardLabels" :class="$style.labelsY">
<div v-for="i in game.map.length" :class="$style.labelsYLabel">{{ i }}</div>
<div :class="$style.boardInner">
<div v-if="showBoardLabels" :class="$style.labelsX">
<span v-for="i in game.map[0].length" :class="$style.labelsXLabel">{{ String.fromCharCode(64 + i) }}</span>
</div>
<div :class="$style.boardCells" :style="cellsStyle">
<div
v-for="(stone, i) in engine.board"
:key="i"
v-tooltip="`${String.fromCharCode(65 + engine.posToXy(i)[0])}${engine.posToXy(i)[1] + 1}`"
:class="[$style.boardCell, {
[$style.boardCell_empty]: stone == null,
[$style.boardCell_none]: engine.map[i] === 'null',
[$style.boardCell_isEnded]: game.isEnded,
[$style.boardCell_myTurn]: !game.isEnded && isMyTurn,
[$style.boardCell_can]: turnUser ? engine.canPut(turnUser.id === blackUser.id, i) : null,
[$style.boardCell_prev]: engine.prevPos === i
}]"
@click="putStone(i)"
>
<Transition
:enterActiveClass="$style.transition_flip_enterActive"
:leaveActiveClass="$style.transition_flip_leaveActive"
:enterFromClass="$style.transition_flip_enterFrom"
:leaveToClass="$style.transition_flip_leaveTo"
mode="default"
<div style="display: flex;">
<div v-if="showBoardLabels" :class="$style.labelsY">
<div v-for="i in game.map.length" :class="$style.labelsYLabel">{{ i }}</div>
</div>
<div :class="$style.boardCells" :style="cellsStyle">
<div
v-for="(stone, i) in engine.board"
:key="i"
v-tooltip="`${String.fromCharCode(65 + engine.posToXy(i)[0])}${engine.posToXy(i)[1] + 1}`"
:class="[$style.boardCell, {
[$style.boardCell_empty]: stone == null,
[$style.boardCell_none]: engine.map[i] === 'null',
[$style.boardCell_isEnded]: game.isEnded,
[$style.boardCell_myTurn]: !game.isEnded && isMyTurn,
[$style.boardCell_can]: turnUser ? engine.canPut(turnUser.id === blackUser.id, i) : null,
[$style.boardCell_prev]: engine.prevPos === i
}]"
@click="putStone(i)"
>
<template v-if="useAvatarAsStone">
<img v-if="stone === true" :class="$style.boardCellStone" :src="blackUser.avatarUrl"/>
<img v-else-if="stone === false" :class="$style.boardCellStone" :src="whiteUser.avatarUrl"/>
</template>
<template v-else>
<img v-if="stone === true" :class="$style.boardCellStone" src="/client-assets/reversi/stone_b.png"/>
<img v-else-if="stone === false" :class="$style.boardCellStone" src="/client-assets/reversi/stone_w.png"/>
</template>
</Transition>
<Transition
:enterActiveClass="$style.transition_flip_enterActive"
:leaveActiveClass="$style.transition_flip_leaveActive"
:enterFromClass="$style.transition_flip_enterFrom"
:leaveToClass="$style.transition_flip_leaveTo"
mode="default"
>
<template v-if="useAvatarAsStone">
<img v-if="stone === true" :class="$style.boardCellStone" :src="blackUser.avatarUrl"/>
<img v-else-if="stone === false" :class="$style.boardCellStone" :src="whiteUser.avatarUrl"/>
</template>
<template v-else>
<img v-if="stone === true" :class="$style.boardCellStone" src="/client-assets/reversi/stone_b.png"/>
<img v-else-if="stone === false" :class="$style.boardCellStone" src="/client-assets/reversi/stone_w.png"/>
</template>
</Transition>
</div>
</div>
<div v-if="showBoardLabels" :class="$style.labelsY">
<div v-for="i in game.map.length" :class="$style.labelsYLabel">{{ i }}</div>
</div>
</div>
<div v-if="showBoardLabels" :class="$style.labelsY">
<div v-for="i in game.map.length" :class="$style.labelsYLabel">{{ i }}</div>
<div v-if="showBoardLabels" :class="$style.labelsX">
<span v-for="i in game.map[0].length" :class="$style.labelsXLabel">{{ String.fromCharCode(64 + i) }}</span>
</div>
</div>
<div v-if="showBoardLabels" :class="$style.labelsX">
<span v-for="i in game.map[0].length" :class="$style.labelsXLabel">{{ String.fromCharCode(64 + i) }}</span>
</div>
</div>
<div v-if="game.isEnded" class="_panel _gaps_s" style="padding: 16px;">
@ -141,7 +143,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, shallowRef, triggerRef, watch } from 'vue';
import * as CRC32 from 'crc-32';
import * as Misskey from 'misskey-js';
import * as Reversi from 'misskey-reversi';
import MkButton from '@/components/MkButton.vue';
@ -155,12 +156,13 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import { userPage } from '@/filters/user.js';
import * as sound from '@/scripts/sound.js';
import * as os from '@/os.js';
import { confetti } from '@/scripts/confetti.js';
const $i = signinRequired();
const props = defineProps<{
game: Misskey.entities.ReversiGameDetailed;
connection: Misskey.ChannelConnection;
connection?: Misskey.ChannelConnection | null;
}>();
const showBoardLabels = ref<boolean>(false);
@ -238,10 +240,16 @@ watch(logPos, (v) => {
if (game.value.isStarted && !game.value.isEnded) {
useInterval(() => {
if (game.value.isEnded) return;
const crc32 = CRC32.str(JSON.stringify(game.value.logs)).toString();
const crc32 = engine.value.calcCrc32();
if (_DEV_) console.log('crc32', crc32);
props.connection.send('checkState', {
crc32: crc32,
misskeyApi('reversi/verify', {
gameId: game.value.id,
crc32: crc32.toString(),
}).then((res) => {
if (res.desynced) {
console.log('resynced');
restoreGame(res.game!);
}
});
}, 10000, { immediate: false, afterMounted: true });
}
@ -264,7 +272,7 @@ function putStone(pos) {
});
const id = Math.random().toString(36).slice(2);
props.connection.send('putStone', {
props.connection!.send('putStone', {
pos: pos,
id,
});
@ -280,22 +288,24 @@ const myTurnTimerRmain = ref<number>(game.value.timeLimitForEachTurn);
const opTurnTimerRmain = ref<number>(game.value.timeLimitForEachTurn);
const TIMER_INTERVAL_SEC = 3;
useInterval(() => {
if (myTurnTimerRmain.value > 0) {
myTurnTimerRmain.value = Math.max(0, myTurnTimerRmain.value - TIMER_INTERVAL_SEC);
}
if (opTurnTimerRmain.value > 0) {
opTurnTimerRmain.value = Math.max(0, opTurnTimerRmain.value - TIMER_INTERVAL_SEC);
}
if (iAmPlayer.value) {
if ((isMyTurn.value && myTurnTimerRmain.value === 0) || (!isMyTurn.value && opTurnTimerRmain.value === 0)) {
props.connection.send('claimTimeIsUp', {});
if (!props.game.isEnded) {
useInterval(() => {
if (myTurnTimerRmain.value > 0) {
myTurnTimerRmain.value = Math.max(0, myTurnTimerRmain.value - TIMER_INTERVAL_SEC);
}
if (opTurnTimerRmain.value > 0) {
opTurnTimerRmain.value = Math.max(0, opTurnTimerRmain.value - TIMER_INTERVAL_SEC);
}
}
}, TIMER_INTERVAL_SEC * 1000, { immediate: false, afterMounted: true });
function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {
if (iAmPlayer.value) {
if ((isMyTurn.value && myTurnTimerRmain.value === 0) || (!isMyTurn.value && opTurnTimerRmain.value === 0)) {
props.connection!.send('claimTimeIsUp', {});
}
}
}, TIMER_INTERVAL_SEC * 1000, { immediate: false, afterMounted: true });
}
async function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {
game.value.logs = Reversi.Serializer.serializeLogs([
...Reversi.Serializer.deserializeLogs(game.value.logs),
log,
@ -306,17 +316,25 @@ function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {
if (log.id == null || !appliedOps.includes(log.id)) {
switch (log.operation) {
case 'put': {
sound.playUrl('/client-assets/reversi/put.mp3', {
volume: 1,
playbackRate: 1,
});
if (log.player !== engine.value.turn) { // = desync
const _game = await misskeyApi('reversi/show-game', {
gameId: props.game.id,
});
restoreGame(_game);
return;
}
engine.value.putStone(log.pos);
triggerRef(engine);
myTurnTimerRmain.value = game.value.timeLimitForEachTurn;
opTurnTimerRmain.value = game.value.timeLimitForEachTurn;
sound.playUrl('/client-assets/reversi/put.mp3', {
volume: 1,
playbackRate: 1,
});
checkEnd();
break;
}
@ -329,6 +347,22 @@ function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {
function onStreamEnded(x) {
game.value = deepClone(x.game);
if (game.value.winnerId === $i.id) {
confetti({
duration: 1000 * 3,
});
sound.playUrl('/client-assets/reversi/win.mp3', {
volume: 1,
playbackRate: 1,
});
} else {
sound.playUrl('/client-assets/reversi/lose.mp3', {
volume: 1,
playbackRate: 1,
});
}
}
function checkEnd() {
@ -347,9 +381,7 @@ function checkEnd() {
}
}
function onStreamRescue(_game) {
console.log('rescue');
function restoreGame(_game) {
game.value = deepClone(_game);
engine.value = Reversi.Serializer.restoreGame({
@ -415,27 +447,31 @@ function share() {
}
onMounted(() => {
props.connection.on('log', onStreamLog);
props.connection.on('rescue', onStreamRescue);
props.connection.on('ended', onStreamEnded);
if (props.connection != null) {
props.connection.on('log', onStreamLog);
props.connection.on('ended', onStreamEnded);
}
});
onActivated(() => {
props.connection.on('log', onStreamLog);
props.connection.on('rescue', onStreamRescue);
props.connection.on('ended', onStreamEnded);
if (props.connection != null) {
props.connection.on('log', onStreamLog);
props.connection.on('ended', onStreamEnded);
}
});
onDeactivated(() => {
props.connection.off('log', onStreamLog);
props.connection.off('rescue', onStreamRescue);
props.connection.off('ended', onStreamEnded);
if (props.connection != null) {
props.connection.off('log', onStreamLog);
props.connection.off('ended', onStreamEnded);
}
});
onUnmounted(() => {
props.connection.off('log', onStreamLog);
props.connection.off('rescue', onStreamRescue);
props.connection.off('ended', onStreamEnded);
if (props.connection != null) {
props.connection.off('log', onStreamLog);
props.connection.off('ended', onStreamEnded);
}
});
</script>
@ -465,8 +501,27 @@ $gap: 4px;
.board {
width: 100%;
max-width: 500px;
box-sizing: border-box;
margin: 0 auto;
padding: 7px;
background: #8C4F26;
box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
border-radius: 12px;
}
.boardInner {
padding: 32px;
background: var(--panel);
box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410;
border-radius: 8px;
}
@container (max-width: 400px) {
.boardInner {
padding: 16px;
}
}
.labelsX {

View file

@ -8,72 +8,74 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="600">
<div style="text-align: center;"><b><MkUserName :user="game.user1"/></b> vs <b><MkUserName :user="game.user2"/></b></div>
<div class="_gaps">
<div style="font-size: 1.5em; text-align: center;">{{ i18n.ts._reversi.gameSettings }}</div>
<div :class="{ [$style.disallow]: isReady }">
<div class="_gaps" :class="{ [$style.disallowInner]: isReady }">
<div style="font-size: 1.5em; text-align: center;">{{ i18n.ts._reversi.gameSettings }}</div>
<div class="_panel">
<div style="display: flex; align-items: center; padding: 16px; border-bottom: solid 1px var(--divider);">
<div>{{ mapName }}</div>
<MkButton style="margin-left: auto;" @click="chooseMap">{{ i18n.ts._reversi.chooseBoard }}</MkButton>
</div>
<div class="_panel">
<div style="display: flex; align-items: center; padding: 16px; border-bottom: solid 1px var(--divider);">
<div>{{ mapName }}</div>
<MkButton style="margin-left: auto;" @click="chooseMap">{{ i18n.ts._reversi.chooseBoard }}</MkButton>
</div>
<div style="padding: 16px;">
<div v-if="game.map == null"><i class="ti ti-dice"></i></div>
<div v-else :class="$style.board" :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }">
<div v-for="(x, i) in game.map.join('')" :class="[$style.boardCell, { [$style.boardCellNone]: x == ' ' }]" @click="onMapCellClick(i, x)">
<i v-if="x === 'b' || x === 'w'" style="pointer-events: none; user-select: none;" :class="x === 'b' ? 'ti ti-circle-filled' : 'ti ti-circle'"></i>
<div style="padding: 16px;">
<div v-if="game.map == null"><i class="ti ti-dice"></i></div>
<div v-else :class="$style.board" :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }">
<div v-for="(x, i) in game.map.join('')" :class="[$style.boardCell, { [$style.boardCellNone]: x == ' ' }]" @click="onMapCellClick(i, x)">
<i v-if="x === 'b' || x === 'w'" style="pointer-events: none; user-select: none;" :class="x === 'b' ? 'ti ti-circle-filled' : 'ti ti-circle'"></i>
</div>
</div>
</div>
</div>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._reversi.blackOrWhite }}</template>
<MkRadios v-model="game.bw">
<option value="random">{{ i18n.ts.random }}</option>
<option :value="'1'">
<I18n :src="i18n.ts._reversi.blackIs" tag="span">
<template #name>
<b><MkUserName :user="game.user1"/></b>
</template>
</I18n>
</option>
<option :value="'2'">
<I18n :src="i18n.ts._reversi.blackIs" tag="span">
<template #name>
<b><MkUserName :user="game.user2"/></b>
</template>
</I18n>
</option>
</MkRadios>
</MkFolder>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._reversi.timeLimitForEachTurn }}</template>
<template #suffix>{{ game.timeLimitForEachTurn }}{{ i18n.ts._time.second }}</template>
<MkRadios v-model="game.timeLimitForEachTurn">
<option :value="5">5{{ i18n.ts._time.second }}</option>
<option :value="10">10{{ i18n.ts._time.second }}</option>
<option :value="30">30{{ i18n.ts._time.second }}</option>
<option :value="60">60{{ i18n.ts._time.second }}</option>
<option :value="90">90{{ i18n.ts._time.second }}</option>
<option :value="120">120{{ i18n.ts._time.second }}</option>
<option :value="180">180{{ i18n.ts._time.second }}</option>
<option :value="3600">3600{{ i18n.ts._time.second }}</option>
</MkRadios>
</MkFolder>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._reversi.rules }}</template>
<div class="_gaps_s">
<MkSwitch v-model="game.isLlotheo" @update:modelValue="updateSettings('isLlotheo')">{{ i18n.ts._reversi.isLlotheo }}</MkSwitch>
<MkSwitch v-model="game.loopedBoard" @update:modelValue="updateSettings('loopedBoard')">{{ i18n.ts._reversi.loopedMap }}</MkSwitch>
<MkSwitch v-model="game.canPutEverywhere" @update:modelValue="updateSettings('canPutEverywhere')">{{ i18n.ts._reversi.canPutEverywhere }}</MkSwitch>
</div>
</MkFolder>
</div>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._reversi.blackOrWhite }}</template>
<MkRadios v-model="game.bw">
<option value="random">{{ i18n.ts.random }}</option>
<option :value="'1'">
<I18n :src="i18n.ts._reversi.blackIs" tag="span">
<template #name>
<b><MkUserName :user="game.user1"/></b>
</template>
</I18n>
</option>
<option :value="'2'">
<I18n :src="i18n.ts._reversi.blackIs" tag="span">
<template #name>
<b><MkUserName :user="game.user2"/></b>
</template>
</I18n>
</option>
</MkRadios>
</MkFolder>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._reversi.timeLimitForEachTurn }}</template>
<template #suffix>{{ game.timeLimitForEachTurn }}{{ i18n.ts._time.second }}</template>
<MkRadios v-model="game.timeLimitForEachTurn">
<option :value="5">5{{ i18n.ts._time.second }}</option>
<option :value="10">10{{ i18n.ts._time.second }}</option>
<option :value="30">30{{ i18n.ts._time.second }}</option>
<option :value="60">60{{ i18n.ts._time.second }}</option>
<option :value="90">90{{ i18n.ts._time.second }}</option>
<option :value="120">120{{ i18n.ts._time.second }}</option>
<option :value="180">180{{ i18n.ts._time.second }}</option>
<option :value="3600">3600{{ i18n.ts._time.second }}</option>
</MkRadios>
</MkFolder>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._reversi.rules }}</template>
<div class="_gaps_s">
<MkSwitch v-model="game.isLlotheo" @update:modelValue="updateSettings('isLlotheo')">{{ i18n.ts._reversi.isLlotheo }}</MkSwitch>
<MkSwitch v-model="game.loopedBoard" @update:modelValue="updateSettings('loopedBoard')">{{ i18n.ts._reversi.loopedMap }}</MkSwitch>
<MkSwitch v-model="game.canPutEverywhere" @update:modelValue="updateSettings('canPutEverywhere')">{{ i18n.ts._reversi.canPutEverywhere }}</MkSwitch>
</div>
</MkFolder>
</div>
</MkSpacer>
<template #footer>
@ -123,7 +125,7 @@ const props = defineProps<{
}>();
const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game));
const isLlotheo = ref<boolean>(false);
const mapName = computed(() => {
if (game.value.map == null) return 'Random';
const found = Object.values(Reversi.maps).find(x => x.data.join('') === game.value.map.join(''));
@ -236,6 +238,15 @@ onUnmounted(() => {
</script>
<style lang="scss" module>
.disallow {
cursor: not-allowed;
}
.disallowInner {
pointer-events: none;
user-select: none;
opacity: 0.7;
}
.board {
display: grid;
grid-gap: 4px;

View file

@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="game == null || connection == null"><MkLoading/></div>
<GameSetting v-else-if="!game.isStarted" :game="game" :connection="connection"/>
<div v-if="game == null || (!game.isEnded && connection == null)"><MkLoading/></div>
<GameSetting v-else-if="!game.isStarted" :game="game" :connection="connection!"/>
<GameBoard v-else :game="game" :connection="connection"/>
</template>
@ -47,23 +47,25 @@ async function fetchGame() {
if (connection.value) {
connection.value.dispose();
}
connection.value = useStream().useChannel('reversiGame', {
gameId: game.value.id,
});
connection.value.on('started', x => {
game.value = x.game;
});
connection.value.on('canceled', x => {
connection.value?.dispose();
if (!game.value.isEnded) {
connection.value = useStream().useChannel('reversiGame', {
gameId: game.value.id,
});
connection.value.on('started', x => {
game.value = x.game;
});
connection.value.on('canceled', x => {
connection.value?.dispose();
if (x.userId !== $i.id) {
os.alert({
type: 'warning',
text: i18n.ts._reversi.gameCanceled,
});
router.push('/reversi');
}
});
if (x.userId !== $i.id) {
os.alert({
type: 'warning',
text: i18n.ts._reversi.gameCanceled,
});
router.push('/reversi');
}
});
}
}
onMounted(() => {

View file

@ -34,12 +34,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPagination :pagination="myGamesPagination" :disableAutoLoad="true">
<template #default="{ items }">
<div :class="$style.gamePreviews">
<MkA v-for="g in items" :key="g.id" v-panel :class="$style.gamePreview" tabindex="-1" :to="`/reversi/g/${g.id}`">
<MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`">
<div :class="$style.gamePreviewPlayers">
<MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/> vs <MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/>
<span v-if="g.winnerId === g.user1Id" style="margin-right: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
<span v-if="g.winnerId === g.user2Id" style="margin-right: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span>
<MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/>
<span style="margin: 0 1em;">vs</span>
<MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/>
<span v-if="g.winnerId === g.user1Id" style="margin-left: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span>
<span v-if="g.winnerId === g.user2Id" style="margin-left: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
</div>
<div :class="$style.gamePreviewFooter">
<span :style="!g.isEnded ? 'color: var(--accent);' : ''">{{ g.isEnded ? i18n.ts._reversi.ended : i18n.ts._reversi.playing }}</span>
<span v-if="!g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span>
<span v-else>{{ i18n.ts._reversi.ended }}</span>
<MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/>
</div>
</MkA>
@ -53,12 +60,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPagination :pagination="gamesPagination" :disableAutoLoad="true">
<template #default="{ items }">
<div :class="$style.gamePreviews">
<MkA v-for="g in items" :key="g.id" v-panel :class="$style.gamePreview" tabindex="-1" :to="`/reversi/g/${g.id}`">
<MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`">
<div :class="$style.gamePreviewPlayers">
<MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/> vs <MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/>
<span v-if="g.winnerId === g.user1Id" style="margin-right: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
<span v-if="g.winnerId === g.user2Id" style="margin-right: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span>
<MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/>
<span style="margin: 0 1em;">vs</span>
<MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/>
<span v-if="g.winnerId === g.user1Id" style="margin-left: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span>
<span v-if="g.winnerId === g.user2Id" style="margin-left: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
</div>
<div :class="$style.gamePreviewFooter">
<span :style="!g.isEnded ? 'color: var(--accent);' : ''">{{ g.isEnded ? i18n.ts._reversi.ended : i18n.ts._reversi.playing }}</span>
<span v-if="!g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span>
<span v-else>{{ i18n.ts._reversi.ended }}</span>
<MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/>
</div>
</MkA>
@ -229,6 +243,11 @@ definePageMetadata(computed(() => ({
</script>
<style lang="scss" module>
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0.2; }
}
.invitation {
display: flex;
box-sizing: border-box;
@ -250,6 +269,10 @@ definePageMetadata(computed(() => ({
overflow: clip;
}
.gamePreviewActive {
box-shadow: inset 0 0 8px 0px var(--accent);
}
.gamePreviewPlayers {
text-align: center;
padding: 16px;
@ -277,6 +300,12 @@ definePageMetadata(computed(() => ({
font-size: 0.9em;
}
.gamePreviewStatusActive {
color: var(--accent);
font-weight: bold;
animation: blink 2s infinite;
}
.waitingScreen {
text-align: center;
}

View file

@ -17,6 +17,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</MkSelect>
<MkRadios v-model="hemisphere">
<template #label>{{ i18n.ts.hemisphere }}</template>
<option value="N">{{ i18n.ts._hemisphere.N }}</option>
<option value="S">{{ i18n.ts._hemisphere.S }}</option>
<template #caption>{{ i18n.ts._hemisphere.caption }}</template>
</MkRadios>
<MkRadios v-model="overridedDeviceKind">
<template #label>{{ i18n.ts.overridedDeviceKind }}</template>
<option :value="null">{{ i18n.ts.auto }}</option>
@ -370,6 +377,7 @@ async function reloadAsk() {
unisonReload();
}
const hemisphere = computed(defaultStore.makeGetterSetter('hemisphere'));
const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind'));
const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior'));
const showNoteActionsOnlyHover = computed(defaultStore.makeGetterSetter('showNoteActionsOnlyHover'));
@ -491,6 +499,7 @@ watch(useSystemFont, () => {
});
watch([
hemisphere,
lang,
fontSize,
useSystemFont,

View file

@ -66,10 +66,44 @@ const rootEl = shallowRef<HTMLElement>();
const queue = ref(0);
const srcWhenNotSignin = ref(isLocalTimelineAvailable ? 'local' : 'global');
const src = computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value), set: (x) => saveSrc(x) });
const withRenotes = ref(true);
const withReplies = ref($i ? defaultStore.state.tlWithReplies : false);
const onlyFiles = ref(false);
const src = computed({
get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value),
set: (x) => saveSrc(x),
});
const withRenotes = computed({
get: () => defaultStore.reactiveState.tl.value.filter.withRenotes,
set: (x: boolean) => saveTlFilter('withRenotes', x),
});
const withReplies = computed({
get: () => {
if (!$i) return false;
if (['local', 'social'].includes(src.value) && onlyFiles.value) {
return false;
} else {
return defaultStore.reactiveState.tl.value.filter.withReplies;
}
},
set: (x: boolean) => saveTlFilter('withReplies', x),
});
const onlyFiles = computed({
get: () => {
if (['local', 'social'].includes(src.value) && withReplies.value) {
return false;
} else {
return defaultStore.reactiveState.tl.value.filter.onlyFiles;
}
},
set: (x: boolean) => saveTlFilter('onlyFiles', x),
});
const withSensitive = computed({
get: () => defaultStore.reactiveState.tl.value.filter.withSensitive,
set: (x: boolean) => {
saveTlFilter('withSensitive', x);
//
tlComponent.value?.reloadTimeline();
},
});
const isShowMediaTimeline = ref(defaultStore.state.showMediaTimeline);
const remoteLocalTimelineEnable1 = ref(defaultStore.state.remoteLocalTimelineEnable1);
const remoteLocalTimelineEnable2 = ref(defaultStore.state.remoteLocalTimelineEnable2);
@ -80,10 +114,6 @@ watch(src, () => {
queue.value = 0;
});
watch(withReplies, (x) => {
if ($i) defaultStore.set('tlWithReplies', x);
});
function queueUpdated(q: number): void {
queue.value = q;
}
@ -155,18 +185,37 @@ async function chooseChannel(ev: MouseEvent): Promise<void> {
}
function saveSrc(newSrc: 'home' | 'local' | 'media' | 'social' | 'global' | `list:${string}`): void {
let userList = null;
const out = {
...defaultStore.state.tl,
src: newSrc,
};
if (newSrc.startsWith('userList:')) {
const id = newSrc.substring('userList:'.length);
userList = defaultStore.reactiveState.pinnedUserLists.value.find(l => l.id === id);
out.userList = defaultStore.reactiveState.pinnedUserLists.value.find(l => l.id === id) ?? null;
}
defaultStore.set('tl', {
src: newSrc,
userList,
});
defaultStore.set('tl', out);
srcWhenNotSignin.value = newSrc;
}
function saveTlFilter(key: keyof typeof defaultStore.state.tl.filter, newValue: boolean) {
if (key !== 'withReplies' || $i) {
const out = { ...defaultStore.state.tl };
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!out.filter) {
out.filter = {
withRenotes: true,
withReplies: true,
withSensitive: true,
onlyFiles: false,
};
}
out.filter[key] = newValue;
defaultStore.set('tl', out);
}
return newValue;
}
async function timetravel(): Promise<void> {
const { canceled, result: date } = await os.inputDate({
title: i18n.ts.date,
@ -203,7 +252,11 @@ const headerActions = computed(() => {
ref: withReplies,
disabled: onlyFiles } : undefined, {
type: 'switch',
text: i18n.ts.fileAttachedOnly,
text: i18n.ts.withSensitive,
ref: withSensitive,
}, {
type: 'switch',
text: i18n.ts.fileAttachedOnly,
ref: onlyFiles,
disabled: src.value === 'local' || src.value === 'social' ? withReplies : false,
@ -216,8 +269,7 @@ const headerActions = computed(() => {
icon: 'ti ti-refresh',
text: i18n.ts.reload,
handler: (ev: Event) => {
console.log('called');
tlComponent.value.reloadTimeline();
tlComponent.value?.reloadTimeline();
},
});
}

View file

@ -7,6 +7,7 @@
import { onUnmounted, Ref, ref, watch } from 'vue';
import { BroadcastChannel } from 'broadcast-channel';
import { defu } from 'defu';
import { $i } from '@/account.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { get, set } from '@/scripts/idb-proxy.js';
@ -80,6 +81,18 @@ export class Storage<T extends StateDef> {
this.loaded = this.ready.then(() => this.load());
}
private isPureObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
private mergeState<T>(value: T, def: T): T {
if (this.isPureObject(value) && this.isPureObject(def)) {
if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def);
return defu(value, def) as T;
}
return value;
}
private async init(): Promise<void> {
await this.migrate();
@ -89,11 +102,11 @@ export class Storage<T extends StateDef> {
for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) {
if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) {
this.reactiveState[k].value = this.state[k] = deviceState[k];
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceState[k], v.default);
} else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) {
this.reactiveState[k].value = this.state[k] = registryCache[k];
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(registryCache[k], v.default);
} else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) {
this.reactiveState[k].value = this.state[k] = deviceAccountState[k];
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceAccountState[k], v.default);
} else {
this.reactiveState[k].value = this.state[k] = v.default;
if (_DEV_) console.log('Use default value', k, v.default);

View file

@ -33,6 +33,10 @@ try {
}
export const dateTimeFormat = _dateTimeFormat;
export const timeZone = dateTimeFormat.resolvedOptions().timeZone;
export const hemisphere = /^(australia|pacific|antarctica|indian)\//i.test(timeZone) ? 'S' : 'N';
let _numberFormat: Intl.NumberFormat;
try {
_numberFormat = new Intl.NumberFormat(versatileLang);

View file

@ -8,6 +8,7 @@ import * as Misskey from 'misskey-js';
import { miLocalStorage } from './local-storage.js';
import type { SoundType } from '@/scripts/sound.js';
import { Storage } from '@/pizzax.js';
import { hemisphere } from '@/scripts/intl-const.js';
interface PostFormAction {
title: string,
@ -258,6 +259,12 @@ export const defaultStore = markRaw(new Storage('base', {
default: {
src: 'home' as 'home' | 'local' | 'social' | 'global' | `list:${string}`,
userList: null as Misskey.entities.UserList | null,
filter: {
withReplies: true,
withRenotes: true,
withSensitive: true,
onlyFiles: false,
},
},
},
pinnedUserLists: {
@ -625,10 +632,6 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: false,
},
tlWithReplies: {
where: 'device',
default: false,
},
defaultWithReplies: {
where: 'account',
default: false,
@ -665,6 +668,10 @@ export const defaultStore = markRaw(new Storage('base', {
sfxVolume: 1,
},
},
hemisphere: {
where: 'device',
default: hemisphere as 'N' | 'S',
},
enableHorizontalSwipe: {
where: 'device',
default: true,

View file

@ -433,13 +433,13 @@ rt {
transform: scale(0.9);
}
@keyframes blink {
@keyframes global-blink {
0% { opacity: 1; transform: scale(1); }
30% { opacity: 1; transform: scale(1); }
90% { opacity: 0; transform: scale(0.5); }
}
@keyframes tada {
@keyframes global-tada {
from {
transform: scale3d(1, 1, 1);
}
@ -469,7 +469,7 @@ rt {
._anime_bounce {
will-change: transform;
animation: bounce ease 0.7s;
animation: global-bounce ease 0.7s;
animation-iteration-count: 1;
transform-origin: 50% 50%;
}
@ -481,7 +481,7 @@ rt {
transition: transform 0.1s ease;
}
@keyframes bounce {
@keyframes global-bounce {
0% {
transform: scaleX(0.90) scaleY(0.90) ;
}

View file

@ -485,7 +485,7 @@ function more() {
left: 20px;
color: var(--navIndicator);
font-size: 8px;
animation: blink 1s infinite;
animation: global-blink 1s infinite;
&:has(.itemIndicateValueIcon) {
animation: none;

View file

@ -560,7 +560,7 @@ function more(ev: MouseEvent) {
left: 20px;
color: var(--navIndicator);
font-size: 8px;
animation: blink 1s infinite;
animation: global-blink 1s infinite;
&:has(.itemIndicateValueIcon) {
animation: none;
@ -883,7 +883,7 @@ function more(ev: MouseEvent) {
left: 24px;
color: var(--navIndicator);
font-size: 8px;
animation: blink 1s infinite;
animation: global-blink 1s infinite;
&:has(.itemIndicateValueIcon) {
animation: none;

View file

@ -141,7 +141,7 @@ onMounted(() => {
left: 0;
color: var(--navIndicator);
font-size: 8px;
animation: blink 1s infinite;
animation: global-blink 1s infinite;
}
&:hover {

View file

@ -221,7 +221,7 @@ watch(defaultStore.reactiveState.menuDisplay, () => {
left: 0;
color: var(--navIndicator);
font-size: 8px;
animation: blink 1s infinite;
animation: global-blink 1s infinite;
&:has(.itemIndicateValueIcon) {
animation: none;

View file

@ -489,7 +489,7 @@ body {
left: 0;
color: var(--indicator);
font-size: 16px;
animation: blink 1s infinite;
animation: global-blink 1s infinite;
&:has(.itemIndicateValueIcon) {
animation: none;

View file

@ -576,7 +576,7 @@ $widgets-hide-threshold: 1090px;
left: 0;
color: var(--indicator);
font-size: 16px;
animation: blink 1s infinite;
animation: global-blink 1s infinite;
&:has(.itemIndicateValueIcon) {
animation: none;