Merge remote-tracking branch 'mi-dev/develop' into emoji-req

This commit is contained in:
mattyatea 2023-11-15 14:24:41 +09:00
commit 36292549c5
No known key found for this signature in database
GPG key ID: 068E54E2C33BEF9A
157 changed files with 4057 additions and 2161 deletions

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="ti ti-alert-triangle" :class="$style.i"></i>
<i v-else class="ti ti-info-circle" :class="$style.i"></i>
<slot></slot>
<div><slot></slot></div>
<button v-if="closable" :class="$style.button" class="_button" @click="close()"><i class="ti ti-x"></i></button>
</div>
</template>
@ -16,11 +17,23 @@ import { } from 'vue';
const props = defineProps<{
warn?: boolean;
closable?: boolean;
}>();
const emit = defineEmits<{
(ev: 'close'): void;
}>();
function close() {
//
emit('close');
}
</script>
<style lang="scss" module>
.root {
display: flex;
align-items: center;
padding: 12px 14px;
font-size: 90%;
background: var(--infoBg);
@ -37,4 +50,9 @@ const props = defineProps<{
.i {
margin-right: 4px;
}
.button {
margin-left: auto;
padding: 4px;
}
</style>

View file

@ -34,7 +34,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onMounted, shallowRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { soundConfigStore } from '@/scripts/sound.js';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{

View file

@ -47,25 +47,34 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<article v-else :class="$style.article" @contextmenu.stop="onContextmenu">
<div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div>
<MkAvatar :class="$style.avatar" :user="appearNote.user" link preview/>
<MkAvatar :class="$style.avatar" :user="appearNote.user" :link="!mock" :preview="!mock"/>
<div :class="$style.main">
<MkNoteHeader :note="appearNote" :mini="true"/>
<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
<div style="container-type: inline-size;">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'account'" :i="$i"/>
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'account'"/>
<MkCwButton v-model="showContent" :note="appearNote" style="margin: 4px 0;"/>
</p>
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm v-if="appearNote.text" :parsedNodes="parsed" :text="appearNote.text" :author="appearNote.user" :nyaize="'account'" :i="$i" :emojiUrls="appearNote.emojis"/>
<Mfm
v-if="appearNote.text"
:parsedNodes="parsed"
:text="appearNote.text"
:author="appearNote.user"
:nyaize="'account'"
:emojiUrls="appearNote.emojis"
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
/>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else>
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'account'" :i="$i" :emojiUrls="appearNote.emojis"/>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'account'" :emojiUrls="appearNote.emojis"/>
</div>
</div>
</div>
@ -84,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
<MkReactionsViewer :note="appearNote" :maxNumber="16">
<MkReactionsViewer :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction">
<template #more>
<div :class="$style.reactionOmitted">{{ i18n.ts.more }}</div>
</template>
@ -136,7 +145,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';
@ -159,7 +168,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 { useNoteCapture } from '@/scripts/use-note-capture.js';
import { deepClone } from '@/scripts/clone.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
@ -170,9 +179,19 @@ import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@/scripts/collapsed.js';
const props = defineProps<{
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
pinned?: boolean;
mock?: boolean;
}>(), {
mock: false,
});
provide('mock', props.mock);
const emit = defineEmits<{
(ev: 'reaction', emoji: string): void;
(ev: 'removeReaction', emoji: string): void;
}>();
const inChannel = inject('inChannel', null);
@ -183,11 +202,17 @@ let note = $ref(deepClone(props.note));
// plugin
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result:Misskey.entities.Note | null = deepClone(note);
let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result);
if (result === null) return isDeleted.value = true;
try {
result = await interruptor.handler(result);
if (result === null) {
isDeleted.value = true;
return;
}
} catch (err) {
console.error(err);
}
}
note = result;
});
@ -209,8 +234,8 @@ const clipButton = shallowRef<HTMLElement>();
let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note);
const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false);
const parsed = appearNote.text ? mfm.parse(appearNote.text) : null;
const urls = parsed ? extractUrlFromMfm(parsed) : null;
const parsed = $computed(() => appearNote.text ? mfm.parse(appearNote.text) : null);
const urls = $computed(() => parsed ? extractUrlFromMfm(parsed) : null);
const isLong = shouldCollapsed(appearNote, urls ?? []);
const collapsed = ref(appearNote.cw == null && isLong);
const isDeleted = ref(false);
@ -232,126 +257,61 @@ 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', {
provide('react', (reaction: string) => {
os.api('notes/reactions/create', {
noteId: appearNote.id,
limit: 11,
reaction: reaction,
});
const users = renotes.map(x => x.user);
if (users.length < 1) return;
os.popup(MkUsersTooltip, {
showing,
users,
count: appearNote.renoteCount,
targetElement: renoteButton.value,
}, {}, 'closed');
});
type Visibility = 'public' | 'home' | 'followers' | 'specified';
if (props.mock) {
watch(() => props.note, (to) => {
note = deepClone(to);
}, { deep: true });
} else {
useNoteCapture({
rootEl: el,
note: $$(appearNote),
pureNote: $$(note),
isDeletedRef: isDeleted,
});
}
// defaultStore.state.visibilitystringstring
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';
if (!props.mock) {
useTooltip(renoteButton, async (showing) => {
const renotes = await os.api('notes/renotes', {
noteId: appearNote.id,
limit: 11,
});
const users = renotes.map(x => x.user);
if (users.length < 1) return;
os.popup(MkUsersTooltip, {
showing,
users,
count: appearNote.renoteCount,
targetElement: renoteButton.value,
}, {}, 'closed');
});
}
function renote(viaKeyboard = false) {
pleaseLogin();
showMovedDialog();
let items = [] as MenuItem[];
if (appearNote.channel) {
items = items.concat([{
text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat',
action: () => {
const el = 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');
}
os.api('notes/create', {
renoteId: appearNote.id,
channelId: appearNote.channelId,
}).then(() => {
os.toast(i18n.ts.renoted);
});
},
}, {
text: i18n.ts.inChannelQuote,
icon: 'ti ti-quote',
action: () => {
os.post({
renote: appearNote,
channel: appearNote.channel,
});
},
}, null]);
}
items = items.concat([{
text: i18n.ts.renote,
icon: 'ti ti-repeat',
action: () => {
const el = 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');
}
os.api('notes/create', {
localOnly,
visibility,
renoteId: appearNote.id,
}).then(() => {
os.toast(i18n.ts.renoted);
});
},
}, {
text: i18n.ts.quote,
icon: 'ti ti-quote',
action: () => {
os.post({
renote: appearNote,
});
},
}]);
os.popupMenu(items, renoteButton.value, {
const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock });
os.popupMenu(menu, renoteButton.value, {
viaKeyboard,
});
}
function reply(viaKeyboard = false): void {
pleaseLogin();
if (props.mock) {
return;
}
os.post({
reply: appearNote,
channel: appearNote.channel,
@ -365,6 +325,10 @@ function react(viaKeyboard = false): void {
pleaseLogin();
showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') {
if (props.mock) {
return;
}
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: '❤️',
@ -379,6 +343,11 @@ function react(viaKeyboard = false): void {
} else {
blur();
reactionPicker.show(reactButton.value, reaction => {
if (props.mock) {
emit('reaction', reaction);
return;
}
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction,
@ -395,12 +364,22 @@ function react(viaKeyboard = false): void {
function undoReact(note): void {
const oldReaction = note.myReaction;
if (!oldReaction) return;
if (props.mock) {
emit('removeReaction', oldReaction);
return;
}
os.api('notes/reactions/delete', {
noteId: note.id,
});
}
function onContextmenu(ev: MouseEvent): void {
if (props.mock) {
return;
}
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
// Audio
@ -422,6 +401,10 @@ function onContextmenu(ev: MouseEvent): void {
}
function menu(viaKeyboard = false): void {
if (props.mock) {
return;
}
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value });
os.popupMenu(menu, menuButton.value, {
viaKeyboard,
@ -429,10 +412,18 @@ function menu(viaKeyboard = false): void {
}
async function clip() {
if (props.mock) {
return;
}
os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
function showRenoteMenu(viaKeyboard = false): void {
if (props.mock) {
return;
}
function getUnrenote(): MenuItem {
return {
text: i18n.ts.unrenote,
@ -490,6 +481,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

@ -67,19 +67,28 @@ SPDX-License-Identifier: AGPL-3.0-only
</header>
<div :class="$style.noteContent">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'account'" :i="$i"/>
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'account'"/>
<MkCwButton v-model="showContent" :note="appearNote"/>
</p>
<div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm v-if="appearNote.text" :parsedNodes="parsed" :text="appearNote.text" :author="appearNote.user" :nyaize="'account'" :i="$i" :emojiUrls="appearNote.emojis"/>
<Mfm
v-if="appearNote.text"
:parsedNodes="parsed"
:text="appearNote.text"
:author="appearNote.user"
:nyaize="'account'"
:emojiUrls="appearNote.emojis"
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
/>
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else>
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'account'" :i="$i" :emojiUrls="appearNote.emojis"/>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'account'" :emojiUrls="appearNote.emojis"/>
</div>
</div>
<div v-if="appearNote.files.length > 0">
@ -184,7 +193,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, inject, onMounted, ref, shallowRef } from 'vue';
import { computed, inject, onMounted, provide, ref, shallowRef } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import MkNoteSub from '@/components/MkNoteSub.vue';
@ -206,7 +215,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 { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js';
import { useNoteCapture } from '@/scripts/use-note-capture.js';
import { deepClone } from '@/scripts/clone.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
@ -230,11 +239,17 @@ let note = $ref(deepClone(props.note));
// plugin
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result:Misskey.entities.Note | null = deepClone(note);
let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result);
if (result === null) return isDeleted.value = true;
try {
result = await interruptor.handler(result);
if (result === null) {
isDeleted.value = true;
return;
}
} catch (err) {
console.error(err);
}
}
note = result;
});
@ -276,6 +291,13 @@ const keymap = {
's': () => showContent.value !== showContent.value,
};
provide('react', (reaction: string) => {
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction,
});
});
let tab = $ref('replies');
let reactionTabType = $ref(null);
@ -325,69 +347,8 @@ function renote(viaKeyboard = false) {
pleaseLogin();
showMovedDialog();
let items = [] as MenuItem[];
if (appearNote.channel) {
items = items.concat([{
text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat',
action: () => {
const el = 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');
}
os.api('notes/create', {
renoteId: appearNote.id,
channelId: appearNote.channelId,
}).then(() => {
os.toast(i18n.ts.renoted);
});
},
}, {
text: i18n.ts.inChannelQuote,
icon: 'ti ti-quote',
action: () => {
os.post({
renote: appearNote,
channel: appearNote.channel,
});
},
}, null]);
}
items = items.concat([{
text: i18n.ts.renote,
icon: 'ti ti-repeat',
action: () => {
const el = 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');
}
os.api('notes/create', {
renoteId: appearNote.id,
}).then(() => {
os.toast(i18n.ts.renoted);
});
},
}, {
text: i18n.ts.quote,
icon: 'ti ti-quote',
action: () => {
os.post({
renote: appearNote,
});
},
}]);
os.popupMenu(items, renoteButton.value, {
const { menu } = getRenoteMenu({ note: note, renoteButton });
os.popupMenu(menu, renoteButton.value, {
viaKeyboard,
});
}

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]">
@ -29,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { } from 'vue';
import { inject } from 'vue';
import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js';
import { notePage } from '@/filters/note.js';
@ -38,6 +44,8 @@ import { userPage } from '@/filters/user.js';
defineProps<{
note: Misskey.entities.Note;
}>();
const mock = inject<boolean>('mock', false);
</script>
<style lang="scss" module>

View file

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
<p v-if="note.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'account'" :i="$i" :emojiUrls="note.emojis"/>
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'account'" :emojiUrls="note.emojis"/>
<MkCwButton v-model="showContent" :note="note"/>
</p>
<div v-show="note.cw == null || showContent">

View file

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
<p v-if="note.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'account'" :i="$i"/>
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'account'"/>
<MkCwButton v-model="showContent" :note="note"/>
</p>
<div v-show="note.cw == null || showContent">

View file

@ -96,6 +96,10 @@ onUnmounted(() => {
onDeactivated(() => {
if (connection) connection.dispose();
});
defineExpose({
reload,
});
</script>
<style lang="scss" module>

View file

@ -166,7 +166,7 @@ defineExpose({
<style lang="scss" module>
.root {
overscroll-behavior: none;
overscroll-behavior: contain;
min-height: 100%;
background: var(--bg);

View file

@ -98,7 +98,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { inject, watch, nextTick, onMounted, defineAsyncComponent } from 'vue';
import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor';
@ -143,15 +143,22 @@ const props = withDefaults(defineProps<{
fixed?: boolean;
autofocus?: boolean;
freezeAfterPosted?: boolean;
mock?: boolean;
}>(), {
initialVisibleUsers: () => [],
autofocus: true,
mock: false,
});
provide('mock', props.mock);
const emit = defineEmits<{
(ev: 'posted'): void;
(ev: 'cancel'): void;
(ev: 'esc'): void;
// Mock
(ev: 'fileChangeSensitive', fileId: string, to: boolean): void;
}>();
const textareaEl = $shallowRef<HTMLTextAreaElement | null>(null);
@ -239,7 +246,7 @@ const maxTextLength = $computed((): number => {
});
const canPost = $computed((): boolean => {
return !posting && !posted &&
return !props.mock && !posting && !posted &&
(1 <= textLength || 1 <= files.length || !!poll || !!props.renote) &&
(textLength <= maxTextLength) &&
(!poll || poll.choices.length >= 2);
@ -292,6 +299,10 @@ if (props.reply && props.reply.text != null) {
}
}
if ($i?.isSilenced && visibility === 'public') {
visibility = 'home';
}
if (props.channel) {
visibility = 'public';
localOnly = true; // TODO:
@ -396,6 +407,8 @@ function focus() {
}
function chooseFileFrom(ev) {
if (props.mock) return;
selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => {
for (const file of files_) {
files.push(file);
@ -408,6 +421,9 @@ function detachFile(id) {
}
function updateFileSensitive(file, sensitive) {
if (props.mock) {
emit('fileChangeSensitive', file.id, sensitive);
}
files[files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
}
@ -420,6 +436,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);
});
@ -434,6 +452,7 @@ function setVisibility() {
os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), {
currentVisibility: visibility,
isSilenced: $i?.isSilenced,
localOnly: localOnly,
src: visibilityButton,
}, {
@ -545,6 +564,8 @@ function onCompositionEnd(ev: CompositionEvent) {
}
async function onPaste(ev: ClipboardEvent) {
if (props.mock) return;
for (const { item, i } of Array.from(ev.clipboardData.items, (item, i) => ({ item, i }))) {
if (item.kind === 'file') {
const file = item.getAsFile();
@ -629,7 +650,7 @@ function onDrop(ev): void {
}
function saveDraft() {
if (props.instant) return;
if (props.instant || props.mock) return;
const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}');
@ -674,6 +695,8 @@ async function post(ev?: MouseEvent) {
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
if (props.mock) return;
const annoying =
text.includes('$[x2') ||
text.includes('$[x3') ||
@ -727,7 +750,11 @@ async function post(ev?: MouseEvent) {
// plugin
if (notePostInterruptors.length > 0) {
for (const interruptor of notePostInterruptors) {
postData = await interruptor.handler(deepClone(postData));
try {
postData = await interruptor.handler(deepClone(postData));
} catch (err) {
console.error(err);
}
}
}
@ -839,6 +866,8 @@ function showActions(ev) {
let postAccount = $ref<Misskey.entities.UserDetailed | null>(null);
function openAccountMenu(ev: MouseEvent) {
if (props.mock) return;
openAccountMenu_({
withExtraOperation: false,
includeCurrentAccount: true,
@ -869,7 +898,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;
@ -1043,6 +1072,7 @@ defineExpose({
.preview {
padding: 16px 20px 0 20px;
min-height: 75px;
max-height: 150px;
overflow: auto;
}

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

@ -23,16 +23,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted } from 'vue';
import { onMounted, onUnmounted, watch } from 'vue';
import { deviceKind } from '@/scripts/device-kind.js';
import { i18n } from '@/i18n.js';
import { getScrollContainer } from '@/scripts/scroll.js';
const SCROLL_STOP = 10;
const MAX_PULL_DISTANCE = Infinity;
const FIRE_THRESHOLD = 230;
const RELEASE_TRANSITION_DURATION = 200;
const PULL_BRAKE_BASE = 2;
const PULL_BRAKE_FACTOR = 200;
const PULL_BRAKE_BASE = 1.5;
const PULL_BRAKE_FACTOR = 170;
let isPullStart = $ref(false);
let isPullEnd = $ref(false);
@ -57,18 +58,6 @@ const emit = defineEmits<{
(ev: 'refresh'): void;
}>();
function getScrollableParentElement(node) {
if (node == null) {
return null;
}
if (node.scrollHeight > node.clientHeight) {
return node;
} else {
return getScrollableParentElement(node.parentNode);
}
}
function getScreenY(event) {
if (supportPointerDesktop) {
return event.screenY;
@ -138,12 +127,9 @@ function moveEnd() {
}
}
function moving(event) {
function moving(event: TouchEvent | PointerEvent) {
if (!isPullStart || isRefreshing || disabled) return;
if (!scrollEl) {
scrollEl = getScrollableParentElement(rootEl);
}
if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance)) {
pullDistance = 0;
isPullEnd = false;
@ -159,6 +145,10 @@ function moving(event) {
const moveHeight = moveScreenY - startScreenY!;
pullDistance = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE);
if (pullDistance > 0) {
if (event.cancelable) event.preventDefault();
}
isPullEnd = pullDistance >= FIRE_THRESHOLD;
}
@ -178,24 +168,48 @@ function setDisabled(value) {
disabled = value;
}
onMounted(() => {
// pull to refresh便
//supportPointerDesktop = !!window.PointerEvent && deviceKind === 'desktop';
function onScrollContainerScroll() {
const scrollPos = scrollEl!.scrollTop;
if (supportPointerDesktop) {
rootEl.addEventListener('pointerdown', moveStart);
// downup
window.addEventListener('pointerup', moveEnd);
rootEl.addEventListener('pointermove', moving, { passive: true });
// When at the top of the page, disable vertical overscroll so passive touch listeners can take over.
if (scrollPos === 0) {
scrollEl!.style.touchAction = 'pan-x pan-down pinch-zoom';
registerEventListenersForReadyToPull();
} else {
rootEl.addEventListener('touchstart', moveStart);
rootEl.addEventListener('touchend', moveEnd);
rootEl.addEventListener('touchmove', moving, { passive: true });
scrollEl!.style.touchAction = 'auto';
unregisterEventListenersForReadyToPull();
}
}
function registerEventListenersForReadyToPull() {
if (rootEl == null) return;
rootEl.addEventListener('touchstart', moveStart, { passive: true });
rootEl.addEventListener('touchmove', moving, { passive: false }); // passive: falsepreventDefault使
}
function unregisterEventListenersForReadyToPull() {
if (rootEl == null) return;
rootEl.removeEventListener('touchstart', moveStart);
rootEl.removeEventListener('touchmove', moving);
}
onMounted(() => {
if (rootEl == null) return;
scrollEl = getScrollContainer(rootEl);
if (scrollEl == null) return;
scrollEl.addEventListener('scroll', onScrollContainerScroll, { passive: true });
rootEl.addEventListener('touchend', moveEnd, { passive: true });
registerEventListenersForReadyToPull();
});
onUnmounted(() => {
if (supportPointerDesktop) window.removeEventListener('pointerup', moveEnd);
if (scrollEl) scrollEl.removeEventListener('scroll', onScrollContainerScroll);
unregisterEventListenersForReadyToPull();
});
defineExpose({

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>
@ -73,7 +73,6 @@ function getReactionName(reaction: string): string {
}
.users {
contain: content;
flex: 1;
min-width: 0;
margin: -4px 14px 0 10px;
@ -85,7 +84,7 @@ function getReactionName(reaction: string): string {
line-height: 24px;
padding-top: 4px;
white-space: nowrap;
overflow: hidden;
overflow: visible;
text-overflow: ellipsis;
}

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

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span>
<MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :emojiUrls="note.emojis"/>
<Mfm v-if="note.text" :text="note.text" :author="note.user" :emojiUrls="note.emojis"/>
<MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
</div>
<details v-if="note.files.length > 0">

View file

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, provide, onUnmounted } from 'vue';
import MkNotes from '@/components/MkNotes.vue';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { useStream, reloadStream } from '@/stream.js';
import { useStream } from '@/stream.js';
import * as sound from '@/scripts/sound.js';
import { $i } from '@/account.js';
import { instance } from '@/instance.js';
@ -201,7 +201,6 @@ function reloadTimeline() {
tlNotesCount = 0;
tlComponent.pagingComponent?.reload().then(() => {
reloadStream();
res();
});
});

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="ti ti-arrow-back-up"></i> <b>{{ i18n.ts.reply }}</b> {{ i18n.ts._initialTutorial._note.reply }}</div>
<div><i class="ti ti-repeat"></i> <b>{{ i18n.ts.renote }}</b> {{ i18n.ts._initialTutorial._note.renote }}</div>
<div><i class="ti ti-plus"></i> <b>{{ i18n.ts.reaction }}</b> {{ i18n.ts._initialTutorial._note.reaction }}</div>
<div><i class="ti ti-dots"></i> <b>{{ i18n.ts.menu }}</b> {{ i18n.ts._initialTutorial._note.menu }}</div>
</div>
</div>
<div v-else-if="phase === 'howToReact'" class="_gaps">
<div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._reaction.description }}</div>
<div>{{ i18n.ts._initialTutorial._reaction.letsTryReacting }}</div>
<MkNote :class="$style.exampleNoteRoot" :note="exampleNote" :mock="true" @reaction="addReaction" @removeReaction="removeReaction" @updateReaction="updateReaction"/>
<div v-if="onceReacted"><b style="color: var(--accent);"><i class="ti ti-check"></i> {{ i18n.ts._initialTutorial.wellDone }}</b> {{ i18n.ts._initialTutorial._reaction.reactNotification }}<br>{{ i18n.ts._initialTutorial._reaction.reactDone }}</div>
</div>
</template>
<script setup lang="ts">
import * as Misskey from 'misskey-js';
import { ref, reactive } from 'vue';
import { i18n } from '@/i18n.js';
import { globalEvents } from '@/events.js';
import { $i } from '@/account.js';
import MkNote from '@/components/MkNote.vue';
const props = defineProps<{
phase: 'aboutNote' | 'howToReact';
}>();
const emit = defineEmits<{
(ev: 'reacted'): void;
}>();
const exampleNote = reactive<Misskey.entities.Note>({
id: '0000000000',
createdAt: '2019-04-14T17:30:49.181Z',
userId: '0000000001',
user: {
id: '0000000001',
name: '藍',
username: 'ai',
host: null,
avatarDecorations: [],
avatarUrl: '/client-assets/tutorial/ai.webp',
avatarBlurhash: 'eiKmhHIByXxZ~qWXs:-pR*NbR*s:xuRjoL-oR*WCt6WWf6WVf6oeWB',
isBot: false,
isCat: true,
emojis: {},
onlineStatus: null,
badgeRoles: [],
},
text: 'just setting up my msky',
cw: null,
visibility: 'public',
localOnly: false,
reactionAcceptance: null,
renoteCount: 0,
repliesCount: 1,
reactions: {},
reactionEmojis: {},
fileIds: [],
files: [],
replyId: null,
renoteId: null,
});
const onceReacted = ref<boolean>(false);
function addReaction(emoji) {
onceReacted.value = true;
emit('reacted');
exampleNote.reactions[emoji] = 1;
exampleNote.myReaction = emoji;
doNotification(emoji);
}
function doNotification(emoji: string): void {
if (!$i || !emoji) return;
const notification: Misskey.entities.Notification = {
id: Math.random().toString(),
createdAt: new Date().toUTCString(),
isRead: false,
type: 'reaction',
reaction: emoji,
user: $i,
userId: $i.id,
note: exampleNote,
};
globalEvents.emit('clientNotification', notification);
}
function removeReaction(emoji) {
delete exampleNote.reactions[emoji];
exampleNote.myReaction = undefined;
}
</script>
<style lang="scss" module>
.exampleNoteRoot {
border-radius: var(--radius);
border: var(--panelBorder);
background: var(--panel);
}
.divider {
height: 1px;
background: var(--divider);
}
</style>

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" :autofocus="false"/>
<MkFormSection>
<template #label>{{ i18n.ts.visibility }}</template>
<div class="_gaps">
<div>{{ i18n.ts._initialTutorial._postNote._visibility.description }}</div>
<div><i class="ti ti-world"></i> <b>{{ i18n.ts._visibility.public }}</b> {{ i18n.ts._initialTutorial._postNote._visibility.public }}</div>
<div><i class="ti ti-home"></i> <b>{{ i18n.ts._visibility.home }}</b> {{ i18n.ts._initialTutorial._postNote._visibility.home }}</div>
<div><i class="ti ti-lock"></i> <b>{{ i18n.ts._visibility.followers }}</b> {{ i18n.ts._initialTutorial._postNote._visibility.followers }}</div>
<div class="_gaps_s">
<div><i class="ti ti-mail"></i> <b>{{ i18n.ts._visibility.specified }}</b> {{ i18n.ts._initialTutorial._postNote._visibility.direct }}</div>
<MkInfo :warn="true">
<b>{{ i18n.ts._initialTutorial._postNote._visibility.doNotSendConfidencialOnDirect1 }}</b> {{ i18n.ts._initialTutorial._postNote._visibility.doNotSendConfidencialOnDirect2 }}
</MkInfo>
</div>
<div><i class="ti ti-rocket-off"></i> <b>{{ i18n.ts._visibility.disableFederation }}</b> {{ i18n.ts._initialTutorial._postNote._visibility.localOnly }}</div>
</div>
</MkFormSection>
<MkFormSection>
<template #label>{{ i18n.ts._initialTutorial._postNote._cw.title }}</template>
<div class="_gaps">
<div>{{ i18n.ts._initialTutorial._postNote._cw.description }}</div>
<MkNote :class="$style.exampleRoot" :note="exampleCWNote" :mock="true"/>
<div>{{ i18n.ts._initialTutorial._postNote._cw.useCases }}</div>
</div>
</MkFormSection>
</div>
</template>
<script setup lang="ts">
import * as Misskey from 'misskey-js';
import { reactive } from 'vue';
import { i18n } from '@/i18n.js';
import MkNote from '@/components/MkNote.vue';
import MkPostForm from '@/components/MkPostForm.vue';
import MkFormSection from '@/components/form/section.vue';
import MkInfo from '@/components/MkInfo.vue';
const exampleCWNote = reactive<Misskey.entities.Note>({
id: '0000000000',
createdAt: '2019-04-14T17:30:49.181Z',
userId: '0000000001',
user: {
id: '0000000001',
name: '藍',
username: 'ai',
host: null,
avatarDecorations: [],
avatarUrl: '/client-assets/tutorial/ai.webp',
avatarBlurhash: 'eiKmhHIByXxZ~qWXs:-pR*NbR*s:xuRjoL-oR*WCt6WWf6WVf6oeWB',
isBot: false,
isCat: true,
emojis: {},
onlineStatus: null,
badgeRoles: [],
},
text: i18n.ts._initialTutorial._postNote._cw._exampleNote.note,
cw: i18n.ts._initialTutorial._postNote._cw._exampleNote.cw,
visibility: 'public',
localOnly: false,
reactionAcceptance: null,
renoteCount: 0,
repliesCount: 1,
reactions: {},
reactionEmojis: {},
fileIds: [],
files: [],
replyId: null,
renoteId: null,
});
</script>
<style lang="scss" module>
.exampleRoot {
max-width: none!important;
border-radius: var(--radius);
border: var(--panelBorder);
background: var(--panel);
}
.divider {
height: 1px;
background: var(--divider);
}
.image {
max-width: 300px;
margin: 0 auto;
}
.post {
position: relative;
display: block;
width: 100%;
height: 40px;
color: var(--fgOnAccent);
font-weight: bold;
text-align: left;
&:before {
content: "";
display: block;
width: calc(100% - 38px);
height: 100%;
margin: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
}
}
.postIcon {
position: relative;
margin-left: 30px;
margin-right: 8px;
width: 32px;
}
.postText {
position: relative;
line-height: 40px;
}
</style>

View file

@ -0,0 +1,145 @@
<!--
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"
:autofocus="false"
:initialNote="exampleNote"
@fileChangeSensitive="doSucceeded"
></MkPostForm>
<div v-if="onceSucceeded"><b style="color: var(--accent);"><i class="ti ti-check"></i> {{ i18n.ts._initialTutorial.wellDone }}</b> {{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.sensitiveSucceeded }}</div>
<MkFolder>
<template #label>{{ i18n.ts.previewNoteText }}</template>
<MkNote :mock="true" :note="exampleNote" :class="$style.exampleRoot"></MkNote>
</MkFolder>
</div>
</template>
<script setup lang="ts">
import * as Misskey from 'misskey-js';
import { ref, reactive } from 'vue';
import { i18n } from '@/i18n.js';
import MkPostForm from '@/components/MkPostForm.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkNote from '@/components/MkNote.vue';
import { $i } from '@/account.js';
const emit = defineEmits<{
(ev: 'succeeded'): void;
}>();
const onceSucceeded = ref<boolean>(false);
function doSucceeded(fileId: string, to: boolean) {
if (fileId === exampleNote.fileIds[0] && to) {
onceSucceeded.value = true;
emit('succeeded');
}
}
const exampleNote = reactive<Misskey.entities.Note>({
id: '0000000000',
createdAt: '2019-04-14T17:30:49.181Z',
userId: '0000000001',
user: $i!,
text: i18n.ts._initialTutorial._howToMakeAttachmentsSensitive._exampleNote.note,
cw: null,
visibility: 'public',
localOnly: false,
reactionAcceptance: null,
renoteCount: 0,
repliesCount: 1,
reactions: {},
reactionEmojis: {},
fileIds: ['0000000002'],
files: [{
id: '0000000002',
createdAt: '2019-04-14T17:30:49.181Z',
name: 'natto_failed.webp',
type: 'image/webp',
md5: 'c44286cf152d0740be0ce5ad45ea85c3',
size: 827532,
isSensitive: false,
blurhash: 'LXNA3TD*XAIA%1%M%gt7.TofRioz',
properties: {
width: 256,
height: 256,
},
url: '/client-assets/tutorial/natto_failed.webp',
thumbnailUrl: '/client-assets/tutorial/natto_failed.webp',
comment: null,
folderId: null,
folder: null,
userId: null,
user: null,
}],
replyId: null,
renoteId: null,
});
</script>
<style lang="scss" module>
.exampleRoot {
border-radius: var(--radius);
border: var(--panelBorder);
background: var(--panel);
}
.divider {
height: 1px;
background: var(--divider);
}
.image {
max-width: 300px;
margin: 0 auto;
}
.post {
position: relative;
display: block;
width: 100%;
height: 40px;
color: var(--fgOnAccent);
font-weight: bold;
text-align: left;
&:before {
content: "";
display: block;
width: calc(100% - 38px);
height: 100%;
margin: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
}
}
.postIcon {
position: relative;
margin-left: 30px;
margin-right: 8px;
width: 32px;
}
.postText {
position: relative;
line-height: 40px;
}
</style>

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="ti ti-home"></i> <b>{{ i18n.ts._timelines.home }}</b> {{ i18n.ts._initialTutorial._timeline.home }}</div>
<div><i class="ti ti-planet"></i> <b>{{ i18n.ts._timelines.local }}</b> {{ i18n.ts._initialTutorial._timeline.local }}</div>
<div><i class="ti ti-universe"></i> <b>{{ i18n.ts._timelines.social }}</b> {{ i18n.ts._initialTutorial._timeline.social }}</div>
<div><i class="ti ti-whirl"></i> <b>{{ i18n.ts._timelines.global }}</b> {{ i18n.ts._initialTutorial._timeline.global }}</div>
</div>
<div class="_gaps_s">
<div>{{ i18n.ts._initialTutorial._timeline.description2 }}</div>
<img :class="$style.image" src="/client-assets/tutorial/timeline_tab.png"/>
</div>
<div :class="$style.divider"></div>
<I18n :src="i18n.ts._initialTutorial._timeline.description3" tag="div" style="padding: 0 16px;">
<template #link>
<a href="https://misskey-hub.net/docs/features/timeline.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
</template>
</I18n>
</div>
</template>
<script setup lang="ts">
import { i18n } from '@/i18n.js';
</script>
<style lang="scss" module>
.exampleNoteRoot {
border-radius: var(--radius);
border: var(--panelBorder);
background: var(--panel);
}
.divider {
height: 1px;
background: var(--divider);
}
.image {
max-width: 300px;
margin: 0 auto;
}
.post {
position: relative;
display: block;
width: 100%;
height: 40px;
color: var(--fgOnAccent);
font-weight: bold;
text-align: left;
&:before {
content: "";
display: block;
width: calc(100% - 38px);
height: 100%;
margin: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
}
}
.postIcon {
position: relative;
margin-left: 30px;
margin-right: 8px;
width: 32px;
}
.postText {
position: relative;
line-height: 40px;
}
</style>

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="ti ti-pencil"></i> {{ i18n.ts._initialTutorial._note.title }}</template>
<template v-else-if="page === 2" #header><i class="ti ti-mood-smile"></i> {{ i18n.ts._initialTutorial._reaction.title }}</template>
<template v-else-if="page === 3" #header><i class="ti ti-home"></i> {{ i18n.ts._initialTutorial._timeline.title }}</template>
<template v-else-if="page === 4" #header><i class="ti ti-pencil-plus"></i> {{ i18n.ts._initialTutorial._postNote.title }}</template>
<template v-else-if="page === 5" #header><i class="ti ti-eye-exclamation"></i> {{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.title }}</template>
<template v-else #header>{{ i18n.ts._initialTutorial.title }}</template>
<div style="overflow-x: clip;">
<Transition
mode="out-in"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
>
<template v-if="page === 0">
<div :class="$style.centerPage">
<MkAnimBg style="position: absolute; top: 0;" :scale="1.5"/>
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps" style="text-align: center;">
<i class="ti ti-confetti" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
<div style="font-size: 120%;">{{ i18n.ts._initialTutorial._landing.title }}</div>
<div>{{ i18n.ts._initialTutorial._landing.description }}</div>
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" @click="page++">{{ i18n.ts._initialTutorial.launchTutorial }} <i class="ti ti-arrow-right"></i></MkButton>
<MkButton style="margin: 0 auto;" transparent rounded @click="close(true)">{{ i18n.ts.close }}</MkButton>
</div>
</MkSpacer>
</div>
</template>
<template v-else-if="page === 1">
<div style="height: 100cqh; overflow: auto;">
<div :class="$style.pageRoot">
<MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
<XNote phase="aboutNote"/>
</MkSpacer>
<div :class="$style.pageFooter">
<div class="_buttonsCenter">
<MkButton v-if="initialPage !== 1" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="page === 2">
<div style="height: 100cqh; overflow: auto;">
<div :class="$style.pageRoot">
<MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
<div class="_gaps">
<XNote phase="howToReact" @reacted="isReactionTutorialPushed = true"/>
<div v-if="!isReactionTutorialPushed">{{ i18n.ts._initialTutorial._reaction.reactToContinue }}</div>
</div>
</MkSpacer>
<div :class="$style.pageFooter">
<div class="_buttonsCenter">
<MkButton v-if="initialPage !== 2" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate :disabled="!isReactionTutorialPushed" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="page === 3">
<div style="height: 100cqh; overflow: auto;">
<div :class="$style.pageRoot">
<MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
<XTimeline/>
</MkSpacer>
<div :class="$style.pageFooter">
<div class="_buttonsCenter">
<MkButton v-if="initialPage !== 3" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="page === 4">
<div style="height: 100cqh; overflow: auto;">
<div :class="$style.pageRoot">
<MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
<XPostNote/>
</MkSpacer>
<div :class="$style.pageFooter">
<div class="_buttonsCenter">
<MkButton v-if="initialPage !== 3" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="page === 5">
<div style="height: 100cqh; overflow: auto;">
<div :class="$style.pageRoot">
<MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
<div class="_gaps">
<XSensitive @succeeded="isSensitiveTutorialSucceeded = true"/>
<div v-if="!isSensitiveTutorialSucceeded">{{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.doItToContinue }}</div>
</div>
</MkSpacer>
<div :class="$style.pageFooter">
<div class="_buttonsCenter">
<MkButton v-if="initialPage !== 2" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate :disabled="!isSensitiveTutorialSucceeded" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="page === 6">
<div :class="$style.centerPage">
<MkAnimBg style="position: absolute; top: 0;" :scale="1.5"/>
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps" style="text-align: center;">
<i class="ti ti-check" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
<div style="font-size: 120%;">{{ i18n.ts._initialTutorial._done.title }}</div>
<I18n :src="i18n.ts._initialTutorial._done.description" tag="div" style="padding: 0 16px;">
<template #link>
<a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
</template>
</I18n>
<div>{{ i18n.t('_initialAccountSetting.haveFun', { name: instance.name ?? host }) }}</div>
<div class="_buttonsCenter" style="margin-top: 16px;">
<MkButton v-if="initialPage !== 4" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton rounded primary gradate @click="close(false)">{{ i18n.ts.close }}</MkButton>
</div>
</div>
</MkSpacer>
</div>
</template>
</Transition>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { ref, shallowRef, watch } from 'vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue';
import XNote from '@/components/MkTutorialDialog.Note.vue';
import XTimeline from '@/components/MkTutorialDialog.Timeline.vue';
import XPostNote from '@/components/MkTutorialDialog.PostNote.vue';
import XSensitive from '@/components/MkTutorialDialog.Sensitive.vue';
import MkAnimBg from '@/components/MkAnimBg.vue';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { host } from '@/config.js';
import { claimAchievement } from '@/scripts/achievements.js';
import * as os from '@/os.js';
const props = defineProps<{
initialPage?: number;
}>();
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
// eslint-disable-next-line vue/no-setup-props-destructure
const page = ref(props.initialPage ?? 0);
watch(page, (to) => {
//
if (to === 6) {
claimAchievement('tutorialCompleted');
}
});
const isReactionTutorialPushed = ref<boolean>(false);
const isSensitiveTutorialSucceeded = ref<boolean>(false);
async function close(skip: boolean) {
if (skip) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts._initialTutorial.skipAreYouSure,
});
if (canceled) return;
}
dialog.value?.close();
}
</script>
<style lang="scss" module>
.transition_x_enterActive,
.transition_x_leaveActive {
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateX(50px);
}
.transition_x_leaveTo {
opacity: 0;
transform: translateX(-50px);
}
.progressBar {
position: absolute;
top: 0;
left: 0;
z-index: 10;
width: 100%;
height: 4px;
}
.progressBarValue {
height: 100%;
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
transition: all 0.5s cubic-bezier(0,.5,.5,1);
}
.centerPage {
display: flex;
justify-content: center;
align-items: center;
height: 100cqh;
padding-bottom: 30px;
box-sizing: border-box;
}
.pageRoot {
display: flex;
flex-direction: column;
min-height: 100%;
}
.pageMain {
flex-grow: 1;
line-height: 1.5;
}
.pageFooter {
position: sticky;
bottom: 0;
left: 0;
flex-shrink: 0;
padding: 12px;
border-top: solid 0.5px var(--divider);
-webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
}
</style>

View file

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="$i && $i.id !== user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span>
<div :class="$style.description">
<div v-if="user.description" :class="$style.mfm">
<Mfm :text="user.description" :author="user" :i="$i"/>
<Mfm :text="user.description" :author="user"/>
</div>
<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
</div>

View file

@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.username"><MkAcct :user="user"/></div>
</div>
<div :class="$style.description">
<Mfm v-if="user.description" :class="$style.mfm" :text="user.description" :author="user" :i="$i"/>
<Mfm v-if="user.description" :class="$style.mfm" :text="user.description" :author="user"/>
<div v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</div>
</div>
<div :class="$style.status">

View file

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.description">
<div v-if="user.description" :class="$style.mfm">
<Mfm :text="user.description" :author="user" :i="$i"/>
<Mfm :text="user.description" :author="user"/>
</div>
<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
</div>

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="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
<div :class="$style.pageRoot">
<MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
<XProfile/>
</MkSpacer>
<div :class="$style.pageFooter">
<div class="_buttonsCenter">
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</MkSpacer>
</div>
</div>
</template>
<template v-else-if="page === 2">
<div style="height: 100cqh; overflow: auto;">
<MkSpacer :marginMin="20" :marginMax="28">
<XPrivacy/>
<div class="_buttonsCenter" style="margin-top: 16px;">
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
<div :class="$style.pageRoot">
<MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
<XPrivacy/>
</MkSpacer>
<div :class="$style.pageFooter">
<div class="_buttonsCenter">
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</MkSpacer>
</div>
</div>
</template>
<template v-else-if="page === 3">
@ -102,16 +110,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps" style="text-align: center;">
<i class="ti ti-check" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
<div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.initialAccountSettingCompleted }}</div>
<I18n :src="i18n.ts._initialAccountSetting.ifYouNeedLearnMore" tag="div" style="padding: 0 16px;">
<template #name>{{ instance.name ?? host }}</template>
<template #link>
<a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
</template>
</I18n>
<div>{{ i18n.t('_initialAccountSetting.haveFun', { name: instance.name ?? host }) }}</div>
<div>{{ i18n.t('_initialAccountSetting.youCanContinueTutorial', { name: instance.name ?? host }) }}</div>
<div class="_buttonsCenter" style="margin-top: 16px;">
<MkButton rounded primary gradate data-cy-user-setup-continue @click="launchTutorial()">{{ i18n.ts._initialAccountSetting.startTutorial }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
<div class="_buttonsCenter">
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate data-cy-user-setup-continue @click="close(false)">{{ i18n.ts.close }}</MkButton>
<MkButton rounded primary data-cy-user-setup-continue @click="setupComplete()">{{ i18n.ts.close }}</MkButton>
</div>
</div>
</MkSpacer>
@ -123,7 +128,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref, shallowRef, watch } from 'vue';
import { ref, shallowRef, watch, nextTick, defineAsyncComponent } from 'vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue';
import XProfile from '@/components/MkUserSetupDialog.Profile.vue';
@ -143,6 +148,7 @@ const emit = defineEmits<{
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
// eslint-disable-next-line vue/no-setup-props-destructure
const page = ref(defaultStore.state.accountSetupWizard);
watch(page, () => {
@ -158,10 +164,24 @@ async function close(skip: boolean) {
if (canceled) return;
}
dialog.value.close();
dialog.value?.close();
defaultStore.set('accountSetupWizard', -1);
}
function setupComplete() {
defaultStore.set('accountSetupWizard', -1);
dialog.value?.close();
}
function launchTutorial() {
setupComplete();
nextTick(() => {
os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {
initialPage: 1,
}, {}, 'closed');
});
}
async function later(later: boolean) {
if (later) {
const { canceled } = await os.confirm({
@ -171,7 +191,7 @@ async function later(later: boolean) {
if (canceled) return;
}
dialog.value.close();
dialog.value?.close();
defaultStore.set('accountSetupWizard', 0);
}
</script>
@ -214,10 +234,21 @@ async function later(later: boolean) {
box-sizing: border-box;
}
.pageRoot {
display: flex;
flex-direction: column;
min-height: 100%;
}
.pageMain {
flex-grow: 1;
}
.pageFooter {
position: sticky;
bottom: 0;
left: 0;
flex-shrink: 0;
padding: 12px;
border-top: solid 0.5px var(--divider);
-webkit-backdrop-filter: blur(15px);

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="ti ti-world"></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

@ -18,10 +18,10 @@ interface Props {
const contentSymbol = Symbol();
const observer = new ResizeObserver((entries) => {
const results: {
container: HTMLSpanElement;
transform: string;
}[] = [];
const results: {
container: HTMLSpanElement;
transform: string;
}[] = [];
for (const entry of entries) {
const content = (entry.target[contentSymbol] ? entry.target : entry.target.firstElementChild) as HTMLSpanElement;
const props: Required<Props> = content[contentSymbol];

View file

@ -5,14 +5,27 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<span v-if="errored">:{{ customEmojiName }}:</span>
<img v-else :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :src="url" :alt="alt" :title="alt" decoding="async" @error="errored = true" @load="errored = false"/>
<img
v-else
:class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
:src="url"
:alt="alt"
:title="alt"
decoding="async"
@error="errored = true"
@load="errored = false"
@click="onClick"
/>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, inject } from 'vue';
import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy.js';
import { defaultStore } from '@/store.js';
import { customEmojisMap } from '@/custom-emojis.js';
import * as os from '@/os.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{
name: string;
@ -21,8 +34,12 @@ const props = defineProps<{
host?: string | null;
url?: string;
useOriginalSize?: boolean;
menu?: boolean;
menuReaction?: boolean;
}>();
const react = inject<((name: string) => void) | null>('react', null);
const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substring(1, props.name.length - 1) : props.name).replace('@.', ''));
const isLocal = computed(() => !props.host && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@')));
@ -55,6 +72,28 @@ const url = computed(() => {
const alt = computed(() => `:${customEmojiName.value}:`);
let errored = $ref(url.value == null);
function onClick(ev: MouseEvent) {
if (props.menu) {
os.popupMenu([{
type: 'label',
text: `:${props.name}:`,
}, {
text: i18n.ts.copy,
icon: 'ti ti-copy',
action: () => {
copyToClipboard(`:${props.name}:`);
os.success();
},
}, ...(props.menuReaction && react ? [{
text: i18n.ts.doReaction,
icon: 'ti ti-plus',
action: () => {
react(`:${props.name}:`);
},
}] : [])], ev.currentTarget ?? ev.target);
}
}
</script>
<style lang="scss" module>

View file

@ -4,21 +4,28 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<img v-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle"/>
<span v-else-if="useOsNativeEmojis" :alt="props.emoji" @pointerenter="computeTitle">{{ props.emoji }}</span>
<img v-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick"/>
<span v-else-if="useOsNativeEmojis" :alt="props.emoji" @pointerenter="computeTitle" @click="onClick">{{ props.emoji }}</span>
<span v-else>{{ emoji }}</span>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, inject } from 'vue';
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js';
import { defaultStore } from '@/store.js';
import { getEmojiName } from '@/scripts/emojilist.js';
import * as os from '@/os.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{
emoji: string;
menu?: boolean;
menuReaction?: boolean;
}>();
const react = inject<((name: string) => void) | null>('react', null);
const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native');
@ -31,6 +38,28 @@ function computeTitle(event: PointerEvent): void {
const title = getEmojiName(props.emoji as string) ?? props.emoji as string;
(event.target as HTMLElement).title = title;
}
function onClick(ev: MouseEvent) {
if (props.menu) {
os.popupMenu([{
type: 'label',
text: props.emoji,
}, {
text: i18n.ts.copy,
icon: 'ti ti-copy',
action: () => {
copyToClipboard(props.emoji);
os.success();
},
}, ...(props.menuReaction && react ? [{
text: i18n.ts.doReaction,
icon: 'ti ti-plus',
action: () => {
react(props.emoji);
},
}] : [])], ev.currentTarget ?? ev.target);
}
}
</script>
<style lang="scss" module>

View file

@ -33,12 +33,13 @@ type MfmProps = {
plain?: boolean;
nowrap?: boolean;
author?: Misskey.entities.UserLite;
i?: Misskey.entities.UserLite | null;
isNote?: boolean;
emojiUrls?: string[];
rootScale?: number;
nyaize: boolean | 'account';
parsedNodes?: mfm.MfmNode[] | null;
enableEmojiMenu?: boolean;
enableEmojiMenuReaction?: boolean;
};
// eslint-disable-next-line import/no-default-export
@ -328,6 +329,8 @@ export default function(props: MfmProps) {
normal: props.plain,
host: null,
useOriginalSize: scale >= 2.5,
menu: props.enableEmojiMenu,
menuReaction: props.enableEmojiMenuReaction,
})];
} else {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@ -351,6 +354,8 @@ export default function(props: MfmProps) {
return [h(MkEmoji, {
key: Math.random(),
emoji: token.props.emoji,
menu: props.enableEmojiMenu,
menuReaction: props.enableEmojiMenuReaction,
})];
}

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_gaps">
<Mfm :text="block.text" :isNote="false" :i="$i"/>
<Mfm :text="block.text" :isNote="false"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url"/>
</div>
</template>