summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
authorかっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>2023-11-03 15:35:07 +0900
committerGitHub <noreply@github.com>2023-11-03 15:35:07 +0900
commit24e629ca5c50789ff0aba31532ae66b51148d70f (patch)
tree513155452fd0644c6b69bf7e53e26ab6575977db /packages/frontend/src/components
parentenhance: アカウント登録時のメールアドレス認証に30分の有... (diff)
downloadsharkey-24e629ca5c50789ff0aba31532ae66b51148d70f.tar.gz
sharkey-24e629ca5c50789ff0aba31532ae66b51148d70f.tar.bz2
sharkey-24e629ca5c50789ff0aba31532ae66b51148d70f.zip
enhance: 初期設定とチュートリアルを統合 (#12141)
* better onboarding experience * enhance: iroiro * (add) title * (enhance) 戻る・次へボタンを全ページでstickyに * fix merging * (add) iroiro * remove unnecessary file * Update CHANGELOG.md * tweak texts * (fix) reactionViewer mock * change strings * Update MkTutorialDialog.Note.vue * Update ja-JP.yml * (fix) reactionViewer error * (fix) path * refactor * fix * Update MkPostForm.vue * Update ja-JP.yml * Update ja-JP.yml * tweak text * Update ja-JP.yml * Update ja-JP.yml * Update ja-JP.yml * (add) achivement * (add) もう一度見れますよメッセージを追加 * Revert "feat: レジストリAPIをサードパーティから利用可能に (#12229)" This reverts commit 79346272f8792d35955efd3aaaa1e42e0cd2a6e3. * Revert "(add) もう一度見れますよメッセージを追加" This reverts commit 6123b35215133f0d5e5db356bb43f4acbafab8fa. * Revert "Revert "feat: レジストリAPIをサードパーティから利用可能に (#12229)"" This reverts commit bae684e484ef99308d7ac816a822047117efe1c6. * tweak --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/MkInfo.vue20
-rw-r--r--packages/frontend/src/components/MkNote.vue150
-rw-r--r--packages/frontend/src/components/MkNoteHeader.vue14
-rw-r--r--packages/frontend/src/components/MkPostForm.vue28
-rw-r--r--packages/frontend/src/components/MkPostFormAttaches.vue17
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.reaction.vue52
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.vue19
-rw-r--r--packages/frontend/src/components/MkTutorialDialog.Note.vue117
-rw-r--r--packages/frontend/src/components/MkTutorialDialog.PostNote.vue135
-rw-r--r--packages/frontend/src/components/MkTutorialDialog.Sensitive.vue144
-rw-r--r--packages/frontend/src/components/MkTutorialDialog.Timeline.vue87
-rw-r--r--packages/frontend/src/components/MkTutorialDialog.vue260
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.vue77
13 files changed, 1027 insertions, 93 deletions
diff --git a/packages/frontend/src/components/MkInfo.vue b/packages/frontend/src/components/MkInfo.vue
index 37490887e1..19402a44ce 100644
--- a/packages/frontend/src/components/MkInfo.vue
+++ b/packages/frontend/src/components/MkInfo.vue
@@ -7,7 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="[$style.root, { [$style.warn]: warn }]">
<i v-if="warn" class="ti ti-alert-triangle" :class="$style.i"></i>
<i v-else class="ti ti-info-circle" :class="$style.i"></i>
- <slot></slot>
+ <div><slot></slot></div>
+ <button v-if="closable" :class="$style.button" class="_button" @click="close()"><i class="ti ti-x"></i></button>
</div>
</template>
@@ -16,11 +17,23 @@ import { } from 'vue';
const props = defineProps<{
warn?: boolean;
+ closable?: boolean;
}>();
+
+const emit = defineEmits<{
+ (ev: 'close'): void;
+}>();
+
+function close() {
+ // こいつの中では非表示動作は行わない
+ emit('close');
+}
</script>
<style lang="scss" module>
.root {
+ display: flex;
+ align-items: center;
padding: 12px 14px;
font-size: 90%;
background: var(--infoBg);
@@ -37,4 +50,9 @@ const props = defineProps<{
.i {
margin-right: 4px;
}
+
+.button {
+ margin-left: auto;
+ padding: 4px;
+}
</style>
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index b31ee78532..d71b07c51b 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<article v-else :class="$style.article" @contextmenu.stop="onContextmenu">
<div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div>
- <MkAvatar :class="$style.avatar" :user="appearNote.user" link preview/>
+ <MkAvatar :class="$style.avatar" :user="appearNote.user" :link="!mock" :preview="!mock"/>
<div :class="$style.main">
<MkNoteHeader :note="appearNote" :mini="true"/>
<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
@@ -84,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
- <MkReactionsViewer :note="appearNote" :maxNumber="16">
+ <MkReactionsViewer :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction">
<template #more>
<div :class="$style.reactionOmitted">{{ i18n.ts.more }}</div>
</template>
@@ -136,7 +136,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { computed, inject, onMounted, ref, shallowRef, Ref, defineAsyncComponent } from 'vue';
+import { computed, inject, onMounted, ref, shallowRef, Ref, defineAsyncComponent, watch, provide } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import MkNoteSub from '@/components/MkNoteSub.vue';
@@ -170,9 +170,19 @@ import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@/scripts/collapsed.js';
-const props = defineProps<{
+const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
pinned?: boolean;
+ mock?: boolean;
+}>(), {
+ mock: false,
+});
+
+provide('mock', props.mock);
+
+const emit = defineEmits<{
+ (ev: 'reaction', emoji: string): void;
+ (ev: 'removeReaction', emoji: string): void;
}>();
const inChannel = inject('inChannel', null);
@@ -232,30 +242,38 @@ const keymap = {
's': () => showContent.value !== showContent.value,
};
-useNoteCapture({
- rootEl: el,
- note: $$(appearNote),
- pureNote: $$(note),
- isDeletedRef: isDeleted,
-});
-
-useTooltip(renoteButton, async (showing) => {
- const renotes = await os.api('notes/renotes', {
- noteId: appearNote.id,
- limit: 11,
+if (props.mock) {
+ watch(() => props.note, (to) => {
+ note = deepClone(to);
+ }, { deep: true });
+} else {
+ useNoteCapture({
+ rootEl: el,
+ note: $$(appearNote),
+ pureNote: $$(note),
+ isDeletedRef: isDeleted,
});
+}
+
+if (!props.mock) {
+ useTooltip(renoteButton, async (showing) => {
+ const renotes = await os.api('notes/renotes', {
+ noteId: appearNote.id,
+ limit: 11,
+ });
- const users = renotes.map(x => x.user);
+ const users = renotes.map(x => x.user);
- if (users.length < 1) return;
+ if (users.length < 1) return;
- os.popup(MkUsersTooltip, {
- showing,
- users,
- count: appearNote.renoteCount,
- targetElement: renoteButton.value,
- }, {}, 'closed');
-});
+ os.popup(MkUsersTooltip, {
+ showing,
+ users,
+ count: appearNote.renoteCount,
+ targetElement: renoteButton.value,
+ }, {}, 'closed');
+ });
+}
type Visibility = 'public' | 'home' | 'followers' | 'specified';
@@ -287,21 +305,25 @@ function renote(viaKeyboard = false) {
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
- os.api('notes/create', {
- renoteId: appearNote.id,
- channelId: appearNote.channelId,
- }).then(() => {
- os.toast(i18n.ts.renoted);
- });
+ if (!props.mock) {
+ os.api('notes/create', {
+ renoteId: appearNote.id,
+ channelId: appearNote.channelId,
+ }).then(() => {
+ os.toast(i18n.ts.renoted);
+ });
+ }
},
}, {
text: i18n.ts.inChannelQuote,
icon: 'ti ti-quote',
action: () => {
- os.post({
- renote: appearNote,
- channel: appearNote.channel,
- });
+ if (!props.mock) {
+ os.post({
+ renote: appearNote,
+ channel: appearNote.channel,
+ });
+ }
},
}, null]);
}
@@ -327,15 +349,17 @@ function renote(viaKeyboard = false) {
visibility = smallerVisibility(visibility, 'home');
}
- os.api('notes/create', {
- localOnly,
- visibility,
- renoteId: appearNote.id,
- }).then(() => {
- os.toast(i18n.ts.renoted);
- });
+ if (!props.mock) {
+ os.api('notes/create', {
+ localOnly,
+ visibility,
+ renoteId: appearNote.id,
+ }).then(() => {
+ os.toast(i18n.ts.renoted);
+ });
+ }
},
- }, {
+ }, (props.mock) ? undefined : {
text: i18n.ts.quote,
icon: 'ti ti-quote',
action: () => {
@@ -352,6 +376,9 @@ function renote(viaKeyboard = false) {
function reply(viaKeyboard = false): void {
pleaseLogin();
+ if (props.mock) {
+ return;
+ }
os.post({
reply: appearNote,
channel: appearNote.channel,
@@ -365,6 +392,10 @@ function react(viaKeyboard = false): void {
pleaseLogin();
showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') {
+ if (props.mock) {
+ return;
+ }
+
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: '❤️',
@@ -379,6 +410,11 @@ function react(viaKeyboard = false): void {
} else {
blur();
reactionPicker.show(reactButton.value, reaction => {
+ if (props.mock) {
+ emit('reaction', reaction);
+ return;
+ }
+
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction,
@@ -395,12 +431,22 @@ function react(viaKeyboard = false): void {
function undoReact(note): void {
const oldReaction = note.myReaction;
if (!oldReaction) return;
+
+ if (props.mock) {
+ emit('removeReaction', oldReaction);
+ return;
+ }
+
os.api('notes/reactions/delete', {
noteId: note.id,
});
}
function onContextmenu(ev: MouseEvent): void {
+ if (props.mock) {
+ return;
+ }
+
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
// 再生速度の選択などのために、Audio要素のコンテキストメニューはブラウザデフォルトとする。
@@ -422,6 +468,10 @@ function onContextmenu(ev: MouseEvent): void {
}
function menu(viaKeyboard = false): void {
+ if (props.mock) {
+ return;
+ }
+
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value });
os.popupMenu(menu, menuButton.value, {
viaKeyboard,
@@ -429,10 +479,18 @@ function menu(viaKeyboard = false): void {
}
async function clip() {
+ if (props.mock) {
+ return;
+ }
+
os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
function showRenoteMenu(viaKeyboard = false): void {
+ if (props.mock) {
+ return;
+ }
+
function getUnrenote(): MenuItem {
return {
text: i18n.ts.unrenote,
@@ -490,6 +548,14 @@ function readPromo() {
});
isDeleted.value = true;
}
+
+function emitUpdReaction(emoji: string, delta: number) {
+ if (delta < 0) {
+ emit('removeReaction', emoji);
+ } else if (delta > 0) {
+ emit('reaction', emoji);
+ }
+}
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue
index 52d5b03685..b2236b99c2 100644
--- a/packages/frontend/src/components/MkNoteHeader.vue
+++ b/packages/frontend/src/components/MkNoteHeader.vue
@@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<header :class="$style.root">
- <MkA v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
+ <div v-if="mock" :class="$style.name">
+ <MkUserName :user="note.user"/>
+ </div>
+ <MkA v-else v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
<MkUserName :user="note.user"/>
</MkA>
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
@@ -14,7 +17,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<img v-for="role in note.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/>
</div>
<div :class="$style.info">
- <MkA :to="notePage(note)">
+ <div v-if="mock">
+ <MkTime :time="note.createdAt" colored/>
+ </div>
+ <MkA v-else :to="notePage(note)">
<MkTime :time="note.createdAt" colored/>
</MkA>
<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
@@ -29,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { } from 'vue';
+import { inject } from 'vue';
import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js';
import { notePage } from '@/filters/note.js';
@@ -38,6 +44,8 @@ import { userPage } from '@/filters/user.js';
defineProps<{
note: Misskey.entities.Note;
}>();
+
+const mock = inject<boolean>('mock', false);
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 1fa5685861..46faae9523 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -98,7 +98,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { inject, watch, nextTick, onMounted, defineAsyncComponent } from 'vue';
+import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor';
@@ -143,15 +143,22 @@ const props = withDefaults(defineProps<{
fixed?: boolean;
autofocus?: boolean;
freezeAfterPosted?: boolean;
+ mock?: boolean;
}>(), {
initialVisibleUsers: () => [],
autofocus: true,
+ mock: false,
});
+provide('mock', props.mock);
+
const emit = defineEmits<{
(ev: 'posted'): void;
(ev: 'cancel'): void;
(ev: 'esc'): void;
+
+ // Mock用
+ (ev: 'fileChangeSensitive', fileId: string, to: boolean): void;
}>();
const textareaEl = $shallowRef<HTMLTextAreaElement | null>(null);
@@ -239,7 +246,7 @@ const maxTextLength = $computed((): number => {
});
const canPost = $computed((): boolean => {
- return !posting && !posted &&
+ return !props.mock && !posting && !posted &&
(1 <= textLength || 1 <= files.length || !!poll || !!props.renote) &&
(textLength <= maxTextLength) &&
(!poll || poll.choices.length >= 2);
@@ -396,6 +403,8 @@ function focus() {
}
function chooseFileFrom(ev) {
+ if (props.mock) return;
+
selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => {
for (const file of files_) {
files.push(file);
@@ -408,6 +417,9 @@ function detachFile(id) {
}
function updateFileSensitive(file, sensitive) {
+ if (props.mock) {
+ emit('fileChangeSensitive', file.id, sensitive);
+ }
files[files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
}
@@ -420,6 +432,8 @@ function replaceFile(file: Misskey.entities.DriveFile, newFile: Misskey.entities
}
function upload(file: File, name?: string): void {
+ if (props.mock) return;
+
uploadFile(file, defaultStore.state.uploadFolder, name).then(res => {
files.push(res);
});
@@ -545,6 +559,8 @@ function onCompositionEnd(ev: CompositionEvent) {
}
async function onPaste(ev: ClipboardEvent) {
+ if (props.mock) return;
+
for (const { item, i } of Array.from(ev.clipboardData.items, (item, i) => ({ item, i }))) {
if (item.kind === 'file') {
const file = item.getAsFile();
@@ -629,7 +645,7 @@ function onDrop(ev): void {
}
function saveDraft() {
- if (props.instant) return;
+ if (props.instant || props.mock) return;
const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}');
@@ -674,6 +690,8 @@ async function post(ev?: MouseEvent) {
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
+ if (props.mock) return;
+
const annoying =
text.includes('$[x2') ||
text.includes('$[x3') ||
@@ -839,6 +857,8 @@ function showActions(ev) {
let postAccount = $ref<Misskey.entities.UserDetailed | null>(null);
function openAccountMenu(ev: MouseEvent) {
+ if (props.mock) return;
+
openAccountMenu_({
withExtraOperation: false,
includeCurrentAccount: true,
@@ -869,7 +889,7 @@ onMounted(() => {
nextTick(() => {
// 書きかけの投稿を復元
- if (!props.instant && !props.mention && !props.specified) {
+ if (!props.instant && !props.mention && !props.specified && !props.mock) {
const draft = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}')[draftKey];
if (draft) {
text = draft.data.text;
diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue
index d499a22ed6..28a09c571f 100644
--- a/packages/frontend/src/components/MkPostFormAttaches.vue
+++ b/packages/frontend/src/components/MkPostFormAttaches.vue
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { defineAsyncComponent } from 'vue';
+import { defineAsyncComponent, inject } from 'vue';
import * as Misskey from 'misskey-js';
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
import * as os from '@/os.js';
@@ -33,6 +33,8 @@ const props = defineProps<{
detachMediaFn?: (id: string) => void;
}>();
+const mock = inject<boolean>('mock', false);
+
const emit = defineEmits<{
(ev: 'update:modelValue', value: any[]): void;
(ev: 'detach', id: string): void;
@@ -44,6 +46,8 @@ const emit = defineEmits<{
let menuShowing = false;
function detachMedia(id: string) {
+ if (mock) return;
+
if (props.detachMediaFn) {
props.detachMediaFn(id);
} else {
@@ -52,6 +56,11 @@ function detachMedia(id: string) {
}
function toggleSensitive(file) {
+ if (mock) {
+ emit('changeSensitive', file, !file.isSensitive);
+ return;
+ }
+
os.api('drive/files/update', {
fileId: file.id,
isSensitive: !file.isSensitive,
@@ -61,6 +70,8 @@ function toggleSensitive(file) {
}
async function rename(file) {
+ if (mock) return;
+
const { canceled, result } = await os.inputText({
title: i18n.ts.enterFileName,
default: file.name,
@@ -77,6 +88,8 @@ async function rename(file) {
}
async function describe(file) {
+ if (mock) return;
+
os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
default: file.comment !== null ? file.comment : '',
file: file,
@@ -94,6 +107,8 @@ async function describe(file) {
}
async function crop(file: Misskey.entities.DriveFile): Promise<void> {
+ if (mock) return;
+
const newFile = await os.cropImage(file, { aspectRatio: NaN });
emit('replaceFile', file, newFile);
}
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index d0db515219..d532ef9b66 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { computed, onMounted, shallowRef, watch } from 'vue';
+import { computed, inject, onMounted, shallowRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import XDetails from '@/components/MkReactionsViewer.details.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
@@ -36,6 +36,12 @@ const props = defineProps<{
note: Misskey.entities.Note;
}>();
+const mock = inject<boolean>('mock', false);
+
+const emit = defineEmits<{
+ (ev: 'reactionToggled', emoji: string, newCount: number): void;
+}>();
+
const buttonEl = shallowRef<HTMLElement>();
const canToggle = computed(() => !props.reaction.match(/@\w/) && $i);
@@ -53,6 +59,11 @@ async function toggleReaction() {
});
if (confirm.canceled) return;
+ if (mock) {
+ emit('reactionToggled', props.reaction, (props.count - 1));
+ return;
+ }
+
os.api('notes/reactions/delete', {
noteId: props.note.id,
}).then(() => {
@@ -64,6 +75,11 @@ async function toggleReaction() {
}
});
} else {
+ if (mock) {
+ emit('reactionToggled', props.reaction, (props.count + 1));
+ return;
+ }
+
os.api('notes/reactions/create', {
noteId: props.note.id,
reaction: props.reaction,
@@ -92,24 +108,26 @@ onMounted(() => {
if (!props.isInitial) anime();
});
-useTooltip(buttonEl, async (showing) => {
- const reactions = await os.apiGet('notes/reactions', {
- noteId: props.note.id,
- type: props.reaction,
- limit: 11,
- _cacheKey_: props.count,
- });
+if (!mock) {
+ useTooltip(buttonEl, async (showing) => {
+ const reactions = await os.apiGet('notes/reactions', {
+ noteId: props.note.id,
+ type: props.reaction,
+ limit: 11,
+ _cacheKey_: props.count,
+ });
- const users = reactions.map(x => x.user);
+ const users = reactions.map(x => x.user);
- os.popup(XDetails, {
- showing,
- reaction: props.reaction,
- users,
- count: props.count,
- targetElement: buttonEl.value,
- }, {}, 'closed');
-}, 100);
+ os.popup(XDetails, {
+ showing,
+ reaction: props.reaction,
+ users,
+ count: props.count,
+ targetElement: buttonEl.value,
+ }, {}, 'closed');
+ }, 100);
+}
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue
index 52ead19a4b..eaa7faa4f9 100644
--- a/packages/frontend/src/components/MkReactionsViewer.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.vue
@@ -12,14 +12,14 @@ SPDX-License-Identifier: AGPL-3.0-only
:moveClass="defaultStore.state.animation ? $style.transition_x_move : ''"
tag="div" :class="$style.root"
>
- <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note"/>
+ <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/>
<slot v-if="hasMoreReactions" name="more"/>
</TransitionGroup>
</template>
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
-import { watch } from 'vue';
+import { inject, watch } from 'vue';
import XReaction from '@/components/MkReactionsViewer.reaction.vue';
import { defaultStore } from '@/store.js';
@@ -30,6 +30,12 @@ const props = withDefaults(defineProps<{
maxNumber: Infinity,
});
+const mock = inject<boolean>('mock', false);
+
+const emit = defineEmits<{
+ (ev: 'mockUpdateMyReaction', emoji: string, delta: number): void;
+}>();
+
const initialReactions = new Set(Object.keys(props.note.reactions));
let reactions = $ref<[string, number][]>([]);
@@ -39,6 +45,15 @@ if (props.note.myReaction && !Object.keys(reactions).includes(props.note.myReact
reactions[props.note.myReaction] = props.note.reactions[props.note.myReaction];
}
+function onMockToggleReaction(emoji: string, count: number) {
+ if (!mock) return;
+
+ const i = reactions.findIndex((item) => item[0] === emoji);
+ if (i < 0) return;
+
+ emit('mockUpdateMyReaction', emoji, (count - reactions[i][1]));
+}
+
watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumber]) => {
let newReactions: [string, number][] = [];
hasMoreReactions = Object.keys(newSource).length > maxNumber;
diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue
new file mode 100644
index 0000000000..c7df1a576e
--- /dev/null
+++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue
@@ -0,0 +1,117 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div v-if="phase === 'aboutNote'" class="_gaps">
+ <div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._note.description }}</div>
+ <MkNote :class="$style.exampleNoteRoot" style="pointer-events: none;" :note="exampleNote" :mock="true"/>
+ <div class="_gaps_s">
+ <div><i class="ti ti-arrow-back-up"></i> <b>{{ i18n.ts.reply }}</b> … {{ i18n.ts._initialTutorial._note.reply }}</div>
+ <div><i class="ti ti-repeat"></i> <b>{{ i18n.ts.renote }}</b> … {{ i18n.ts._initialTutorial._note.renote }}</div>
+ <div><i class="ti ti-plus"></i> <b>{{ i18n.ts.reaction }}</b> … {{ i18n.ts._initialTutorial._note.reaction }}</div>
+ <div><i class="ti ti-dots"></i> <b>{{ i18n.ts.menu }}</b> … {{ i18n.ts._initialTutorial._note.menu }}</div>
+ </div>
+</div>
+<div v-else-if="phase === 'howToReact'" class="_gaps">
+ <div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._reaction.description }}</div>
+ <div>{{ i18n.ts._initialTutorial._reaction.letsTryReacting }}</div>
+ <MkNote :class="$style.exampleNoteRoot" :note="exampleNote" :mock="true" @reaction="addReaction" @removeReaction="removeReaction" @updateReaction="updateReaction"/>
+ <div v-if="onceReacted"><b style="color: var(--accent);"><i class="ti ti-check"></i> {{ i18n.ts._initialTutorial.wellDone }}</b> {{ i18n.ts._initialTutorial._reaction.reactNotification }}<br>{{ i18n.ts._initialTutorial._reaction.reactDone }}</div>
+</div>
+</template>
+
+<script setup lang="ts">
+import * as Misskey from 'misskey-js';
+import { ref, reactive } from 'vue';
+import { i18n } from '@/i18n.js';
+import { globalEvents } from '@/events.js';
+import { $i } from '@/account.js';
+import MkNote from '@/components/MkNote.vue';
+
+const props = defineProps<{
+ phase: 'aboutNote' | 'howToReact';
+}>();
+
+const emit = defineEmits<{
+ (ev: 'reacted'): void;
+}>();
+
+const exampleNote = reactive<Misskey.entities.Note>({
+ id: '0000000000',
+ createdAt: '2019-04-14T17:30:49.181Z',
+ userId: '0000000001',
+ user: {
+ id: '0000000001',
+ name: '藍',
+ username: 'ai',
+ host: null,
+ avatarDecorations: [],
+ avatarUrl: '/client-assets/tutorial/ai.webp',
+ avatarBlurhash: 'eiKmhHIByXxZ~qWXs:-pR*NbR*s:xuRjoL-oR*WCt6WWf6WVf6oeWB',
+ isBot: false,
+ isCat: true,
+ emojis: {},
+ onlineStatus: null,
+ badgeRoles: [],
+ },
+ text: 'just setting up my msky',
+ cw: null,
+ visibility: 'public',
+ localOnly: false,
+ reactionAcceptance: null,
+ renoteCount: 0,
+ repliesCount: 1,
+ reactions: {},
+ reactionEmojis: {},
+ fileIds: [],
+ files: [],
+ replyId: null,
+ renoteId: null,
+});
+const onceReacted = ref<boolean>(false);
+
+function addReaction(emoji) {
+ onceReacted.value = true;
+ emit('reacted');
+ exampleNote.reactions[emoji] = 1;
+ exampleNote.myReaction = emoji;
+ doNotification(emoji);
+}
+
+function doNotification(emoji: string): void {
+ if (!$i || !emoji) return;
+
+ const notification: Misskey.entities.Notification = {
+ id: Math.random().toString(),
+ createdAt: new Date().toUTCString(),
+ isRead: false,
+ type: 'reaction',
+ reaction: emoji,
+ user: $i,
+ userId: $i.id,
+ note: exampleNote,
+ };
+
+ globalEvents.emit('clientNotification', notification);
+}
+
+function removeReaction(emoji) {
+ delete exampleNote.reactions[emoji];
+ exampleNote.myReaction = undefined;
+}
+</script>
+
+<style lang="scss" module>
+.exampleNoteRoot {
+ border-radius: var(--radius);
+ border: var(--panelBorder);
+ background: var(--panel);
+}
+
+.divider {
+ height: 1px;
+ background: var(--divider);
+}
+</style>
diff --git a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue
new file mode 100644
index 0000000000..9b55a1dca7
--- /dev/null
+++ b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue
@@ -0,0 +1,135 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+ <div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._postNote.description1 }}</div>
+ <MkPostForm :class="$style.exampleRoot" :mock="true"/>
+ <MkFormSection>
+ <template #label>{{ i18n.ts.visibility }}</template>
+ <div class="_gaps">
+ <div>{{ i18n.ts._initialTutorial._postNote._visibility.description }}</div>
+ <div><i class="ti ti-world"></i> <b>{{ i18n.ts._visibility.public }}</b> … {{ i18n.ts._initialTutorial._postNote._visibility.public }}</div>
+ <div><i class="ti ti-home"></i> <b>{{ i18n.ts._visibility.home }}</b> … {{ i18n.ts._initialTutorial._postNote._visibility.home }}</div>
+ <div><i class="ti ti-lock"></i> <b>{{ i18n.ts._visibility.followers }}</b> … {{ i18n.ts._initialTutorial._postNote._visibility.followers }}</div>
+ <div class="_gaps_s">
+ <div><i class="ti ti-mail"></i> <b>{{ i18n.ts._visibility.specified }}</b> … {{ i18n.ts._initialTutorial._postNote._visibility.direct }}</div>
+ <MkInfo :warn="true">
+ <b>{{ i18n.ts._initialTutorial._postNote._visibility.doNotSendConfidencialOnDirect1 }}</b> {{ i18n.ts._initialTutorial._postNote._visibility.doNotSendConfidencialOnDirect2 }}
+ </MkInfo>
+ </div>
+ <div><i class="ti ti-rocket-off"></i> <b>{{ i18n.ts._visibility.disableFederation }}</b> … {{ i18n.ts._initialTutorial._postNote._visibility.localOnly }}</div>
+ </div>
+ </MkFormSection>
+ <MkFormSection>
+ <template #label>{{ i18n.ts._initialTutorial._postNote._cw.title }}</template>
+ <div class="_gaps">
+ <div>{{ i18n.ts._initialTutorial._postNote._cw.description }}</div>
+ <MkNote :class="$style.exampleRoot" :note="exampleCWNote" :mock="true"/>
+ <div>{{ i18n.ts._initialTutorial._postNote._cw.useCases }}</div>
+ </div>
+ </MkFormSection>
+</div>
+</template>
+
+<script setup lang="ts">
+import * as Misskey from 'misskey-js';
+import { reactive } from 'vue';
+import { i18n } from '@/i18n.js';
+import MkNote from '@/components/MkNote.vue';
+import MkPostForm from '@/components/MkPostForm.vue';
+import MkFormSection from '@/components/form/section.vue';
+import MkInfo from '@/components/MkInfo.vue';
+
+const exampleCWNote = reactive<Misskey.entities.Note>({
+ id: '0000000000',
+ createdAt: '2019-04-14T17:30:49.181Z',
+ userId: '0000000001',
+ user: {
+ id: '0000000001',
+ name: '藍',
+ username: 'ai',
+ host: null,
+ avatarDecorations: [],
+ avatarUrl: '/client-assets/tutorial/ai.webp',
+ avatarBlurhash: 'eiKmhHIByXxZ~qWXs:-pR*NbR*s:xuRjoL-oR*WCt6WWf6WVf6oeWB',
+ isBot: false,
+ isCat: true,
+ emojis: {},
+ onlineStatus: null,
+ badgeRoles: [],
+ },
+ text: i18n.ts._initialTutorial._postNote._cw._exampleNote.note,
+ cw: i18n.ts._initialTutorial._postNote._cw._exampleNote.cw,
+ visibility: 'public',
+ localOnly: false,
+ reactionAcceptance: null,
+ renoteCount: 0,
+ repliesCount: 1,
+ reactions: {},
+ reactionEmojis: {},
+ fileIds: [],
+ files: [],
+ replyId: null,
+ renoteId: null,
+});
+</script>
+
+<style lang="scss" module>
+.exampleRoot {
+ max-width: none!important;
+ border-radius: var(--radius);
+ border: var(--panelBorder);
+ background: var(--panel);
+}
+
+.divider {
+ height: 1px;
+ background: var(--divider);
+}
+
+.image {
+ max-width: 300px;
+ margin: 0 auto;
+}
+
+.post {
+ position: relative;
+ display: block;
+ width: 100%;
+ height: 40px;
+ color: var(--fgOnAccent);
+ font-weight: bold;
+ text-align: left;
+
+ &:before {
+ content: "";
+ display: block;
+ width: calc(100% - 38px);
+ height: 100%;
+ margin: auto;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+ }
+
+}
+
+.postIcon {
+ position: relative;
+ margin-left: 30px;
+ margin-right: 8px;
+ width: 32px;
+}
+
+.postText {
+ position: relative;
+ line-height: 40px;
+}
+</style>
diff --git a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
new file mode 100644
index 0000000000..768d00bb07
--- /dev/null
+++ b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
@@ -0,0 +1,144 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+ <div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.description }}</div>
+ <div>{{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.tryThisFile }}</div>
+ <MkInfo>{{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.method }}</MkInfo>
+ <MkPostForm
+ :class="$style.exampleRoot"
+ :mock="true"
+ :initialNote="exampleNote"
+ @fileChangeSensitive="doSucceeded"
+ ></MkPostForm>
+ <div v-if="onceSucceeded"><b style="color: var(--accent);"><i class="ti ti-check"></i> {{ i18n.ts._initialTutorial.wellDone }}</b> {{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.sensitiveSucceeded }}</div>
+ <MkFolder>
+ <template #label>{{ i18n.ts.previewNoteText }}</template>
+ <MkNote :mock="true" :note="exampleNote" :class="$style.exampleRoot"></MkNote>
+ </MkFolder>
+</div>
+</template>
+
+<script setup lang="ts">
+import * as Misskey from 'misskey-js';
+import { ref, reactive } from 'vue';
+import { i18n } from '@/i18n.js';
+import MkPostForm from '@/components/MkPostForm.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import MkNote from '@/components/MkNote.vue';
+import { $i } from '@/account.js';
+
+const emit = defineEmits<{
+ (ev: 'succeeded'): void;
+}>();
+
+const onceSucceeded = ref<boolean>(false);
+
+function doSucceeded(fileId: string, to: boolean) {
+ if (fileId === exampleNote.fileIds[0] && to) {
+ onceSucceeded.value = true;
+ emit('succeeded');
+ }
+}
+
+const exampleNote = reactive<Misskey.entities.Note>({
+ id: '0000000000',
+ createdAt: '2019-04-14T17:30:49.181Z',
+ userId: '0000000001',
+ user: $i!,
+ text: i18n.ts._initialTutorial._howToMakeAttachmentsSensitive._exampleNote.note,
+ cw: null,
+ visibility: 'public',
+ localOnly: false,
+ reactionAcceptance: null,
+ renoteCount: 0,
+ repliesCount: 1,
+ reactions: {},
+ reactionEmojis: {},
+ fileIds: ['0000000002'],
+ files: [{
+ id: '0000000002',
+ createdAt: '2019-04-14T17:30:49.181Z',
+ name: 'natto_failed.webp',
+ type: 'image/webp',
+ md5: 'c44286cf152d0740be0ce5ad45ea85c3',
+ size: 827532,
+ isSensitive: false,
+ blurhash: 'LXNA3TD*XAIA%1%M%gt7.TofRioz',
+ properties: {
+ width: 256,
+ height: 256,
+ },
+ url: '/client-assets/tutorial/natto_failed.webp',
+ thumbnailUrl: '/client-assets/tutorial/natto_failed.webp',
+ comment: null,
+ folderId: null,
+ folder: null,
+ userId: null,
+ user: null,
+ }],
+ replyId: null,
+ renoteId: null,
+});
+
+</script>
+
+<style lang="scss" module>
+.exampleRoot {
+ border-radius: var(--radius);
+ border: var(--panelBorder);
+ background: var(--panel);
+}
+
+.divider {
+ height: 1px;
+ background: var(--divider);
+}
+
+.image {
+ max-width: 300px;
+ margin: 0 auto;
+}
+
+.post {
+ position: relative;
+ display: block;
+ width: 100%;
+ height: 40px;
+ color: var(--fgOnAccent);
+ font-weight: bold;
+ text-align: left;
+
+ &:before {
+ content: "";
+ display: block;
+ width: calc(100% - 38px);
+ height: 100%;
+ margin: auto;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+ }
+
+}
+
+.postIcon {
+ position: relative;
+ margin-left: 30px;
+ margin-right: 8px;
+ width: 32px;
+}
+
+.postText {
+ position: relative;
+ line-height: 40px;
+}
+</style>
diff --git a/packages/frontend/src/components/MkTutorialDialog.Timeline.vue b/packages/frontend/src/components/MkTutorialDialog.Timeline.vue
new file mode 100644
index 0000000000..75b917f33c
--- /dev/null
+++ b/packages/frontend/src/components/MkTutorialDialog.Timeline.vue
@@ -0,0 +1,87 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+ <div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._timeline.description1 }}</div>
+ <div class="_gaps_s">
+ <div><i class="ti ti-home"></i> <b>{{ i18n.ts._timelines.home }}</b> … {{ i18n.ts._initialTutorial._timeline.home }}</div>
+ <div><i class="ti ti-planet"></i> <b>{{ i18n.ts._timelines.local }}</b> … {{ i18n.ts._initialTutorial._timeline.local }}</div>
+ <div><i class="ti ti-universe"></i> <b>{{ i18n.ts._timelines.social }}</b> … {{ i18n.ts._initialTutorial._timeline.social }}</div>
+ <div><i class="ti ti-whirl"></i> <b>{{ i18n.ts._timelines.global }}</b> … {{ i18n.ts._initialTutorial._timeline.global }}</div>
+ </div>
+ <div class="_gaps_s">
+ <div>{{ i18n.ts._initialTutorial._timeline.description2 }}</div>
+ <img :class="$style.image" src="/client-assets/tutorial/timeline_tab.png"/>
+ </div>
+ <div :class="$style.divider"></div>
+ <I18n :src="i18n.ts._initialTutorial._timeline.description3" tag="div" style="padding: 0 16px;">
+ <template #link>
+ <a href="https://misskey-hub.net/docs/features/timeline.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
+ </template>
+ </I18n>
+
+</div>
+</template>
+
+<script setup lang="ts">
+import { i18n } from '@/i18n.js';
+</script>
+
+<style lang="scss" module>
+.exampleNoteRoot {
+ border-radius: var(--radius);
+ border: var(--panelBorder);
+ background: var(--panel);
+}
+
+.divider {
+ height: 1px;
+ background: var(--divider);
+}
+
+.image {
+ max-width: 300px;
+ margin: 0 auto;
+}
+
+.post {
+ position: relative;
+ display: block;
+ width: 100%;
+ height: 40px;
+ color: var(--fgOnAccent);
+ font-weight: bold;
+ text-align: left;
+
+ &:before {
+ content: "";
+ display: block;
+ width: calc(100% - 38px);
+ height: 100%;
+ margin: auto;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+ }
+
+}
+
+.postIcon {
+ position: relative;
+ margin-left: 30px;
+ margin-right: 8px;
+ width: 32px;
+}
+
+.postText {
+ position: relative;
+ line-height: 40px;
+}
+</style>
diff --git a/packages/frontend/src/components/MkTutorialDialog.vue b/packages/frontend/src/components/MkTutorialDialog.vue
new file mode 100644
index 0000000000..e28838425f
--- /dev/null
+++ b/packages/frontend/src/components/MkTutorialDialog.vue
@@ -0,0 +1,260 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+ ref="dialog"
+ :width="600"
+ :height="650"
+ @close="close(true)"
+ @closed="emit('closed')"
+>
+ <template v-if="page === 1" #header><i class="ti ti-pencil"></i> {{ i18n.ts._initialTutorial._note.title }}</template>
+ <template v-else-if="page === 2" #header><i class="ti ti-mood-smile"></i> {{ i18n.ts._initialTutorial._reaction.title }}</template>
+ <template v-else-if="page === 3" #header><i class="ti ti-home"></i> {{ i18n.ts._initialTutorial._timeline.title }}</template>
+ <template v-else-if="page === 4" #header><i class="ti ti-pencil-plus"></i> {{ i18n.ts._initialTutorial._postNote.title }}</template>
+ <template v-else-if="page === 5" #header><i class="ti ti-eye-exclamation"></i> {{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.title }}</template>
+ <template v-else #header>{{ i18n.ts._initialTutorial.title }}</template>
+
+ <div style="overflow-x: clip;">
+ <Transition
+ mode="out-in"
+ :enterActiveClass="$style.transition_x_enterActive"
+ :leaveActiveClass="$style.transition_x_leaveActive"
+ :enterFromClass="$style.transition_x_enterFrom"
+ :leaveToClass="$style.transition_x_leaveTo"
+ >
+ <template v-if="page === 0">
+ <div :class="$style.centerPage">
+ <MkAnimBg style="position: absolute; top: 0;" :scale="1.5"/>
+ <MkSpacer :marginMin="20" :marginMax="28">
+ <div class="_gaps" style="text-align: center;">
+ <i class="ti ti-confetti" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
+ <div style="font-size: 120%;">{{ i18n.ts._initialTutorial._landing.title }}</div>
+ <div>{{ i18n.ts._initialTutorial._landing.description }}</div>
+ <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" @click="page++">{{ i18n.ts._initialTutorial.launchTutorial }} <i class="ti ti-arrow-right"></i></MkButton>
+ <MkButton style="margin: 0 auto;" transparent rounded @click="close(true)">{{ i18n.ts.close }}</MkButton>
+ </div>
+ </MkSpacer>
+ </div>
+ </template>
+ <template v-else-if="page === 1">
+ <div style="height: 100cqh; overflow: auto;">
+ <div :class="$style.pageRoot">
+ <MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
+ <XNote phase="aboutNote"/>
+ </MkSpacer>
+ <div :class="$style.pageFooter">
+ <div class="_buttonsCenter">
+ <MkButton v-if="initialPage !== 1" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
+ <MkButton primary rounded gradate @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+ <template v-else-if="page === 2">
+ <div style="height: 100cqh; overflow: auto;">
+ <div :class="$style.pageRoot">
+ <MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
+ <div class="_gaps">
+ <XNote phase="howToReact" @reacted="isReactionTutorialPushed = true"/>
+ <div v-if="!isReactionTutorialPushed">{{ i18n.ts._initialTutorial._reaction.reactToContinue }}</div>
+ </div>
+ </MkSpacer>
+ <div :class="$style.pageFooter">
+ <div class="_buttonsCenter">
+ <MkButton v-if="initialPage !== 2" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
+ <MkButton primary rounded gradate :disabled="!isReactionTutorialPushed" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+ <template v-else-if="page === 3">
+ <div style="height: 100cqh; overflow: auto;">
+ <div :class="$style.pageRoot">
+ <MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
+ <XTimeline/>
+ </MkSpacer>
+ <div :class="$style.pageFooter">
+ <div class="_buttonsCenter">
+ <MkButton v-if="initialPage !== 3" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
+ <MkButton primary rounded gradate @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+ <template v-else-if="page === 4">
+ <div style="height: 100cqh; overflow: auto;">
+ <div :class="$style.pageRoot">
+ <MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
+ <XPostNote/>
+ </MkSpacer>
+ <div :class="$style.pageFooter">
+ <div class="_buttonsCenter">
+ <MkButton v-if="initialPage !== 3" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
+ <MkButton primary rounded gradate @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+ <template v-else-if="page === 5">
+ <div style="height: 100cqh; overflow: auto;">
+ <div :class="$style.pageRoot">
+ <MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
+ <div class="_gaps">
+ <XSensitive @succeeded="isSensitiveTutorialSucceeded = true"/>
+ <div v-if="!isSensitiveTutorialSucceeded">{{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.doItToContinue }}</div>
+ </div>
+ </MkSpacer>
+ <div :class="$style.pageFooter">
+ <div class="_buttonsCenter">
+ <MkButton v-if="initialPage !== 2" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
+ <MkButton primary rounded gradate :disabled="!isSensitiveTutorialSucceeded" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+ <template v-else-if="page === 6">
+ <div :class="$style.centerPage">
+ <MkAnimBg style="position: absolute; top: 0;" :scale="1.5"/>
+ <MkSpacer :marginMin="20" :marginMax="28">
+ <div class="_gaps" style="text-align: center;">
+ <i class="ti ti-check" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
+ <div style="font-size: 120%;">{{ i18n.ts._initialTutorial._done.title }}</div>
+ <I18n :src="i18n.ts._initialTutorial._done.description" tag="div" style="padding: 0 16px;">
+ <template #link>
+ <a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
+ </template>
+ </I18n>
+ <div>{{ i18n.t('_initialAccountSetting.haveFun', { name: instance.name ?? host }) }}</div>
+ <div class="_buttonsCenter" style="margin-top: 16px;">
+ <MkButton v-if="initialPage !== 4" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
+ <MkButton rounded primary gradate @click="close(false)">{{ i18n.ts.close }}</MkButton>
+ </div>
+ </div>
+ </MkSpacer>
+ </div>
+ </template>
+ </Transition>
+ </div>
+</MkModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { ref, shallowRef, watch } from 'vue';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import MkButton from '@/components/MkButton.vue';
+import XNote from '@/components/MkTutorialDialog.Note.vue';
+import XTimeline from '@/components/MkTutorialDialog.Timeline.vue';
+import XPostNote from '@/components/MkTutorialDialog.PostNote.vue';
+import XSensitive from '@/components/MkTutorialDialog.Sensitive.vue';
+import MkAnimBg from '@/components/MkAnimBg.vue';
+import { i18n } from '@/i18n.js';
+import { instance } from '@/instance.js';
+import { host } from '@/config.js';
+import { claimAchievement } from '@/scripts/achievements.js';
+import * as os from '@/os.js';
+
+const props = defineProps<{
+ initialPage?: number;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'closed'): void;
+}>();
+
+const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+
+// eslint-disable-next-line vue/no-setup-props-destructure
+const page = ref(props.initialPage ?? 0);
+
+watch(page, (to) => {
+ // チュートリアルの枚数を増やしたら必ず変更すること!!
+ if (to === 6) {
+ claimAchievement('tutorialCompleted');
+ }
+});
+
+const isReactionTutorialPushed = ref<boolean>(false);
+const isSensitiveTutorialSucceeded = ref<boolean>(false);
+
+async function close(skip: boolean) {
+ if (skip) {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.ts._initialTutorial.skipAreYouSure,
+ });
+ if (canceled) return;
+ }
+
+ dialog.value?.close();
+}
+</script>
+
+<style lang="scss" module>
+.transition_x_enterActive,
+.transition_x_leaveActive {
+ transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
+}
+.transition_x_enterFrom {
+ opacity: 0;
+ transform: translateX(50px);
+}
+.transition_x_leaveTo {
+ opacity: 0;
+ transform: translateX(-50px);
+}
+
+.progressBar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 10;
+ width: 100%;
+ height: 4px;
+}
+
+.progressBarValue {
+ height: 100%;
+ background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+ transition: all 0.5s cubic-bezier(0,.5,.5,1);
+}
+
+.centerPage {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100cqh;
+ padding-bottom: 30px;
+ box-sizing: border-box;
+}
+
+.pageRoot {
+ display: flex;
+ flex-direction: column;
+ min-height: 100%;
+}
+
+.pageMain {
+ flex-grow: 1;
+ line-height: 1.5;
+}
+
+.pageFooter {
+ position: sticky;
+ bottom: 0;
+ left: 0;
+ flex-shrink: 0;
+ padding: 12px;
+ border-top: solid 0.5px var(--divider);
+ -webkit-backdrop-filter: blur(15px);
+ backdrop-filter: blur(15px);
+}
+</style>
diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue
index d60e01c44d..05b55f77a7 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.vue
@@ -46,24 +46,32 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<template v-else-if="page === 1">
<div style="height: 100cqh; overflow: auto;">
- <MkSpacer :marginMin="20" :marginMax="28">
- <XProfile/>
- <div class="_buttonsCenter" style="margin-top: 16px;">
- <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
- <MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ <div :class="$style.pageRoot">
+ <MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
+ <XProfile/>
+ </MkSpacer>
+ <div :class="$style.pageFooter">
+ <div class="_buttonsCenter">
+ <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
+ <MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
</div>
- </MkSpacer>
+ </div>
</div>
</template>
<template v-else-if="page === 2">
<div style="height: 100cqh; overflow: auto;">
- <MkSpacer :marginMin="20" :marginMax="28">
- <XPrivacy/>
- <div class="_buttonsCenter" style="margin-top: 16px;">
- <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
- <MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ <div :class="$style.pageRoot">
+ <MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
+ <XPrivacy/>
+ </MkSpacer>
+ <div :class="$style.pageFooter">
+ <div class="_buttonsCenter">
+ <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
+ <MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
</div>
- </MkSpacer>
+ </div>
</div>
</template>
<template v-else-if="page === 3">
@@ -102,16 +110,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps" style="text-align: center;">
<i class="ti ti-check" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
<div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.initialAccountSettingCompleted }}</div>
- <I18n :src="i18n.ts._initialAccountSetting.ifYouNeedLearnMore" tag="div" style="padding: 0 16px;">
- <template #name>{{ instance.name ?? host }}</template>
- <template #link>
- <a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
- </template>
- </I18n>
- <div>{{ i18n.t('_initialAccountSetting.haveFun', { name: instance.name ?? host }) }}</div>
+ <div>{{ i18n.t('_initialAccountSetting.youCanContinueTutorial', { name: instance.name ?? host }) }}</div>
<div class="_buttonsCenter" style="margin-top: 16px;">
+ <MkButton rounded primary gradate data-cy-user-setup-continue @click="launchTutorial()">{{ i18n.ts._initialAccountSetting.startTutorial }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
+ <div class="_buttonsCenter">
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
- <MkButton primary rounded gradate data-cy-user-setup-continue @click="close(false)">{{ i18n.ts.close }}</MkButton>
+ <MkButton rounded primary data-cy-user-setup-continue @click="setupComplete()">{{ i18n.ts.close }}</MkButton>
</div>
</div>
</MkSpacer>
@@ -123,7 +128,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { ref, shallowRef, watch } from 'vue';
+import { ref, shallowRef, watch, nextTick, defineAsyncComponent } from 'vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue';
import XProfile from '@/components/MkUserSetupDialog.Profile.vue';
@@ -143,6 +148,7 @@ const emit = defineEmits<{
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+// eslint-disable-next-line vue/no-setup-props-destructure
const page = ref(defaultStore.state.accountSetupWizard);
watch(page, () => {
@@ -158,8 +164,22 @@ async function close(skip: boolean) {
if (canceled) return;
}
- dialog.value.close();
+ dialog.value?.close();
+ defaultStore.set('accountSetupWizard', -1);
+}
+
+function setupComplete() {
defaultStore.set('accountSetupWizard', -1);
+ dialog.value?.close();
+}
+
+function launchTutorial() {
+ setupComplete();
+ nextTick(() => {
+ os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {
+ initialPage: 1,
+ }, {}, 'closed');
+ });
}
async function later(later: boolean) {
@@ -171,7 +191,7 @@ async function later(later: boolean) {
if (canceled) return;
}
- dialog.value.close();
+ dialog.value?.close();
defaultStore.set('accountSetupWizard', 0);
}
</script>
@@ -214,10 +234,21 @@ async function later(later: boolean) {
box-sizing: border-box;
}
+.pageRoot {
+ display: flex;
+ flex-direction: column;
+ min-height: 100%;
+}
+
+.pageMain {
+ flex-grow: 1;
+}
+
.pageFooter {
position: sticky;
bottom: 0;
left: 0;
+ flex-shrink: 0;
padding: 12px;
border-top: solid 0.5px var(--divider);
-webkit-backdrop-filter: blur(15px);