merge: upstream
This commit is contained in:
commit
7c480424a6
120 changed files with 2610 additions and 933 deletions
BIN
packages/frontend/assets/tutorial/ai.webp
Normal file
BIN
packages/frontend/assets/tutorial/ai.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
BIN
packages/frontend/assets/tutorial/natto_failed.webp
Normal file
BIN
packages/frontend/assets/tutorial/natto_failed.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
BIN
packages/frontend/assets/tutorial/timeline_tab.png
Normal file
BIN
packages/frontend/assets/tutorial/timeline_tab.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.8 KiB |
|
@ -71,7 +71,7 @@
|
|||
"twemoji-parser": "14.0.0",
|
||||
"typescript": "5.2.2",
|
||||
"uuid": "9.0.1",
|
||||
"v-code-diff": "1.7.1",
|
||||
"v-code-diff": "1.7.2",
|
||||
"vanilla-tilt": "1.8.1",
|
||||
"vite": "4.5.0",
|
||||
"vue": "3.3.7",
|
||||
|
|
|
@ -51,6 +51,7 @@ import { i18n } from '@/i18n.js';
|
|||
import { $i } from '@/account.js';
|
||||
import { useRouter } from '@/router.js';
|
||||
import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js';
|
||||
import { deviceKind } from '@/scripts/device-kind.js';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
@ -78,7 +79,11 @@ function onClick(ev: MouseEvent) {
|
|||
if (props.selectMode) {
|
||||
emit('chosen', props.file);
|
||||
} else {
|
||||
router.push(`/my/drive/file/${props.file.id}`);
|
||||
if (deviceKind === 'desktop') {
|
||||
router.push(`/my/drive/file/${props.file.id}`);
|
||||
} else {
|
||||
os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="[$style.root, { [$style.warn]: warn }]">
|
||||
<i v-if="warn" class="ph-warning ph-bold ph-lg" :class="$style.i"></i>
|
||||
<i v-else class="ph-info ph-bold ph-lg" :class="$style.i"></i>
|
||||
<slot></slot>
|
||||
<div><slot></slot></div>
|
||||
<button v-if="closable" :class="$style.button" class="_button" @click="close()"><i class="ph-x ph-bold ph-lg"></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: color-mix(in srgb, var(--infoBg) 65%, transparent);
|
||||
|
@ -38,4 +51,9 @@ const props = defineProps<{
|
|||
.i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-left: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -48,7 +48,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, { [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined">
|
||||
<MkNoteHeader :note="appearNote" :mini="true" v-on:click.stop/>
|
||||
<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
|
||||
|
@ -85,7 +85,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="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
|
||||
</div>
|
||||
<MkReactionsViewer v-show="appearNote.cw == null || showContent" :note="appearNote" :maxNumber="16" v-on:click.stop>
|
||||
<MkReactionsViewer :note="appearNote" :maxNumber="16" v-on:click.stop @mockUpdateMyReaction="emitUpdReaction">
|
||||
<template #more>
|
||||
<div :class="$style.reactionOmitted">{{ i18n.ts.more }}</div>
|
||||
</template>
|
||||
|
@ -111,7 +111,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i class="ph-prohibit ph-bold ph-lg"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="canRenote"
|
||||
v-if="canRenote && !props.mock"
|
||||
ref="quoteButton"
|
||||
:class="$style.footerButton"
|
||||
class="_button"
|
||||
|
@ -153,7 +153,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';
|
||||
|
@ -176,7 +176,7 @@ import { reactionPicker } from '@/scripts/reaction-picker.js';
|
|||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu.js';
|
||||
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js';
|
||||
import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js';
|
||||
import { useNoteCapture } from '@/scripts/use-note-capture.js';
|
||||
import { deepClone } from '@/scripts/clone.js';
|
||||
|
@ -189,9 +189,19 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
|
|||
import { shouldCollapsed } from '@/scripts/collapsed.js';
|
||||
import { useRouter } from '@/router.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 router = useRouter();
|
||||
|
@ -265,51 +275,20 @@ 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 (users.length < 1) return;
|
||||
|
||||
os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.renoteCount,
|
||||
targetElement: renoteButton.value,
|
||||
}, {}, 'closed');
|
||||
});
|
||||
|
||||
useTooltip(quoteButton, async (showing) => {
|
||||
const renotes = await os.api('notes/renotes', {
|
||||
noteId: appearNote.id,
|
||||
limit: 11,
|
||||
quote: true,
|
||||
});
|
||||
|
||||
const users = renotes.map(x => x.user);
|
||||
|
||||
if (users.length < 1) return;
|
||||
|
||||
os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.renoteCount,
|
||||
targetElement: quoteButton.value,
|
||||
}, {}, 'closed');
|
||||
});
|
||||
|
||||
if ($i) {
|
||||
if ($i && !props.mock) {
|
||||
os.api("notes/renotes", {
|
||||
noteId: appearNote.id,
|
||||
userId: $i.id,
|
||||
|
@ -352,14 +331,16 @@ function renote() {
|
|||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
}
|
||||
|
||||
os.api('notes/create', {
|
||||
renoteId: appearNote.id,
|
||||
channelId: appearNote.channelId,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
});
|
||||
} else {
|
||||
if (!props.mock) {
|
||||
os.api('notes/create', {
|
||||
renoteId: appearNote.id,
|
||||
channelId: appearNote.channelId,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
});
|
||||
}
|
||||
} else if (!appearNote.channel || appearNote.channel?.allowRenoteToExternal) {
|
||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
@ -377,20 +358,25 @@ function renote() {
|
|||
visibility = smallerVisibility(visibility, 'home');
|
||||
}
|
||||
|
||||
os.api('notes/create', {
|
||||
localOnly,
|
||||
visibility,
|
||||
renoteId: appearNote.id,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
});
|
||||
if (!props.mock) {
|
||||
os.api('notes/create', {
|
||||
localOnly,
|
||||
visibility,
|
||||
renoteId: appearNote.id,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function quote() {
|
||||
pleaseLogin();
|
||||
showMovedDialog();
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (appearNote.channel) {
|
||||
os.post({
|
||||
|
@ -444,6 +430,9 @@ function quote() {
|
|||
|
||||
function reply(viaKeyboard = false): void {
|
||||
pleaseLogin();
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
os.post({
|
||||
reply: appearNote,
|
||||
channel: appearNote.channel,
|
||||
|
@ -456,6 +445,9 @@ function reply(viaKeyboard = false): void {
|
|||
function like(): void {
|
||||
pleaseLogin();
|
||||
showMovedDialog();
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
os.api('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: '❤️',
|
||||
|
@ -473,6 +465,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: '❤️',
|
||||
|
@ -487,6 +483,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,
|
||||
|
@ -503,12 +504,21 @@ 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 undoRenote(note) : void {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
os.api("notes/unrenote", {
|
||||
noteId: note.id
|
||||
});
|
||||
|
@ -525,6 +535,9 @@ function undoRenote(note) : void {
|
|||
}
|
||||
|
||||
function undoQuote(note) : void {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
os.api("notes/unrenote", {
|
||||
noteId: note.id,
|
||||
quote: true
|
||||
|
@ -542,6 +555,10 @@ function undoQuote(note) : void {
|
|||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent): void {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isLink = (el: HTMLElement) => {
|
||||
if (el.tagName === 'A') return true;
|
||||
// 再生速度の選択などのために、Audio要素のコンテキストメニューはブラウザデフォルトとする。
|
||||
|
@ -563,6 +580,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,
|
||||
|
@ -577,10 +598,18 @@ async function menuVersions(viaKeyboard = false): Promise<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,
|
||||
|
@ -638,6 +667,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>
|
||||
|
|
|
@ -399,6 +399,16 @@ useTooltip(quoteButton, async (showing) => {
|
|||
}, {}, 'closed');
|
||||
});
|
||||
|
||||
type Visibility = 'public' | 'home' | 'followers' | 'specified';
|
||||
|
||||
function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility {
|
||||
if (a === 'specified' || b === 'specified') return 'specified';
|
||||
if (a === 'followers' || b === 'followers') return 'followers';
|
||||
if (a === 'home' || b === 'home') return 'home';
|
||||
// if (a === 'public' || b === 'public')
|
||||
return 'public';
|
||||
}
|
||||
|
||||
function renote() {
|
||||
pleaseLogin();
|
||||
showMovedDialog();
|
||||
|
@ -419,7 +429,7 @@ function renote() {
|
|||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
});
|
||||
} else {
|
||||
} else if (!appearNote.channel || appearNote.channel?.allowRenoteToExternal) {
|
||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
@ -428,7 +438,18 @@ function renote() {
|
|||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
}
|
||||
|
||||
const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility;
|
||||
const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly;
|
||||
|
||||
let visibility = appearNote.visibility;
|
||||
visibility = smallerVisibility(visibility, configuredVisibility);
|
||||
if (appearNote.channel?.isSensitive) {
|
||||
visibility = smallerVisibility(visibility, 'home');
|
||||
}
|
||||
|
||||
os.api('notes/create', {
|
||||
localOnly,
|
||||
visibility,
|
||||
renoteId: appearNote.id,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
|
|
|
@ -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]">
|
||||
|
@ -30,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { shallowRef } from 'vue';
|
||||
import { inject, shallowRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
|
@ -41,6 +47,7 @@ import { popupMenu } from '@/os.js';
|
|||
const props = defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
}>();
|
||||
|
||||
const menuVersionsButton = shallowRef<HTMLElement>();
|
||||
|
||||
async function menuVersions(viaKeyboard = false): Promise<void> {
|
||||
|
@ -49,6 +56,8 @@ async function menuVersions(viaKeyboard = false): Promise<void> {
|
|||
viaKeyboard,
|
||||
}).then(focus).finally(cleanup);
|
||||
}
|
||||
|
||||
const mock = inject<boolean>('mock', false);
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
@ -4,14 +4,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div ref="elRef" :class="$style.root">
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.head">
|
||||
<MkAvatar v-if="notification.type === 'pollEnded'" :class="$style.icon" :user="notification.note.user" link preview/>
|
||||
<MkAvatar v-else-if="notification.type === 'note'" :class="$style.icon" :user="notification.note.user" link preview/>
|
||||
<MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/>
|
||||
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
|
||||
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
|
||||
<img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/>
|
||||
<MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user" link preview/>
|
||||
<img v-else-if="notification.icon" :class="$style.icon" :src="notification.icon" alt=""/>
|
||||
<img v-else-if="notification.icon" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
|
||||
<div
|
||||
:class="[$style.subIcon, {
|
||||
[$style.t_follow]: notification.type === 'follow',
|
||||
|
@ -37,9 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
|
||||
<MkReactionIcon
|
||||
v-else-if="notification.type === 'reaction'"
|
||||
ref="reactionRef"
|
||||
:withTooltip="true"
|
||||
:reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction"
|
||||
:customEmojis="notification.note.emojis"
|
||||
:noStyle="true"
|
||||
style="width: 100%; height: 100%;"
|
||||
/>
|
||||
|
@ -52,16 +53,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
|
||||
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
|
||||
<MkA v-else-if="notification.user" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
|
||||
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.t('_notification.reactedBySomeUsers', { n: notification.reactions.length }) }}</span>
|
||||
<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.t('_notification.renotedBySomeUsers', { n: notification.users.length }) }}</span>
|
||||
<span v-else>{{ notification.header }}</span>
|
||||
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
|
||||
</header>
|
||||
<div>
|
||||
<MkA v-if="notification.type === 'reaction'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
|
||||
<MkA v-if="notification.type === 'reaction' || notification.type === 'reaction:grouped'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
|
||||
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
|
||||
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
|
||||
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
|
||||
</MkA>
|
||||
<MkA v-else-if="notification.type === 'renote'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)">
|
||||
<MkA v-else-if="notification.type === 'renote' || notification.type === 'renote:grouped'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)">
|
||||
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
|
||||
<Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="true" :author="notification.note.renote.user"/>
|
||||
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
|
||||
|
@ -102,6 +105,25 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<span v-else-if="notification.type === 'app'" :class="$style.text">
|
||||
<Mfm :text="notification.body" :nowrap="false"/>
|
||||
</span>
|
||||
|
||||
<div v-if="notification.type === 'reaction:grouped'">
|
||||
<div v-for="reaction of notification.reactions" :class="$style.reactionsItem">
|
||||
<MkAvatar :class="$style.reactionsItemAvatar" :user="reaction.user" link preview/>
|
||||
<div :class="$style.reactionsItemReaction">
|
||||
<MkReactionIcon
|
||||
:withTooltip="true"
|
||||
:reaction="reaction.reaction ? reaction.reaction.replace(/^:(\w+):$/, ':$1@.:') : reaction.reaction"
|
||||
:noStyle="true"
|
||||
style="width: 100%; height: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="notification.type === 'renote:grouped'">
|
||||
<div v-for="user of notification.users" :class="$style.reactionsItem">
|
||||
<MkAvatar :class="$style.reactionsItemAvatar" :user="user" link preview/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -112,14 +134,12 @@ import { ref, shallowRef } from 'vue';
|
|||
import * as Misskey from 'misskey-js';
|
||||
import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
||||
import MkFollowButton from '@/components/MkFollowButton.vue';
|
||||
import XReactionTooltip from '@/components/MkReactionTooltip.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { getNoteSummary } from '@/scripts/get-note-summary.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import { useTooltip } from '@/scripts/use-tooltip.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
|
||||
|
@ -132,9 +152,6 @@ const props = withDefaults(defineProps<{
|
|||
full: false,
|
||||
});
|
||||
|
||||
const elRef = shallowRef<HTMLElement>(null);
|
||||
const reactionRef = ref(null);
|
||||
|
||||
const followRequestDone = ref(false);
|
||||
|
||||
const acceptFollowRequest = () => {
|
||||
|
@ -146,15 +163,6 @@ const rejectFollowRequest = () => {
|
|||
followRequestDone.value = true;
|
||||
os.api('following/requests/reject', { userId: props.notification.user.id });
|
||||
};
|
||||
|
||||
useTooltip(reactionRef, (showing) => {
|
||||
os.popup(XReactionTooltip, {
|
||||
showing,
|
||||
reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction,
|
||||
emojis: props.notification.note.emojis,
|
||||
targetElement: reactionRef.value.$el,
|
||||
}, {}, 'closed');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
@ -181,6 +189,29 @@ useTooltip(reactionRef, (showing) => {
|
|||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon_reactionGroup,
|
||||
.icon_renoteGroup {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
font-size: 15px;
|
||||
border-radius: var(--radius-full);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.icon_reactionGroup {
|
||||
background: #e99a0b;
|
||||
}
|
||||
|
||||
.icon_renoteGroup {
|
||||
background: #36d298;
|
||||
}
|
||||
|
||||
.icon_app {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
|
@ -305,6 +336,36 @@ useTooltip(reactionRef, (showing) => {
|
|||
flex: 1;
|
||||
}
|
||||
|
||||
.reactionsItem {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
margin-top: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.reactionsItemAvatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.reactionsItemReaction {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 100%;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 0 0 3px var(--panel);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@container (max-width: 600px) {
|
||||
.root {
|
||||
padding: 16px;
|
||||
|
|
|
@ -4,25 +4,27 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkPagination ref="pagingComponent" :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" class="_ghost"/>
|
||||
<div>{{ i18n.ts.noNotifications }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<MkPullToRefresh :refresher="() => reload()">
|
||||
<MkPagination ref="pagingComponent" :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" class="_ghost"/>
|
||||
<div>{{ i18n.ts.noNotifications }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ items: notifications }">
|
||||
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true">
|
||||
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
|
||||
<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel notification"/>
|
||||
</MkDateSeparatedList>
|
||||
</template>
|
||||
</MkPagination>
|
||||
<template #default="{ items: notifications }">
|
||||
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true">
|
||||
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
|
||||
<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel"/>
|
||||
</MkDateSeparatedList>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</MkPullToRefresh>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, onDeactivated, onMounted, computed, shallowRef } from 'vue';
|
||||
import { onUnmounted, onDeactivated, onMounted, computed, shallowRef, onActivated } from 'vue';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
import XNotification from '@/components/MkNotification.vue';
|
||||
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
||||
|
@ -32,6 +34,8 @@ import { $i } from '@/account.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { notificationTypes } from '@/const.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
excludeTypes?: typeof notificationTypes[number][];
|
||||
|
@ -39,7 +43,13 @@ const props = defineProps<{
|
|||
|
||||
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
||||
|
||||
const pagination: Paging = {
|
||||
const pagination: Paging = defaultStore.state.useGroupedNotifications ? {
|
||||
endpoint: 'i/notifications-grouped' as const,
|
||||
limit: 20,
|
||||
params: computed(() => ({
|
||||
excludeTypes: props.excludeTypes ?? undefined,
|
||||
})),
|
||||
} : {
|
||||
endpoint: 'i/notifications' as const,
|
||||
limit: 20,
|
||||
params: computed(() => ({
|
||||
|
@ -47,7 +57,7 @@ const pagination: Paging = {
|
|||
})),
|
||||
};
|
||||
|
||||
const onNotification = (notification) => {
|
||||
function onNotification(notification) {
|
||||
const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
|
||||
if (isMuted || document.visibilityState === 'visible') {
|
||||
useStream().send('readNotification');
|
||||
|
@ -56,7 +66,15 @@ const onNotification = (notification) => {
|
|||
if (!isMuted) {
|
||||
pagingComponent.value.prepend(notification);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function reload() {
|
||||
return new Promise<void>((res) => {
|
||||
pagingComponent.value?.reload().then(() => {
|
||||
res();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let connection;
|
||||
|
||||
|
@ -65,6 +83,12 @@ onMounted(() => {
|
|||
connection.on('notification', onNotification);
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
pagingComponent.value?.reload();
|
||||
connection = useStream().useChannel('main');
|
||||
connection.on('notification', onNotification);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (connection) connection.dispose();
|
||||
});
|
||||
|
|
|
@ -99,7 +99,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';
|
||||
|
@ -145,15 +145,22 @@ const props = withDefaults(defineProps<{
|
|||
autofocus?: boolean;
|
||||
freezeAfterPosted?: boolean;
|
||||
editId?: Misskey.entities.Note["id"];
|
||||
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);
|
||||
|
@ -241,7 +248,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);
|
||||
|
@ -294,6 +301,10 @@ if (props.reply && props.reply.text != null) {
|
|||
}
|
||||
}
|
||||
|
||||
if ($i?.isSilenced && visibility === 'public') {
|
||||
visibility = 'home';
|
||||
}
|
||||
|
||||
if (props.channel) {
|
||||
visibility = 'public';
|
||||
localOnly = true; // TODO: チャンネルが連合するようになった折には消す
|
||||
|
@ -402,6 +413,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);
|
||||
|
@ -414,6 +427,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;
|
||||
}
|
||||
|
||||
|
@ -426,6 +442,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);
|
||||
});
|
||||
|
@ -440,6 +458,7 @@ function setVisibility() {
|
|||
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), {
|
||||
currentVisibility: visibility,
|
||||
isSilenced: $i?.isSilenced,
|
||||
localOnly: localOnly,
|
||||
src: visibilityButton,
|
||||
}, {
|
||||
|
@ -551,6 +570,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();
|
||||
|
@ -635,7 +656,7 @@ function onDrop(ev): void {
|
|||
}
|
||||
|
||||
function saveDraft() {
|
||||
if (props.instant) return;
|
||||
if (props.instant || props.mock) return;
|
||||
|
||||
const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}');
|
||||
|
||||
|
@ -664,6 +685,14 @@ function deleteDraft() {
|
|||
}
|
||||
|
||||
async function post(ev?: MouseEvent) {
|
||||
if (useCw && (cw == null || cw.trim() === '')) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.cwNotationRequired,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev) {
|
||||
const el = ev.currentTarget ?? ev.target;
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
@ -672,6 +701,8 @@ async function post(ev?: MouseEvent) {
|
|||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
}
|
||||
|
||||
if (props.mock) return;
|
||||
|
||||
const annoying =
|
||||
text.includes('$[x2') ||
|
||||
text.includes('$[x3') ||
|
||||
|
@ -838,6 +869,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,
|
||||
|
@ -868,7 +901,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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -47,7 +47,13 @@ let scrollEl: HTMLElement | null = null;
|
|||
|
||||
let disabled = false;
|
||||
|
||||
const emits = defineEmits<{
|
||||
const props = withDefaults(defineProps<{
|
||||
refresher: () => Promise<void>;
|
||||
}>(), {
|
||||
refresher: () => Promise.resolve(),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'refresh'): void;
|
||||
}>();
|
||||
|
||||
|
@ -120,7 +126,12 @@ function moveEnd() {
|
|||
if (isPullEnd) {
|
||||
isPullEnd = false;
|
||||
isRefreshing = true;
|
||||
fixOverContent().then(() => emits('refresh'));
|
||||
fixOverContent().then(() => {
|
||||
emit('refresh');
|
||||
props.refresher().then(() => {
|
||||
refreshFinished();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
closeContent().then(() => isPullStart = false);
|
||||
}
|
||||
|
@ -188,7 +199,6 @@ onUnmounted(() => {
|
|||
});
|
||||
|
||||
defineExpose({
|
||||
refreshFinished,
|
||||
setDisabled,
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -4,16 +4,31 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkCustomEmoji v-if="reaction[0] === ':'" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl"/>
|
||||
<MkEmoji v-else :emoji="reaction" :normal="true" :noStyle="noStyle"/>
|
||||
<MkCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl"/>
|
||||
<MkEmoji v-else ref="elRef" :emoji="reaction" :normal="true" :noStyle="noStyle"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import { defineAsyncComponent, shallowRef } from 'vue';
|
||||
import { useTooltip } from '@/scripts/use-tooltip.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
const props = defineProps<{
|
||||
reaction: string;
|
||||
noStyle?: boolean;
|
||||
emojiUrl?: string;
|
||||
withTooltip?: boolean;
|
||||
}>();
|
||||
|
||||
const elRef = shallowRef();
|
||||
|
||||
if (props.withTooltip) {
|
||||
useTooltip(elRef, (showing) => {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkReactionTooltip.vue')), {
|
||||
showing,
|
||||
reaction: props.reaction.replace(/^:(\w+):$/, ':$1@.:'),
|
||||
targetElement: elRef.value.$el,
|
||||
}, {}, 'closed');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkAvatar :class="$style.avatar" :user="u"/>
|
||||
<MkUserName :user="u" :nowrap="true"/>
|
||||
</div>
|
||||
<div v-if="users.length > 10" :class="$style.more">+{{ count - 10 }}</div>
|
||||
<div v-if="count > 10" :class="$style.more">+{{ count - 10 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkTooltip>
|
||||
|
|
|
@ -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: 10,
|
||||
_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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkPullToRefresh ref="prComponent" @refresh="() => reloadTimeline(true)">
|
||||
<MkPullToRefresh ref="prComponent" :refresher="() => reloadTimeline()">
|
||||
<MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)" @status="prComponent.setDisabled($event)"/>
|
||||
</MkPullToRefresh>
|
||||
</template>
|
||||
|
@ -205,25 +205,18 @@ const pagination = {
|
|||
params: query,
|
||||
};
|
||||
|
||||
const reloadTimeline = (fromPR = false) => {
|
||||
tlNotesCount = 0;
|
||||
function reloadTimeline() {
|
||||
return new Promise<void>((res) => {
|
||||
tlNotesCount = 0;
|
||||
|
||||
tlComponent.pagingComponent?.reload().then(() => {
|
||||
reloadStream();
|
||||
if (fromPR) prComponent.refreshFinished();
|
||||
tlComponent.pagingComponent?.reload().then(() => {
|
||||
reloadStream();
|
||||
res();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
//const pullRefresh = () => reloadTimeline(true);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reloadTimeline,
|
||||
});
|
||||
|
||||
/* TODO
|
||||
const timetravel = (date?: Date) => {
|
||||
this.date = date;
|
||||
this.$refs.tl.reload();
|
||||
};
|
||||
*/
|
||||
</script>
|
||||
|
|
117
packages/frontend/src/components/MkTutorialDialog.Note.vue
Normal file
117
packages/frontend/src/components/MkTutorialDialog.Note.vue
Normal file
|
@ -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="ph-arrow-u-up-left ph-bold ph-lg"></i> <b>{{ i18n.ts.reply }}</b> … {{ i18n.ts._initialTutorial._note.reply }}</div>
|
||||
<div><i class="ph-rocket-launch ph-bold ph-lg"></i> <b>{{ i18n.ts.renote }}</b> … {{ i18n.ts._initialTutorial._note.renote }}</div>
|
||||
<div><i class="ph-smiley ph-bold ph-lg"></i> <b>{{ i18n.ts.reaction }}</b> … {{ i18n.ts._initialTutorial._note.reaction }}</div>
|
||||
<div><i class="ph-dots-three ph-bold ph-lg"></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="ph-check ph-bold ph-lg"></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>
|
135
packages/frontend/src/components/MkTutorialDialog.PostNote.vue
Normal file
135
packages/frontend/src/components/MkTutorialDialog.PostNote.vue
Normal file
|
@ -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="ph-globe-hemisphere-west ph-bold ph-lg"></i> <b>{{ i18n.ts._visibility.public }}</b> … {{ i18n.ts._initialTutorial._postNote._visibility.public }}</div>
|
||||
<div><i class="ph-house ph-bold ph-lg"></i> <b>{{ i18n.ts._visibility.home }}</b> … {{ i18n.ts._initialTutorial._postNote._visibility.home }}</div>
|
||||
<div><i class="ph-lock ph-bold ph-lg"></i> <b>{{ i18n.ts._visibility.followers }}</b> … {{ i18n.ts._initialTutorial._postNote._visibility.followers }}</div>
|
||||
<div class="_gaps_s">
|
||||
<div><i class="ph-envelope ph-bold ph-lg"></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="ph-rocket ph-bold ph-lg"></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>
|
144
packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
Normal file
144
packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
Normal file
|
@ -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="ph-check ph-bold ph-lg"></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>
|
|
@ -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="ph-house ph-bold pg-lg"></i> <b>{{ i18n.ts._timelines.home }}</b> … {{ i18n.ts._initialTutorial._timeline.home }}</div>
|
||||
<div><i class="ph-planet ph-bold pg-lg"></i> <b>{{ i18n.ts._timelines.local }}</b> … {{ i18n.ts._initialTutorial._timeline.local }}</div>
|
||||
<div><i class="ph-rocket-launch ph-bold ph-lg"></i> <b>{{ i18n.ts._timelines.social }}</b> … {{ i18n.ts._initialTutorial._timeline.social }}</div>
|
||||
<div><i class="ph-globe-hemisphere-west ph-bold ph-lg"></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>
|
260
packages/frontend/src/components/MkTutorialDialog.vue
Normal file
260
packages/frontend/src/components/MkTutorialDialog.vue
Normal file
|
@ -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="ph-pencil ph-bold pg-lg"></i> {{ i18n.ts._initialTutorial._note.title }}</template>
|
||||
<template v-else-if="page === 2" #header><i class="ph-smiley ph-bold pg-lg"></i> {{ i18n.ts._initialTutorial._reaction.title }}</template>
|
||||
<template v-else-if="page === 3" #header><i class="ph-house ph-bold pg-lg"></i> {{ i18n.ts._initialTutorial._timeline.title }}</template>
|
||||
<template v-else-if="page === 4" #header><i class="ph-plus ph-bold pg-lg"></i> {{ i18n.ts._initialTutorial._postNote.title }}</template>
|
||||
<template v-else-if="page === 5" #header><i class="ph-eye-slash ph-bold pg-lg"></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="ph-confetti ph-bold pg-lg" 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="ph-arrow-right ph-bold pg-lg"></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="ph-arrow-left ph-bold pg-lg"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate @click="page++">{{ i18n.ts.continue }} <i class="ph-arrow-right ph-bold pg-lg"></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="ph-arrow-left ph-bold pg-lg"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate :disabled="!isReactionTutorialPushed" @click="page++">{{ i18n.ts.continue }} <i class="ph-arrow-right ph-bold pg-lg"></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="ph-arrow-left ph-bold pg-lg"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate @click="page++">{{ i18n.ts.continue }} <i class="ph-arrow-right ph-bold pg-lg"></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="ph-arrow-left ph-bold pg-lg"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate @click="page++">{{ i18n.ts.continue }} <i class="ph-arrow-right ph-bold pg-lg"></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="ph-arrow-left ph-bold pg-lg"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate :disabled="!isSensitiveTutorialSucceeded" @click="page++">{{ i18n.ts.continue }} <i class="ph-arrow-right ph-bold pg-lg"></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="ph-check ph-bold pg-lg" 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="ph-arrow-left ph-bold pg-lg"></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>
|
|
@ -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="ph-arrow-left ph-bold pg-lg"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ph-arrow-right ph-bold pg-lg"></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="ph-arrow-left ph-bold pg-lg"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ph-arrow-right ph-bold pg-lg"></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="ph-arrow-left ph-bold pg-lg"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ph-arrow-right ph-bold pg-lg"></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="ph-arrow-left ph-bold pg-lg"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ph-arrow-right ph-bold pg-lg"></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="ph-check ph-bold ph-lg" 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="ph-arrow-right ph-bold pg-lg"></i></MkButton>
|
||||
</div>
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ph-arrow-left ph-bold pg-lg"></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);
|
||||
|
|
|
@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="[$style.label, $style.item]">
|
||||
{{ i18n.ts.visibility }}
|
||||
</div>
|
||||
<button key="public" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')">
|
||||
<button key="public" :disabled="isSilenced" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')">
|
||||
<div :class="$style.icon"><i class="ph-globe-hemisphere-west ph-bold ph-lg"></i></div>
|
||||
<div :class="$style.body">
|
||||
<span :class="$style.itemTitle">{{ i18n.ts._visibility.public }}</span>
|
||||
|
@ -51,6 +51,7 @@ const modal = $shallowRef<InstanceType<typeof MkModal>>();
|
|||
|
||||
const props = withDefaults(defineProps<{
|
||||
currentVisibility: typeof Misskey.noteVisibilities[number];
|
||||
isSilenced: boolean;
|
||||
localOnly: boolean;
|
||||
src?: HTMLElement;
|
||||
}>(), {
|
||||
|
|
|
@ -17,7 +17,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { toUnicode } from 'punycode/';
|
||||
import MkCondensedLine from './MkCondensedLine.vue';
|
||||
import { host as hostRaw } from '@/config.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
|
|
|
@ -1045,7 +1045,7 @@
|
|||
["⌛", "hourglass", 6],
|
||||
["📡", "satellite", 6],
|
||||
["🔋", "battery", 6],
|
||||
["🪫", "battery", 6],
|
||||
["🪫", "low_battery", 6],
|
||||
["🔌", "electric_plug", 6],
|
||||
["💡", "bulb", 6],
|
||||
["🔦", "flashlight", 6],
|
||||
|
|
|
@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Sharkey</MkButton>
|
||||
</div>
|
||||
<FormSection>
|
||||
<div class="_formLinks">
|
||||
<div class="_gaps_s">
|
||||
<FormLink to="https://github.com/transfem-org/Sharkey" external>
|
||||
<template #icon><i class="ph-code ph-bold pg-lg"></i></template>
|
||||
{{ i18n.ts._aboutMisskey.source }}
|
||||
|
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="_gaps">
|
||||
<div>
|
||||
<MkInput v-model="host" :debounce="true" class="">
|
||||
<template #prefix><i class="ph-magnifying-glass ph-bold ph-lg"></i></template>
|
||||
|
|
|
@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkKeyValue>
|
||||
</FormSplit>
|
||||
<FormLink v-if="instance.impressumUrl" :to="instance.impressumUrl" external>{{ i18n.ts.impressum }}</FormLink>
|
||||
<div class="_formLinks">
|
||||
<div class="_gaps_s">
|
||||
<MkFolder v-if="instance.serverRules.length > 0">
|
||||
<template #label>{{ i18n.ts.serverRules }}</template>
|
||||
|
||||
|
@ -79,7 +79,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<FormSection>
|
||||
<template #label>Well-known resources</template>
|
||||
<div class="_formLinks">
|
||||
<div class="_gaps_s">
|
||||
<FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink>
|
||||
<FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink>
|
||||
<FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink>
|
||||
|
|
|
@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="900">
|
||||
<div class="_gaps">
|
||||
<MkInfo>{{ i18n.ts._announcement.shouldNotBeUsedToPresentPermanentInfo }}</MkInfo>
|
||||
<MkInfo v-if="announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo>
|
||||
|
||||
<MkFolder v-for="announcement in announcements" :key="announcement.id ?? announcement._id" :defaultOpen="announcement.id == null">
|
||||
|
@ -43,6 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<option value="banner">{{ i18n.ts.banner }}</option>
|
||||
<option value="dialog">{{ i18n.ts.dialog }}</option>
|
||||
</MkRadios>
|
||||
<MkInfo v-if="announcement.display === 'dialog'" warn>{{ i18n.ts._announcement.dialogAnnouncementUxWarn }}</MkInfo>
|
||||
<MkSwitch v-model="announcement.forExistingUsers" :helpText="i18n.ts._announcement.forExistingUsersDescription">
|
||||
{{ i18n.ts._announcement.forExistingUsers }}
|
||||
</MkSwitch>
|
||||
|
|
|
@ -24,6 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts.sensitive }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkSwitch v-model="allowRenoteToExternal">
|
||||
<template #label>{{ i18n.ts._channel.allowRenoteToExternal }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<div>
|
||||
<MkButton v-if="bannerId == null" @click="setBannerImage"><i class="ph-plus ph-bold ph-lg"></i> {{ i18n.ts._channel.setBanner }}</MkButton>
|
||||
<div v-else-if="bannerUrl">
|
||||
|
@ -76,7 +80,7 @@ import { useRouter } from '@/router.js';
|
|||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkSwitch from "@/components/MkSwitch.vue";
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
|
||||
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
|
||||
|
||||
|
@ -93,6 +97,7 @@ let bannerUrl = $ref<string | null>(null);
|
|||
let bannerId = $ref<string | null>(null);
|
||||
let color = $ref('#000');
|
||||
let isSensitive = $ref(false);
|
||||
let allowRenoteToExternal = $ref(true);
|
||||
const pinnedNotes = ref([]);
|
||||
|
||||
watch(() => bannerId, async () => {
|
||||
|
@ -121,6 +126,7 @@ async function fetchChannel() {
|
|||
id,
|
||||
}));
|
||||
color = channel.color;
|
||||
allowRenoteToExternal = channel.allowRenoteToExternal;
|
||||
}
|
||||
|
||||
fetchChannel();
|
||||
|
@ -150,6 +156,7 @@ function save() {
|
|||
pinnedNoteIds: pinnedNotes.value.map(x => x.id),
|
||||
color: color,
|
||||
isSensitive: isSensitive,
|
||||
allowRenoteToExternal: allowRenoteToExternal,
|
||||
};
|
||||
|
||||
if (props.channelId) {
|
||||
|
|
|
@ -56,6 +56,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #key>{{ i18n.ts._fileViewer.size }}</template>
|
||||
<template #value>{{ bytes(file.size) }}</template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue :class="$style.fileMetaDataChildren" :copy="file.url">
|
||||
<template #key>URL</template>
|
||||
<template #value>{{ file.url }}</template>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="_fullinfo">
|
||||
|
|
|
@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<FormSplit>
|
||||
<MkKeyValue>
|
||||
<template #key>{{ i18n.ts._registry.domain }}</template>
|
||||
<template #value>{{ i18n.ts.system }}</template>
|
||||
<template #value>{{ props.domain === '@' ? i18n.ts.system : props.domain.toUpperCase() }}</template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue>
|
||||
<template #key>{{ i18n.ts._registry.scope }}</template>
|
||||
|
@ -23,8 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<FormSection v-if="keys">
|
||||
<template #label>{{ i18n.ts.keys }}</template>
|
||||
<div class="_formLinks">
|
||||
<FormLink v-for="key in keys" :to="`/registry/value/system/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink>
|
||||
<div class="_gaps_s">
|
||||
<FormLink v-for="key in keys" :to="`/registry/value/${props.domain}/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
|
@ -46,15 +46,17 @@ import FormSplit from '@/components/form/split.vue';
|
|||
|
||||
const props = defineProps<{
|
||||
path: string;
|
||||
domain: string;
|
||||
}>();
|
||||
|
||||
const scope = $computed(() => props.path.split('/'));
|
||||
const scope = $computed(() => props.path ? props.path.split('/') : []);
|
||||
|
||||
let keys = $ref(null);
|
||||
|
||||
function fetchKeys() {
|
||||
os.api('i/registry/keys-with-type', {
|
||||
scope: scope,
|
||||
domain: props.domain === '@' ? null : props.domain,
|
||||
}).then(res => {
|
||||
keys = Object.entries(res).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
});
|
||||
|
|
|
@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<FormSplit>
|
||||
<MkKeyValue>
|
||||
<template #key>{{ i18n.ts._registry.domain }}</template>
|
||||
<template #value>{{ i18n.ts.system }}</template>
|
||||
<template #value>{{ props.domain === '@' ? i18n.ts.system : props.domain.toUpperCase() }}</template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue>
|
||||
<template #key>{{ i18n.ts._registry.scope }}</template>
|
||||
|
@ -58,6 +58,7 @@ import FormInfo from '@/components/MkInfo.vue';
|
|||
|
||||
const props = defineProps<{
|
||||
path: string;
|
||||
domain: string;
|
||||
}>();
|
||||
|
||||
const scope = $computed(() => props.path.split('/').slice(0, -1));
|
||||
|
@ -70,6 +71,7 @@ function fetchValue() {
|
|||
os.api('i/registry/get-detail', {
|
||||
scope,
|
||||
key,
|
||||
domain: props.domain === '@' ? null : props.domain,
|
||||
}).then(res => {
|
||||
value = res;
|
||||
valueForEditor = JSON5.stringify(res.value, null, '\t');
|
||||
|
@ -95,6 +97,7 @@ async function save() {
|
|||
scope,
|
||||
key,
|
||||
value: JSON5.parse(valueForEditor),
|
||||
domain: props.domain === '@' ? null : props.domain,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -108,6 +111,7 @@ function del() {
|
|||
os.apiWithDialog('i/registry/remove', {
|
||||
scope,
|
||||
key,
|
||||
domain: props.domain === '@' ? null : props.domain,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -9,12 +9,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSpacer :contentMax="600" :marginMin="16">
|
||||
<MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton>
|
||||
|
||||
<FormSection v-if="scopes">
|
||||
<template #label>{{ i18n.ts.system }}</template>
|
||||
<div class="_formLinks">
|
||||
<FormLink v-for="scope in scopes" :to="`/registry/keys/system/${scope.join('/')}`" class="_monospace">{{ scope.join('/') }}</FormLink>
|
||||
</div>
|
||||
</FormSection>
|
||||
<div v-if="scopesWithDomain" class="_gaps_m">
|
||||
<FormSection v-for="domain in scopesWithDomain" :key="domain.domain">
|
||||
<template #label>{{ domain.domain ? domain.domain.toUpperCase() : i18n.ts.system }}</template>
|
||||
<div class="_gaps_s">
|
||||
<FormLink v-for="scope in domain.scopes" :to="`/registry/keys/${domain.domain ?? '@'}/${scope.join('/')}`" class="_monospace">{{ scope.length === 0 ? '(root)' : scope.join('/') }}</FormLink>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
@ -28,11 +30,11 @@ import FormLink from '@/components/form/link.vue';
|
|||
import FormSection from '@/components/form/section.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
let scopes = $ref(null);
|
||||
let scopesWithDomain = $ref(null);
|
||||
|
||||
function fetchScopes() {
|
||||
os.api('i/registry/scopes').then(res => {
|
||||
scopes = res.slice().sort((a, b) => a.join('/').localeCompare(b.join('/')));
|
||||
os.api('i/registry/scopes-with-domain').then(res => {
|
||||
scopesWithDomain = res;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -90,6 +90,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts.notificationDisplay }}</template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<MkSwitch v-model="useGroupedNotifications">{{ i18n.ts.useGroupedNotifications }}</MkSwitch>
|
||||
|
||||
<MkRadios v-model="notificationPosition">
|
||||
<template #label>{{ i18n.ts.position }}</template>
|
||||
<option value="leftTop"><i class="ph-arrow-up-left ph-bold ph-lg"></i> {{ i18n.ts.leftTop }}</option>
|
||||
|
@ -270,6 +272,7 @@ const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificati
|
|||
const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn'));
|
||||
const defaultWithReplies = computed(defaultStore.makeGetterSetter('defaultWithReplies'));
|
||||
const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline'));
|
||||
const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications'));
|
||||
|
||||
watch(lang, () => {
|
||||
miLocalStorage.setItem('lang', lang.value as string);
|
||||
|
|
|
@ -58,7 +58,8 @@ function submit() {
|
|||
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.somethingHappened,
|
||||
title: i18n.ts.somethingHappened,
|
||||
text: i18n.ts.signupPendingError,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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="ph-info ph-bold ph-lg"></i> {{ i18n.ts._timelineTutorial.title }}</div>
|
||||
<div :class="$style.step">
|
||||
<button class="_button" :class="$style.stepArrow" :disabled="tutorial === 0" @click="tutorial--">
|
||||
<i class="ph-caret-left ph-bold ph-lg"></i>
|
||||
</button>
|
||||
<span :class="$style.stepNumber">{{ tutorial + 1 }} / {{ tutorialsNumber }}</span>
|
||||
<button class="_button" :class="$style.stepArrow" :disabled="tutorial === tutorialsNumber - 1" @click="tutorial++">
|
||||
<i class="ph-caret-right ph-bold ph-lg"></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="ph-check ph-bold ph-lg"></i></MkButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<MkButton :class="$style.footerItem" primary rounded gradate @click="tutorial++">{{ i18n.ts.next }} <i class="ph-arrow-right ph-bold pg-lg"></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>
|
|
@ -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>
|
||||
|
@ -32,9 +34,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';
|
||||
|
@ -49,8 +52,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 = {
|
||||
|
@ -142,6 +143,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 = [
|
||||
{
|
||||
|
|
|
@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MarqueeText :duration="40">
|
||||
<MkA v-for="instance in instances" :key="instance.id" :class="$style.federationInstance" :to="`/instance-info/${instance.host}`" behavior="window">
|
||||
<!--<MkInstanceCardMini :instance="instance"/>-->
|
||||
<img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/>
|
||||
<img v-if="instance.iconUrl" class="icon" :src="getInstanceIcon(instance)" alt=""/>
|
||||
<span class="name _monospace">{{ instance.host }}</span>
|
||||
</MkA>
|
||||
</MarqueeText>
|
||||
|
@ -46,10 +46,15 @@ import { instance } from '@/instance.js';
|
|||
import number from '@/filters/number.js';
|
||||
import MkNumber from '@/components/MkNumber.vue';
|
||||
import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue';
|
||||
import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
|
||||
|
||||
let meta = $ref<Misskey.entities.Instance>();
|
||||
let instances = $ref<any[]>();
|
||||
|
||||
function getInstanceIcon(instance): string {
|
||||
return getProxiedImageUrl(instance.iconUrl, 'preview');
|
||||
}
|
||||
|
||||
os.api('meta', { detail: true }).then(_meta => {
|
||||
meta = _meta;
|
||||
});
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import { AsyncComponentLoader, defineAsyncComponent, inject } from 'vue';
|
||||
import { Router } from '@/nirax';
|
||||
import { Router } from '@/nirax.js';
|
||||
import { $i, iAmModerator } from '@/account.js';
|
||||
import MkLoading from '@/pages/_loading_.vue';
|
||||
import MkError from '@/pages/_error_.vue';
|
||||
|
@ -314,10 +314,10 @@ export const routes = [{
|
|||
path: '/custom-emojis-manager',
|
||||
component: page(() => import('./pages/custom-emojis-manager.vue')),
|
||||
}, {
|
||||
path: '/registry/keys/system/:path(*)?',
|
||||
path: '/registry/keys/:domain/:path(*)?',
|
||||
component: page(() => import('./pages/registry.keys.vue')),
|
||||
}, {
|
||||
path: '/registry/value/system/:path(*)?',
|
||||
path: '/registry/value/:domain/:path(*)?',
|
||||
component: page(() => import('./pages/registry.value.vue')),
|
||||
}, {
|
||||
path: '/registry',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -78,6 +78,11 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
|
|||
const isImage = file.type.startsWith('image/');
|
||||
let menu;
|
||||
menu = [{
|
||||
type: 'link',
|
||||
to: `/my/drive/file/${file.id}`,
|
||||
text: i18n.ts._fileViewer.title,
|
||||
icon: 'ph-file-text ph-bold pg-lg',
|
||||
}, null, {
|
||||
text: i18n.ts.rename,
|
||||
icon: 'ph-textbox ph-bold ph-lg',
|
||||
action: () => rename(file),
|
||||
|
@ -113,11 +118,6 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
|
|||
text: i18n.ts.download,
|
||||
icon: 'ph-download ph-bold ph-lg',
|
||||
download: file.name,
|
||||
}, null, {
|
||||
type: 'link',
|
||||
to: `/my/drive/file/${file.id}`,
|
||||
text: i18n.ts._fileViewer.title,
|
||||
icon: 'ph-file-text ph-bold pg-lg',
|
||||
}, null, {
|
||||
text: i18n.ts.delete,
|
||||
icon: 'ph-trash ph-bold ph-lg',
|
||||
|
|
|
@ -17,6 +17,7 @@ import { miLocalStorage } from '@/local-storage.js';
|
|||
import { getUserMenu } from '@/scripts/get-user-menu.js';
|
||||
import { clipsCache } from '@/cache.js';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
|
||||
export async function getNoteClipMenu(props: {
|
||||
note: Misskey.entities.Note;
|
||||
|
@ -452,3 +453,122 @@ export function getNoteMenu(props: {
|
|||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
type Visibility = 'public' | 'home' | 'followers' | 'specified';
|
||||
|
||||
// defaultStore.state.visibilityがstringなためstringも受け付けている
|
||||
function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility {
|
||||
if (a === 'specified' || b === 'specified') return 'specified';
|
||||
if (a === 'followers' || b === 'followers') return 'followers';
|
||||
if (a === 'home' || b === 'home') return 'home';
|
||||
// if (a === 'public' || b === 'public')
|
||||
return 'public';
|
||||
}
|
||||
|
||||
export function getRenoteMenu(props: {
|
||||
note: Misskey.entities.Note;
|
||||
renoteButton: Ref<HTMLElement>;
|
||||
mock?: boolean;
|
||||
}) {
|
||||
const isRenote = (
|
||||
props.note.renote != null &&
|
||||
props.note.text == null &&
|
||||
props.note.fileIds.length === 0 &&
|
||||
props.note.poll == null
|
||||
);
|
||||
|
||||
const appearNote = isRenote ? props.note.renote as Misskey.entities.Note : props.note;
|
||||
|
||||
const channelRenoteItems: MenuItem[] = [];
|
||||
const normalRenoteItems: MenuItem[] = [];
|
||||
|
||||
if (appearNote.channel) {
|
||||
channelRenoteItems.push(...[{
|
||||
text: i18n.ts.inChannelRenote,
|
||||
icon: 'ti ti-repeat',
|
||||
action: () => {
|
||||
const el = props.renoteButton.value as HTMLElement | null | undefined;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
}
|
||||
|
||||
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: () => {
|
||||
if (!props.mock) {
|
||||
os.post({
|
||||
renote: appearNote,
|
||||
channel: appearNote.channel,
|
||||
});
|
||||
}
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
if (!appearNote.channel || appearNote.channel?.allowRenoteToExternal) {
|
||||
normalRenoteItems.push(...[{
|
||||
text: i18n.ts.renote,
|
||||
icon: 'ti ti-repeat',
|
||||
action: () => {
|
||||
const el = props.renoteButton.value as HTMLElement | null | undefined;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
}
|
||||
|
||||
const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility;
|
||||
const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly;
|
||||
|
||||
let visibility = appearNote.visibility;
|
||||
visibility = smallerVisibility(visibility, configuredVisibility);
|
||||
if (appearNote.channel?.isSensitive) {
|
||||
visibility = smallerVisibility(visibility, 'home');
|
||||
}
|
||||
|
||||
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: () => {
|
||||
os.post({
|
||||
renote: appearNote,
|
||||
});
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
// nullを挟むことで区切り線を出せる
|
||||
const renoteItems = [
|
||||
...normalRenoteItems,
|
||||
...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [null] : [],
|
||||
...channelRenoteItems,
|
||||
];
|
||||
|
||||
return {
|
||||
menu: renoteItems,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ export function useTooltip(
|
|||
};
|
||||
|
||||
autoHidingTimer = window.setInterval(() => {
|
||||
if (!document.body.contains(elRef.value)) {
|
||||
if (elRef.value == null || !document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) {
|
||||
if (!isHovering) return;
|
||||
isHovering = false;
|
||||
window.clearTimeout(timeoutId);
|
||||
|
|
|
@ -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',
|
||||
|
@ -389,6 +394,10 @@ export const defaultStore = markRaw(new Storage('base', {
|
|||
where: 'device',
|
||||
default: false,
|
||||
},
|
||||
useGroupedNotifications: {
|
||||
where: 'device',
|
||||
default: true,
|
||||
},
|
||||
}));
|
||||
|
||||
// TODO: 他のタブと永続化されたstateを同期
|
||||
|
|
|
@ -371,12 +371,6 @@ hr {
|
|||
grid-gap: 12px;
|
||||
}
|
||||
|
||||
._formLinks {
|
||||
> *:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
._beta {
|
||||
margin-left: 0.7em;
|
||||
font-size: 65%;
|
||||
|
|
|
@ -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-sharkey',
|
||||
|
|
|
@ -61,6 +61,12 @@ watch($$(withRenotes), v => {
|
|||
});
|
||||
});
|
||||
|
||||
watch($$(withReplies), v => {
|
||||
updateColumn(props.column.id, {
|
||||
withReplies: v,
|
||||
});
|
||||
});
|
||||
|
||||
watch($$(onlyFiles), v => {
|
||||
updateColumn(props.column.id, {
|
||||
onlyFiles: v,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue