diff --git a/CHANGELOG.md b/CHANGELOG.md index 6837cf6e1b..4fdf9687ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください https://misskey-hub.net/docs/advanced/publish-on-your-website.html - Feat: 通知をグルーピングして表示するオプション(オプトアウト) +- Feat: Misskeyの基本的なチュートリアルを実装 - Feat: スワイプしてタイムラインを再読込できるように - PCの場合は右上のボタンからでも再読込できます - Enhance: タイムラインの自動更新を無効にできるように diff --git a/locales/index.d.ts b/locales/index.d.ts index f6db40e944..b45559eea2 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1182,10 +1182,91 @@ export interface Locale { "pushNotificationDescription": string; "initialAccountSettingCompleted": string; "haveFun": string; - "ifYouNeedLearnMore": string; + "youCanContinueTutorial": string; + "startTutorial": string; "skipAreYouSure": string; "laterAreYouSure": string; }; + "_initialTutorial": { + "launchTutorial": string; + "title": string; + "wellDone": string; + "skipAreYouSure": string; + "_landing": { + "title": string; + "description": string; + }; + "_note": { + "title": string; + "description": string; + "reply": string; + "renote": string; + "reaction": string; + "menu": string; + }; + "_reaction": { + "title": string; + "description": string; + "letsTryReacting": string; + "reactToContinue": string; + "reactNotification": string; + "reactDone": string; + }; + "_timeline": { + "title": string; + "description1": string; + "home": string; + "local": string; + "social": string; + "global": string; + "description2": string; + "description3": string; + }; + "_postNote": { + "title": string; + "description1": string; + "_visibility": { + "description": string; + "public": string; + "home": string; + "followers": string; + "direct": string; + "doNotSendConfidencialOnDirect1": string; + "doNotSendConfidencialOnDirect2": string; + "localOnly": string; + }; + "_cw": { + "title": string; + "description": string; + "_exampleNote": { + "cw": string; + "note": string; + }; + "useCases": string; + }; + }; + "_howToMakeAttachmentsSensitive": { + "title": string; + "description": string; + "tryThisFile": string; + "_exampleNote": { + "note": string; + }; + "method": string; + "sensitiveSucceeded": string; + "doItToContinue": string; + }; + "_done": { + "title": string; + "description": string; + }; + }; + "_timelineDescription": { + "home": string; + "local": string; + "social": string; + "global": string; + }; "_serverRules": { "description": string; }; @@ -1533,6 +1614,10 @@ export interface Locale { "title": string; "description": string; }; + "_tutorialCompleted": { + "title": string; + "description": string; + }; }; }; "_role": { @@ -1861,17 +1946,6 @@ export interface Locale { "hour": string; "day": string; }; - "_timelineTutorial": { - "title": string; - "step1_1": string; - "step1_2": string; - "step2_1": string; - "step2_2": string; - "step3_1": string; - "step3_2": string; - "step4_1": string; - "step4_2": string; - }; "_2fa": { "alreadyRegistered": string; "registerTOTP": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 1b79c399e7..8fd77afd92 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1170,7 +1170,7 @@ _announcement: _initialAccountSetting: accountCreated: "アカウントの作成が完了しました!" - letsStartAccountSetup: "アカウントの初期設定を行いましょう。" + letsStartAccountSetup: "さっそくアカウントの初期設定を行いましょう。" letsFillYourProfile: "まずはあなたのプロフィールを設定しましょう。" profileSetting: "プロフィール設定" privacySetting: "プライバシー設定" @@ -1180,10 +1180,80 @@ _initialAccountSetting: pushNotificationDescription: "プッシュ通知を有効にすると{name}の通知をお使いのデバイスで受け取ることができます。" initialAccountSettingCompleted: "初期設定が完了しました!" haveFun: "{name}をお楽しみください!" - ifYouNeedLearnMore: "{name}(Misskey)の使い方などを詳しく知るには{link}をご覧ください。" + youCanContinueTutorial: "このまま{name}(Misskey)の使い方についてのチュートリアルに進むこともできますが、ここで中断してすぐに使い始めることもできます。" + startTutorial: "チュートリアルを開始" skipAreYouSure: "初期設定をスキップしますか?" laterAreYouSure: "初期設定をあとでやり直しますか?" +_initialTutorial: + launchTutorial: "チュートリアルを見る" + title: "チュートリアル" + wellDone: "よくできました" + skipAreYouSure: "チュートリアルを終了しますか?" + _landing: + title: "チュートリアルへようこそ" + description: "ここでは、Misskeyの基本的な使い方や機能を確認できます。" + _note: + title: "ノートって何?" + description: "Misskeyでの投稿は「ノート」と呼びます。ノートはタイムラインに時系列で並んでいて、リアルタイムで更新されていきます。" + reply: "返信することができます。返信に対しての返信も可能で、スレッドのように会話を続けることもできます。" + renote: "そのノートを自分のタイムラインに流して共有することができます。テキストを追加して引用することも可能です。" + reaction: "リアクションをつけることができます。詳しくは次のページで解説します。" + menu: "ノートの詳細を表示したり、リンクをコピーしたりなどの様々な操作が行えます。" + _reaction: + title: "リアクションって何?" + description: "ノートには「リアクション」をつけることができます。「いいね」では伝わらないニュアンスも、リアクションで簡単・気軽に表現できます。" + letsTryReacting: "リアクションは、ノートの「+」ボタンをクリックするとつけられます。試しにこのサンプルのノートにリアクションをつけてみてください!" + reactToContinue: "リアクションをつけると先に進めるようになります。" + reactNotification: "あなたのノートが誰かにリアクションされると、リアルタイムで通知を受け取ります。" + reactDone: "「ー」ボタンを押すとリアクションを取り消すことができます。" + _timeline: + title: "タイムラインのしくみ" + description1: "Misskeyには、使い方に応じて複数のタイムラインが用意されています(サーバーによってはいずれかが無効になっていることがあります)。" + home: "あなたがフォローしているアカウントの投稿を見られます。" + local: "このサーバーにいるユーザー全員の投稿を見られます。" + social: "ホームタイムラインとローカルタイムラインの投稿が両方表示されます。" + global: "接続している他のすべてのサーバーからの投稿を見られます。" + description2: "それぞれのタイムラインは、画面上部でいつでも切り替えられます。" + description3: "その他にも、リストタイムラインやチャンネルタイムラインなどがあります。詳しくは{link}をご覧ください。" + _postNote: + title: "ノートの投稿設定" + description1: "Misskeyにノートを投稿する際には、様々なオプションの設定が可能です。投稿フォームはこのようになっています。" + _visibility: + description: "ノートを表示できる相手を制限できます。" + public: "すべてのユーザーに公開。" + home: "ホームタイムラインのみに公開。フォロワー・プロフィールを見に来た人・リノートから、他のユーザーも見ることができます。" + followers: "フォロワーにのみ公開。本人以外がリノートすることはできず、またフォロワー以外は閲覧できません。" + direct: "指定したユーザーにのみ公開され、また相手に通知が入ります。ダイレクトメッセージのかわりにお使いいただけます。" + doNotSendConfidencialOnDirect1: "機密情報は送信する際は注意してください。" + doNotSendConfidencialOnDirect2: "送信先のサーバーの管理者は投稿内容を見ることが可能なので、信頼できないサーバーのユーザーにダイレクト投稿を送信する場合は、機密情報の扱いに注意が必要です。" + localOnly: "他のサーバーに投稿を連合しません。上記の公開範囲に関わらず、他のサーバーのユーザーは、この設定がついたノートを直接閲覧することができなくなります。" + _cw: + title: "内容を隠す(CW)" + description: "本文のかわりに「注釈」に書いた内容が表示されます。「もっと見る」を押すと本文が表示されます。" + _exampleNote: + cw: "飯テロ注意" + note: "チョコのかかったドーナツを食べました🍩😋" + useCases: "サーバーのガイドラインにより必要とされるノートに指定したり、ネタバレ投稿やセンシティブな文章を自主規制したりするときに使います。" + _howToMakeAttachmentsSensitive: + title: "添付ファイルをセンシティブにするには?" + description: "サーバーのガイドラインにより必要とされる際や、そのまま見れる状態にしておくべきではない添付ファイルには、「センシティブ」設定を付けます。" + tryThisFile: "試しに、このフォームに添付された画像をセンシティブにしてみてください!" + _exampleNote: + note: "納豆のフタ開けるのミスったわね…" + method: "添付ファイルをセンシティブにする際は、そのファイルをクリックしてメニューを開き、「センシティブとして設定」をクリックします。" + sensitiveSucceeded: "ファイルを添付する際は、サーバーのガイドラインに従ってセンシティブを適切に設定してください。" + doItToContinue: "画像をセンシティブに設定すると先に進めるようになります。" + _done: + title: "チュートリアルは終了です🎉" + description: "ここで紹介した機能はほんの一部にすぎません。Misskeyの使い方をより詳しく知るには、{link}をご覧ください。" + +_timelineDescription: + home: "ホームタイムラインでは、あなたがフォローしているアカウントの投稿を見られます。" + local: "ローカルタイムラインでは、このサーバーにいるユーザー全員の投稿を見られます。" + social: "ソーシャルタイムラインには、ホームタイムラインとローカルタイムラインの投稿が両方表示されます。" + global: "グローバルタイムラインでは、接続している他のすべてのサーバーからの投稿を見られます。" + _serverRules: description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" @@ -1456,6 +1526,9 @@ _achievements: _smashTestNotificationButton: title: "テスト過剰" description: "通知のテストをごく短時間のうちに連続して行った" + _tutorialCompleted: + title: "Misskey初心者講座 修了証" + description: "チュートリアルを完了した" _role: new: "ロールの作成" @@ -1778,17 +1851,6 @@ _time: hour: "時間" day: "日" -_timelineTutorial: - title: "Misskeyの使い方" - step1_1: "この画面は「タイムライン」です。{name}に投稿された「ノート」が時系列で表示されます。" - step1_2: "タイムラインにはいくつか種類があり、例えば「ホームタイムライン」にはあなたがフォローしている人のノートが流れ、「ローカルタイムライン」には{name}全体のノートが流れます。" - step2_1: "試しに、何かノートを投稿してみましょう。画面上にある鉛筆マークのボタンを押すとフォームが開きます。" - step2_2: "初めてのノートの内容は、あなたの自己紹介や「{name}始めました」などがおすすめです。" - step3_1: "投稿できましたか?" - step3_2: "あなたのノートがタイムラインに表示されていれば成功です。" - step4_1: "ノートには、「リアクション」を付けることができます。" - step4_2: "リアクションを付けるには、ノートの「+」マークをクリックして、好きな絵文字を選択します。" - _2fa: alreadyRegistered: "既に設定は完了しています。" registerTOTP: "認証アプリの設定を開始" diff --git a/packages/backend/src/core/AchievementService.ts b/packages/backend/src/core/AchievementService.ts index 1b8718335b..88fc033859 100644 --- a/packages/backend/src/core/AchievementService.ts +++ b/packages/backend/src/core/AchievementService.ts @@ -86,6 +86,7 @@ export const ACHIEVEMENT_TYPES = [ 'cookieClicked', 'brainDiver', 'smashTestNotificationButton', + 'tutorialCompleted', ] as const; @Injectable() diff --git a/packages/frontend/assets/tutorial/ai.webp b/packages/frontend/assets/tutorial/ai.webp new file mode 100644 index 0000000000..d9d4564942 Binary files /dev/null and b/packages/frontend/assets/tutorial/ai.webp differ diff --git a/packages/frontend/assets/tutorial/natto_failed.webp b/packages/frontend/assets/tutorial/natto_failed.webp new file mode 100644 index 0000000000..87db5f7732 Binary files /dev/null and b/packages/frontend/assets/tutorial/natto_failed.webp differ diff --git a/packages/frontend/assets/tutorial/timeline_tab.png b/packages/frontend/assets/tutorial/timeline_tab.png new file mode 100644 index 0000000000..b52ad5fb51 Binary files /dev/null and b/packages/frontend/assets/tutorial/timeline_tab.png differ 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, }); +} - const users = renotes.map(x => x.user); +if (!props.mock) { + useTooltip(renoteButton, async (showing) => { + const renotes = await os.api('notes/renotes', { + noteId: appearNote.id, + limit: 11, + }); - if (users.length < 1) return; + const users = renotes.map(x => x.user); - os.popup(MkUsersTooltip, { - showing, - users, - count: appearNote.renoteCount, - targetElement: renoteButton.value, - }, {}, 'closed'); -}); + if (users.length < 1) return; + + 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,10 +164,24 @@ 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) { if (later) { const { canceled } = await os.confirm({ @@ -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); diff --git a/packages/frontend/src/pages/timeline.tutorial.vue b/packages/frontend/src/pages/timeline.tutorial.vue deleted file mode 100644 index 66b8e796e5..0000000000 --- a/packages/frontend/src/pages/timeline.tutorial.vue +++ /dev/null @@ -1,123 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div :class="$style.container"> - <div :class="$style.title"> - <div :class="$style.titleText"><i class="ti ti-info-circle"></i> {{ i18n.ts._timelineTutorial.title }}</div> - <div :class="$style.step"> - <button class="_button" :class="$style.stepArrow" :disabled="tutorial === 0" @click="tutorial--"> - <i class="ti ti-chevron-left"></i> - </button> - <span :class="$style.stepNumber">{{ tutorial + 1 }} / {{ tutorialsNumber }}</span> - <button class="_button" :class="$style.stepArrow" :disabled="tutorial === tutorialsNumber - 1" @click="tutorial++"> - <i class="ti ti-chevron-right"></i> - </button> - </div> - </div> - - <div v-if="tutorial === 0" :class="$style.body"> - <div>{{ i18n.t('_timelineTutorial.step1_1', { name: instance.name ?? host }) }}</div> - <div>{{ i18n.t('_timelineTutorial.step1_2', { name: instance.name ?? host }) }}</div> - </div> - <div v-else-if="tutorial === 1" :class="$style.body"> - <div>{{ i18n.ts._timelineTutorial.step2_1 }}</div> - <div>{{ i18n.t('_timelineTutorial.step2_2', { name: instance.name ?? host }) }}</div> - </div> - <div v-else-if="tutorial === 2" :class="$style.body"> - <div>{{ i18n.ts._timelineTutorial.step3_1 }}</div> - <div>{{ i18n.ts._timelineTutorial.step3_2 }}</div> - </div> - <div v-else-if="tutorial === 3" :class="$style.body"> - <div>{{ i18n.ts._timelineTutorial.step4_1 }}</div> - <div>{{ i18n.ts._timelineTutorial.step4_2 }}</div> - </div> - - <div :class="$style.footer"> - <template v-if="tutorial === tutorialsNumber - 1"> - <MkButton :class="$style.footerItem" primary rounded gradate @click="tutorial = -1">{{ i18n.ts.done }} <i class="ti ti-check"></i></MkButton> - </template> - <template v-else> - <MkButton :class="$style.footerItem" primary rounded gradate @click="tutorial++">{{ i18n.ts.next }} <i class="ti ti-arrow-right"></i></MkButton> - </template> - </div> -</div> -</template> - -<script lang="ts" setup> -import { computed } from 'vue'; -import MkButton from '@/components/MkButton.vue'; -import { defaultStore } from '@/store.js'; -import { i18n } from '@/i18n.js'; -import { instance } from '@/instance.js'; -import { host } from '@/config.js'; - -const tutorialsNumber = 4; - -const tutorial = computed({ - get() { return defaultStore.reactiveState.timelineTutorial.value || 0; }, - set(value) { defaultStore.set('timelineTutorial', value); }, -}); -</script> - -<style lang="scss" module> -.small { - opacity: 0.7; -} - -.container { - border: solid 2px var(--accent); -} - -.title { - display: flex; - flex-wrap: wrap; - padding: 22px 32px; - font-weight: bold; - - &Text { - margin: 4px 0; - padding-right: 4px; - } -} - -.step { - margin-left: auto; - - &Arrow { - padding: 4px; - &:disabled { - opacity: 0.5; - } - &:first-child { - padding-right: 8px; - } - &:last-child { - padding-left: 8px; - } - } - - &Number { - font-weight: normal; - margin: 4px; - } -} - -.body { - padding: 0 32px; -} - -.footer { - display: flex; - flex-wrap: wrap; - flex-direction: row; - justify-content: right; - padding: 22px 32px; - - &Item { - margin: 4px; - } -} -</style> diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 5b97385ead..cfe270aefb 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -8,7 +8,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :displayMyAvatar="true"/></template> <MkSpacer :contentMax="800"> <div ref="rootEl" v-hotkey.global="keymap"> - <XTutorial v-if="$i && defaultStore.reactiveState.timelineTutorial.value != -1" class="_panel" style="margin-bottom: var(--margin);"/> + <MkInfo v-if="['home', 'local', 'social', 'global'].includes(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--margin);" closable @close="closeTutorial()"> + {{ i18n.ts._timelineDescription[src] }} + </MkInfo> <MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/> <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> @@ -31,9 +33,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, computed, watch, provide } from 'vue'; +import { computed, watch, provide } from 'vue'; import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; import MkTimeline from '@/components/MkTimeline.vue'; +import MkInfo from '@/components/MkInfo.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import { scroll } from '@/scripts/scroll.js'; import * as os from '@/os.js'; @@ -48,8 +51,6 @@ import { deviceKind } from '@/scripts/device-kind.js'; provide('shouldOmitHeaderTitle', true); -const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue')); - const isLocalTimelineAvailable = ($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable); const isGlobalTimelineAvailable = ($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable); const keymap = { @@ -140,6 +141,13 @@ function focus(): void { tlComponent.focus(); } +function closeTutorial(): void { + if (!['home', 'local', 'social', 'global'].includes(src)) return; + const before = defaultStore.state.timelineTutorials; + before[src] = true; + defaultStore.set('timelineTutorials', before); +} + const headerActions = $computed(() => { const tmp = [ { diff --git a/packages/frontend/src/scripts/achievements.ts b/packages/frontend/src/scripts/achievements.ts index af7c6b060c..e7585fcf81 100644 --- a/packages/frontend/src/scripts/achievements.ts +++ b/packages/frontend/src/scripts/achievements.ts @@ -82,6 +82,7 @@ export const ACHIEVEMENT_TYPES = [ 'cookieClicked', 'brainDiver', 'smashTestNotificationButton', + 'tutorialCompleted', ] as const; export const ACHIEVEMENT_BADGES = { @@ -460,6 +461,11 @@ export const ACHIEVEMENT_BADGES = { bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', frame: 'bronze', }, + 'tutorialCompleted': { + img: '/fluent-emoji/1f393.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'bronze', + }, /* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107> } as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], { img: string; diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 0f2e642b7b..7f916656de 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -49,9 +49,14 @@ export const defaultStore = markRaw(new Storage('base', { where: 'account', default: 0, }, - timelineTutorial: { + timelineTutorials: { where: 'account', - default: 0, + default: { + home: false, + local: false, + social: false, + global: false, + }, }, keepCw: { where: 'account', diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index ff6157f5f8..64008c5748 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { defineAsyncComponent } from 'vue'; import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; import { instance } from '@/instance.js'; @@ -102,7 +103,13 @@ export function openInstanceMenu(ev: MouseEvent) { action: () => { window.open('https://misskey-hub.net/help.html', '_blank'); }, - }, { + }, ($i) ? { + text: i18n.ts._initialTutorial.launchTutorial, + icon: 'ti ti-presentation', + action: () => { + os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {}, {}, 'closed'); + }, + } : undefined, { type: 'link', text: i18n.ts.aboutMisskey, to: '/about-misskey',