merge: upstream

This commit is contained in:
Mar0xy 2023-11-03 15:35:12 +01:00
commit 7c480424a6
No known key found for this signature in database
GPG key ID: 56569BBE47D2C828
120 changed files with 2610 additions and 933 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -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",

View file

@ -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);
}
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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);

View file

@ -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>

View file

@ -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;

View file

@ -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();
});

View file

@ -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;

View file

@ -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);
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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>

View 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>

View 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>

View 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>

View file

@ -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>

View 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>

View file

@ -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);

View file

@ -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;
}>(), {

View file

@ -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';

View file

@ -1045,7 +1045,7 @@
["⌛", "hourglass", 6],
["📡", "satellite", 6],
["🔋", "battery", 6],
["🪫", "battery", 6],
["🪫", "low_battery", 6],
["🔌", "electric_plug", 6],
["💡", "bulb", 6],
["🔦", "flashlight", 6],

View file

@ -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 }}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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) {

View file

@ -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">

View file

@ -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]));
});

View file

@ -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,
});
});
}

View file

@ -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;
});
}

View file

@ -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);

View file

@ -58,7 +58,8 @@ function submit() {
os.alert({
type: 'error',
text: i18n.ts.somethingHappened,
title: i18n.ts.somethingHappened,
text: i18n.ts.signupPendingError,
});
});
}

View file

@ -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>

View file

@ -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 = [
{

View file

@ -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;
});

View file

@ -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',

View file

@ -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;

View file

@ -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',

View file

@ -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,
};
}

View file

@ -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);

View file

@ -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を同期

View file

@ -371,12 +371,6 @@ hr {
grid-gap: 12px;
}
._formLinks {
> *:not(:last-child) {
margin-bottom: 8px;
}
}
._beta {
margin-left: 0.7em;
font-size: 65%;

View file

@ -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',

View file

@ -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,