Merge remote-tracking branch 'misskey-original/develop' into develop
# Conflicts: # package.json # packages/frontend/src/components/MkPostForm.vue # packages/frontend/src/navbar.ts
This commit is contained in:
commit
1802c5da5f
59 changed files with 1606 additions and 951 deletions
|
|
@ -202,16 +202,24 @@ export async function common(createVue: () => App<Element>) {
|
|||
}
|
||||
}, { immediate: true });
|
||||
|
||||
if (defaultStore.state.keepScreenOn) {
|
||||
if ('wakeLock' in navigator) {
|
||||
// Keep screen on
|
||||
const onVisibilityChange = () => document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
navigator.wakeLock.request('screen');
|
||||
|
||||
document.addEventListener('visibilitychange', async () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
navigator.wakeLock.request('screen');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if (defaultStore.state.keepScreenOn && 'wakeLock' in navigator) {
|
||||
navigator.wakeLock.request('screen')
|
||||
.then(onVisibilityChange)
|
||||
.catch(() => {
|
||||
// On WebKit-based browsers, user activation is required to send wake lock request
|
||||
// https://webkit.org/blog/13862/the-user-activation-api/
|
||||
document.addEventListener(
|
||||
'click',
|
||||
() => navigator.wakeLock.request('screen').then(onVisibilityChange),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
//#region Fetch user
|
||||
|
|
|
|||
|
|
@ -16,7 +16,22 @@ import MkButton from '@/components/MkButton.vue';
|
|||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
note: Misskey.entities.Note;
|
||||
text: string | null;
|
||||
files: Misskey.entities.DriveFile[];
|
||||
poll?: {
|
||||
expiresAt: string | null;
|
||||
multiple: boolean;
|
||||
choices: {
|
||||
isVoted: boolean;
|
||||
text: string;
|
||||
votes: number;
|
||||
}[];
|
||||
} | {
|
||||
choices: string[];
|
||||
multiple: boolean;
|
||||
expiresAt: string | null;
|
||||
expiredAfter: string | null;
|
||||
};
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -25,9 +40,9 @@ const emit = defineEmits<{
|
|||
|
||||
const label = computed(() => {
|
||||
return concat([
|
||||
props.note.text ? [i18n.t('_cw.chars', { count: props.note.text.length })] : [],
|
||||
props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length })] : [],
|
||||
props.note.poll != null ? [i18n.ts.poll] : [],
|
||||
props.text ? [i18n.t('_cw.chars', { count: props.text.length })] : [],
|
||||
props.files.length !== 0 ? [i18n.t('_cw.files', { count: props.files.length })] : [],
|
||||
props.poll != null ? [i18n.ts.poll] : [],
|
||||
] as string[][]).join(' / ');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<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="'respect'"/>
|
||||
<MkCwButton v-model="showContent" :note="appearNote" style="margin: 4px 0;"/>
|
||||
<MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;"/>
|
||||
</p>
|
||||
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
|
||||
<div :class="$style.text">
|
||||
|
|
@ -150,6 +150,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!--
|
||||
MkDateSeparatedList uses TransitionGroup which requires single element in the child elements
|
||||
so MkNote create empty div instead of no elements
|
||||
-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<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="'respect'"/>
|
||||
<MkCwButton v-model="showContent" :note="appearNote"/>
|
||||
<MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll"/>
|
||||
</p>
|
||||
<div v-show="appearNote.cw == null || showContent">
|
||||
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkUserName :user="user" :nowrap="true"/>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<p v-if="useCw" :class="$style.cw">
|
||||
<Mfm v-if="cw != ''" :text="cw" :author="user" :nyaize="'respect'" :i="user" style="margin-right: 8px;"/>
|
||||
<MkCwButton v-model="showContent" :text="text.trim()" :files="files" :poll="poll" style="margin: 4px 0;"/>
|
||||
</p>
|
||||
<div v-show="!useCw || showContent">
|
||||
<Mfm :text="text.trim()" :author="user" :nyaize="'respect'" :i="user"/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -20,11 +24,23 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkCwButton from '@/components/MkCwButton.vue';
|
||||
|
||||
const showContent = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
text: string;
|
||||
files: Misskey.entities.DriveFile[];
|
||||
poll?: {
|
||||
choices: string[];
|
||||
multiple: boolean;
|
||||
expiresAt: string | null;
|
||||
expiredAfter: string | null;
|
||||
};
|
||||
useCw: boolean;
|
||||
cw: string | null;
|
||||
user: Misskey.entities.User;
|
||||
}>();
|
||||
</script>
|
||||
|
|
@ -53,6 +69,14 @@ const props = defineProps<{
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.cw {
|
||||
cursor: default;
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 2px;
|
||||
font-weight: bold;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<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="'respect'" :emojiUrls="note.emojis"/>
|
||||
<MkCwButton v-model="showContent" :note="note"/>
|
||||
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
|
||||
</p>
|
||||
<div v-show="note.cw == null || showContent">
|
||||
<MkSubNoteContent :class="$style.text" :note="note"/>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<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="'respect'"/>
|
||||
<MkCwButton v-model="showContent" :note="note"/>
|
||||
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
|
||||
</p>
|
||||
<div v-show="note.cw == null || showContent">
|
||||
<MkSubNoteContent :class="$style.text" :note="note"/>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, onDeactivated, onMounted, computed, shallowRef, onActivated } from 'vue';
|
||||
import { onUnmounted, onDeactivated, onMounted, computed, shallowRef, onActivated, watch } from 'vue';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
import XNotification from '@/components/MkNotification.vue';
|
||||
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
||||
|
|
@ -43,7 +43,7 @@ const props = defineProps<{
|
|||
|
||||
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
||||
|
||||
const pagination: Paging = defaultStore.state.useGroupedNotifications ? {
|
||||
let pagination = $computed(() => defaultStore.reactiveState.useGroupedNotifications.value ? {
|
||||
endpoint: 'i/notifications-grouped' as const,
|
||||
limit: 20,
|
||||
params: computed(() => ({
|
||||
|
|
@ -55,7 +55,7 @@ const pagination: Paging = defaultStore.state.useGroupedNotifications ? {
|
|||
params: computed(() => ({
|
||||
excludeTypes: props.excludeTypes ?? undefined,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
function onNotification(notification) {
|
||||
const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ watch([$$(backed), $$(contentEl)], () => {
|
|||
});
|
||||
|
||||
// パラメータに何らかの変更があった際、再読込したい(チャンネル等のIDが変わったなど)
|
||||
watch(() => props.pagination.params, init, { deep: true });
|
||||
watch(() => [props.pagination.endpoint, props.pagination.params], init, { deep: true });
|
||||
|
||||
watch(queue, (a, b) => {
|
||||
if (a.size === 0 && b.size === 0) return;
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
||||
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
|
||||
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
|
||||
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :user="postAccount ?? $i"/>
|
||||
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/>
|
||||
<div v-if="showingOptions" style="padding: 8px 16px;">
|
||||
</div>
|
||||
<footer :class="$style.footer">
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ export default function(props: MfmProps) {
|
|||
|
||||
case 'fn': {
|
||||
// TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
|
||||
let style;
|
||||
let style: string | undefined;
|
||||
switch (token.props.name) {
|
||||
case 'tada': {
|
||||
const speed = validTime(token.props.args.speed) ?? '1s';
|
||||
|
|
@ -341,7 +341,7 @@ export default function(props: MfmProps) {
|
|||
]);
|
||||
}
|
||||
}
|
||||
if (style == null) {
|
||||
if (style === undefined) {
|
||||
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']);
|
||||
} else {
|
||||
return h('span', {
|
||||
|
|
|
|||
|
|
@ -48,16 +48,12 @@ import { scrollToTop } from '@/scripts/scroll.js';
|
|||
import { globalEvents } from '@/events.js';
|
||||
import { injectPageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
|
||||
import { PageHeaderItem } from '@/types/page-header.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tabs?: Tab[];
|
||||
tab?: string;
|
||||
actions?: {
|
||||
text: string;
|
||||
icon: string;
|
||||
highlighted?: boolean;
|
||||
handler: (ev: MouseEvent) => void;
|
||||
}[];
|
||||
actions?: PageHeaderItem[];
|
||||
thin?: boolean;
|
||||
displayMyAvatar?: boolean;
|
||||
}>(), {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import * as os from '@/os.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { ui } from '@/config.js';
|
||||
import { unisonReload } from '@/scripts/unison-reload.js';
|
||||
import {fetchCustomEmojis} from "@/custom-emojis.js";
|
||||
import { clearCache } from './scripts/clear-cache.js';
|
||||
|
||||
export const navbarItemDef = reactive({
|
||||
notifications: {
|
||||
|
|
@ -172,17 +172,11 @@ export const navbarItemDef = reactive({
|
|||
show: computed(() => $i != null),
|
||||
to: `/@${$i?.username}`,
|
||||
},
|
||||
cacheclear: {
|
||||
cacheClear: {
|
||||
title: i18n.ts.cacheClear,
|
||||
icon: 'ti ti-trash',
|
||||
title: i18n.ts.clearCache,
|
||||
action: async () => {
|
||||
os.waiting();
|
||||
miLocalStorage.removeItem('locale');
|
||||
miLocalStorage.removeItem('theme');
|
||||
miLocalStorage.removeItem('emojis');
|
||||
miLocalStorage.removeItem('lastEmojisFetchedAt');
|
||||
await fetchCustomEmojis(true);
|
||||
unisonReload();
|
||||
}
|
||||
action: (ev) => {
|
||||
clearCache();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts.sensitiveWords }}</template>
|
||||
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<MkTextarea v-model="hiddenTags">
|
||||
<template #label>{{ i18n.ts.hiddenTags }}</template>
|
||||
<template #caption>{{ i18n.ts.hiddenTagsDescription }}</template>
|
||||
</MkTextarea>
|
||||
</div>
|
||||
</FormSuspense>
|
||||
</MkSpacer>
|
||||
|
|
@ -72,6 +77,7 @@ import FormLink from '@/components/form/link.vue';
|
|||
let enableRegistration: boolean = $ref(false);
|
||||
let emailRequiredForSignup: boolean = $ref(false);
|
||||
let sensitiveWords: string = $ref('');
|
||||
let hiddenTags: string = $ref('');
|
||||
let preservedUsernames: string = $ref('');
|
||||
let tosUrl: string | null = $ref(null);
|
||||
let privacyPolicyUrl: string | null = $ref(null);
|
||||
|
|
@ -81,6 +87,7 @@ async function init() {
|
|||
enableRegistration = !meta.disableRegistration;
|
||||
emailRequiredForSignup = meta.emailRequiredForSignup;
|
||||
sensitiveWords = meta.sensitiveWords.join('\n');
|
||||
hiddenTags = meta.hiddenTags.join('\n');
|
||||
preservedUsernames = meta.preservedUsernames.join('\n');
|
||||
tosUrl = meta.tosUrl;
|
||||
privacyPolicyUrl = meta.privacyPolicyUrl;
|
||||
|
|
@ -93,6 +100,7 @@ function save() {
|
|||
tosUrl,
|
||||
privacyPolicyUrl,
|
||||
sensitiveWords: sensitiveWords.split('\n'),
|
||||
hiddenTags: hiddenTags.split('\n'),
|
||||
preservedUsernames: preservedUsernames.split('\n'),
|
||||
}).then(() => {
|
||||
fetchInstance();
|
||||
|
|
|
|||
|
|
@ -86,6 +86,9 @@ import { defaultStore } from '@/store.js';
|
|||
import MkNote from '@/components/MkNote.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||
import { PageHeaderItem } from '@/types/page-header.js';
|
||||
import { isSupportShare } from '@/scripts/navigator.js';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -167,24 +170,40 @@ async function search() {
|
|||
|
||||
const headerActions = $computed(() => {
|
||||
if (channel && channel.userId) {
|
||||
const share = {
|
||||
icon: 'ti ti-share',
|
||||
text: i18n.ts.share,
|
||||
handler: async (): Promise<void> => {
|
||||
navigator.share({
|
||||
title: channel.name,
|
||||
text: channel.description,
|
||||
url: `${url}/channels/${channel.id}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
const headerItems: PageHeaderItem[] = [];
|
||||
|
||||
const canEdit = ($i && $i.id === channel.userId) || iAmModerator;
|
||||
return canEdit ? [share, {
|
||||
icon: 'ti ti-settings',
|
||||
text: i18n.ts.edit,
|
||||
handler: edit,
|
||||
}] : [share];
|
||||
headerItems.push({
|
||||
icon: 'ti ti-link',
|
||||
text: i18n.ts.copyUrl,
|
||||
handler: async (): Promise<void> => {
|
||||
copyToClipboard(`${url}/channels/${channel.id}`);
|
||||
os.success();
|
||||
},
|
||||
});
|
||||
|
||||
if (isSupportShare()) {
|
||||
headerItems.push({
|
||||
icon: 'ti ti-share',
|
||||
text: i18n.ts.share,
|
||||
handler: async (): Promise<void> => {
|
||||
navigator.share({
|
||||
title: channel.name,
|
||||
text: channel.description,
|
||||
url: `${url}/channels/${channel.id}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (($i && $i.id === channel.userId) || iAmModerator) {
|
||||
headerItems.push({
|
||||
icon: 'ti ti-settings',
|
||||
text: i18n.ts.edit,
|
||||
handler: edit,
|
||||
});
|
||||
}
|
||||
|
||||
return headerItems.length > 0 ? headerItems : null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
|
|||
import { url } from '@/config.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { clipsCache } from '@/cache';
|
||||
import { isSupportShare } from '@/scripts/navigator.js';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||
|
||||
const props = defineProps<{
|
||||
clipId: string,
|
||||
|
|
@ -118,6 +120,13 @@ const headerActions = $computed(() => clip && isOwned ? [{
|
|||
clipsCache.delete();
|
||||
},
|
||||
}, ...(clip.isPublic ? [{
|
||||
icon: 'ti ti-link',
|
||||
text: i18n.ts.copyUrl,
|
||||
handler: async (): Promise<void> => {
|
||||
copyToClipboard(`${url}/clips/${clip.id}`);
|
||||
os.success();
|
||||
},
|
||||
}] : []), ...(clip.isPublic && isSupportShare() ? [{
|
||||
icon: 'ti ti-share',
|
||||
text: i18n.ts.share,
|
||||
handler: async (): Promise<void> => {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ function menu(ev) {
|
|||
os.apiGet('emoji', { name: props.emoji.name }).then(res => {
|
||||
os.alert({
|
||||
type: 'info',
|
||||
text: `License: ${res.license}`,
|
||||
text: `Name: ${res.name}\nAliases: ${res.aliases.join(' ')}\nCategory: ${res.category}\nisSensitive: ${res.isSensitive}\nlocalOnly: ${res.localOnly}\nLicense: ${res.license}\nURL: ${res.url}`,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" asLike class="button" rounded primary @click="unlike()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
|
||||
<MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
|
||||
<MkButton v-tooltip="i18n.ts.shareWithNote" class="button" rounded @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></MkButton>
|
||||
<MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton>
|
||||
<MkButton v-tooltip="i18n.ts.copyLink" class="button" rounded @click="copyLink"><i class="ti ti-link ti-fw"></i></MkButton>
|
||||
<MkButton v-if="isSupportShare()" v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else :class="$style.ready">
|
||||
|
|
@ -70,6 +71,8 @@ import MkFolder from '@/components/MkFolder.vue';
|
|||
import MkCode from '@/components/MkCode.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { isSupportShare } from '@/scripts/navigator.js';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||
|
||||
const props = defineProps<{
|
||||
id: string;
|
||||
|
|
@ -89,6 +92,11 @@ function fetchFlash() {
|
|||
});
|
||||
}
|
||||
|
||||
function copyLink() {
|
||||
copyToClipboard(`${url}/play/${flash.id}`);
|
||||
os.success();
|
||||
}
|
||||
|
||||
function share() {
|
||||
navigator.share({
|
||||
title: flash.title,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div class="other">
|
||||
<button v-if="$i && $i.id === post.user.id" v-tooltip="i18n.ts.edit" v-click-anime class="_button" @click="edit"><i class="ti ti-pencil ti-fw"></i></button>
|
||||
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
|
||||
<button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
|
||||
<button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button>
|
||||
<button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user">
|
||||
|
|
@ -74,6 +75,8 @@ import { i18n } from '@/i18n.js';
|
|||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { isSupportShare } from '@/scripts/navigator.js';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -102,6 +105,11 @@ function fetchPost() {
|
|||
});
|
||||
}
|
||||
|
||||
function copyLink() {
|
||||
copyToClipboard(`${url}/gallery/${post.id}`);
|
||||
os.success();
|
||||
}
|
||||
|
||||
function share() {
|
||||
navigator.share({
|
||||
title: post.title,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<div class="other">
|
||||
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
|
||||
<button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
|
||||
<button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button>
|
||||
<button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user">
|
||||
|
|
@ -90,6 +91,8 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
|
|||
import { pageViewInterruptors, defaultStore } from '@/store.js';
|
||||
import { deepClone } from '@/scripts/clone.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { isSupportShare } from '@/scripts/navigator.js';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||
|
||||
const props = defineProps<{
|
||||
pageName: string;
|
||||
|
|
@ -136,6 +139,11 @@ function share() {
|
|||
});
|
||||
}
|
||||
|
||||
function copyLink() {
|
||||
copyToClipboard(`${url}/@${page.user.username}/pages/${page.name}`);
|
||||
os.success();
|
||||
}
|
||||
|
||||
function shareWithNote() {
|
||||
os.post({
|
||||
initialText: `${page.title || page.name} ${url}/@${page.user.username}/pages/${page.name}`,
|
||||
|
|
|
|||
|
|
@ -32,13 +32,11 @@ import { i18n } from '@/i18n.js';
|
|||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkSuperMenu from '@/components/MkSuperMenu.vue';
|
||||
import { signout, $i } from '@/account.js';
|
||||
import { unisonReload } from '@/scripts/unison-reload.js';
|
||||
import { clearCache } from '@/scripts/clear-cache.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { useRouter } from '@/router.js';
|
||||
import { definePageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js';
|
||||
import * as os from '@/os.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { fetchCustomEmojis } from '@/custom-emojis.js';
|
||||
|
||||
const indexInfo = {
|
||||
title: i18n.ts.settings,
|
||||
|
|
@ -181,13 +179,7 @@ const menuDef = computed(() => [{
|
|||
icon: 'ti ti-trash',
|
||||
text: i18n.ts.clearCache,
|
||||
action: async () => {
|
||||
os.waiting();
|
||||
miLocalStorage.removeItem('locale');
|
||||
miLocalStorage.removeItem('theme');
|
||||
miLocalStorage.removeItem('emojis');
|
||||
miLocalStorage.removeItem('lastEmojisFetchedAt');
|
||||
await fetchCustomEmojis(true);
|
||||
unisonReload();
|
||||
await clearCache();
|
||||
},
|
||||
}, {
|
||||
type: 'button',
|
||||
|
|
|
|||
|
|
@ -7,8 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div class="_gaps_m">
|
||||
<MkSelect v-model="type">
|
||||
<template #label>{{ i18n.ts.sound }}</template>
|
||||
<option v-for="x in soundsTypes" :key="x" :value="x">{{ x == null ? i18n.ts.none : x }}</option>
|
||||
<option v-for="x in soundsTypes" :key="x ?? 'null'" :value="x">{{ getSoundTypeName(x) }}</option>
|
||||
</MkSelect>
|
||||
<div v-if="type === '_driveFile_'" :class="$style.fileSelectorRoot">
|
||||
<MkButton :class="$style.fileSelectorButton" inline rounded primary @click="selectSound">{{ i18n.ts.selectFile }}</MkButton>
|
||||
<div :class="['_nowrap', !fileUrl && $style.fileNotSelected]">{{ friendlyFileName }}</div>
|
||||
</div>
|
||||
<MkRange v-model="volume" :min="0" :max="1" :step="0.05" :textConverter="(v) => `${Math.floor(v * 100)}%`">
|
||||
<template #label>{{ i18n.ts.volume }}</template>
|
||||
</MkRange>
|
||||
|
|
@ -21,30 +25,149 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import type { SoundType } from '@/scripts/sound.js';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkRange from '@/components/MkRange.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { playFile, soundsTypes } from '@/scripts/sound.js';
|
||||
import * as os from '@/os.js';
|
||||
import { playFile, soundsTypes, getSoundDuration } from '@/scripts/sound.js';
|
||||
import { selectFile } from '@/scripts/select-file.js';
|
||||
|
||||
const props = defineProps<{
|
||||
type: string;
|
||||
type: SoundType;
|
||||
fileId?: string;
|
||||
fileUrl?: string;
|
||||
volume: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update', result: { type: string; volume: number; }): void;
|
||||
(ev: 'update', result: { type: SoundType; fileId?: string; fileUrl?: string; volume: number; }): void;
|
||||
}>();
|
||||
|
||||
let type = $ref(props.type);
|
||||
let volume = $ref(props.volume);
|
||||
const type = ref<SoundType>(props.type);
|
||||
const fileId = ref(props.fileId);
|
||||
const fileUrl = ref(props.fileUrl);
|
||||
const fileName = ref<string>('');
|
||||
const volume = ref(props.volume);
|
||||
|
||||
if (type.value === '_driveFile_' && fileId.value) {
|
||||
const apiRes = await os.api('drive/files/show', {
|
||||
fileId: fileId.value,
|
||||
});
|
||||
fileName.value = apiRes.name;
|
||||
}
|
||||
|
||||
function getSoundTypeName(f: SoundType): string {
|
||||
switch (f) {
|
||||
case null:
|
||||
return i18n.ts.none;
|
||||
case '_driveFile_':
|
||||
return i18n.ts._soundSettings.driveFile;
|
||||
default:
|
||||
return f;
|
||||
}
|
||||
}
|
||||
|
||||
const friendlyFileName = computed<string>(() => {
|
||||
if (fileName.value) {
|
||||
return fileName.value;
|
||||
}
|
||||
if (fileUrl.value) {
|
||||
return fileUrl.value;
|
||||
}
|
||||
|
||||
return i18n.ts._soundSettings.driveFileWarn;
|
||||
});
|
||||
|
||||
function selectSound(ev) {
|
||||
selectFile(ev.currentTarget ?? ev.target, i18n.ts._soundSettings.driveFile).then(async (file) => {
|
||||
if (!file.type.startsWith('audio')) {
|
||||
os.alert({
|
||||
type: 'warning',
|
||||
title: i18n.ts._soundSettings.driveFileTypeWarn,
|
||||
text: i18n.ts._soundSettings.driveFileTypeWarnDescription,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const duration = await getSoundDuration(file.url);
|
||||
if (duration >= 2000) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
title: i18n.ts._soundSettings.driveFileDurationWarn,
|
||||
text: i18n.ts._soundSettings.driveFileDurationWarnDescription,
|
||||
okText: i18n.ts.continue,
|
||||
cancelText: i18n.ts.cancel,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
fileUrl.value = file.url;
|
||||
fileName.value = file.name;
|
||||
fileId.value = file.id;
|
||||
});
|
||||
}
|
||||
|
||||
function listen() {
|
||||
playFile(type, volume);
|
||||
if (type.value === '_driveFile_' && (!fileUrl.value || !fileId.value)) {
|
||||
os.alert({
|
||||
type: 'warning',
|
||||
text: i18n.ts._soundSettings.driveFileWarn,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
playFile(type.value === '_driveFile_' ? {
|
||||
type: '_driveFile_',
|
||||
fileId: fileId.value as string,
|
||||
fileUrl: fileUrl.value as string,
|
||||
volume: volume.value,
|
||||
} : {
|
||||
type: type.value,
|
||||
volume: volume.value,
|
||||
});
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit('update', { type, volume });
|
||||
if (type.value === '_driveFile_' && !fileUrl.value) {
|
||||
os.alert({
|
||||
type: 'warning',
|
||||
text: i18n.ts._soundSettings.driveFileWarn,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type.value !== '_driveFile_') {
|
||||
fileUrl.value = undefined;
|
||||
fileName.value = '';
|
||||
fileId.value = undefined;
|
||||
}
|
||||
|
||||
emit('update', {
|
||||
type: type.value,
|
||||
fileId: fileId.value,
|
||||
fileUrl: fileUrl.value,
|
||||
volume: volume.value,
|
||||
});
|
||||
|
||||
os.success();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.fileSelectorRoot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fileSelectorButton {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fileNotSelected {
|
||||
font-weight: 700;
|
||||
color: var(--infoWarnFg);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<FormSection>
|
||||
<template #label>{{ i18n.ts.sounds }}</template>
|
||||
<div class="_gaps_s">
|
||||
<MkFolder v-for="type in soundsKeys" :key="type">
|
||||
<MkFolder v-for="type in operationTypes" :key="type">
|
||||
<template #label>{{ i18n.t('_sfx.' + type) }}</template>
|
||||
<template #suffix>{{ sounds[type].type ?? i18n.ts.none }}</template>
|
||||
<template #suffix>{{ getSoundTypeName(sounds[type].type) }}</template>
|
||||
|
||||
<XSound :type="sounds[type].type" :volume="sounds[type].volume" @update="(res) => updated(type, res)"/>
|
||||
<XSound :type="sounds[type].type" :volume="sounds[type].volume" :fileId="sounds[type].fileId" :fileUrl="sounds[type].fileUrl" @update="(res) => updated(type, res)"/>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
|
@ -33,6 +33,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { Ref, computed, ref } from 'vue';
|
||||
import type { SoundType, OperationType } from '@/scripts/sound.js';
|
||||
import type { SoundStore } from '@/store.js';
|
||||
import XSound from './sounds.sound.vue';
|
||||
import MkRange from '@/components/MkRange.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
|
@ -40,6 +42,7 @@ import FormSection from '@/components/form/section.vue';
|
|||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { operationTypes } from '@/scripts/sound.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
|
||||
|
|
@ -47,9 +50,7 @@ const notUseSound = computed(defaultStore.makeGetterSetter('sound_notUseSound'))
|
|||
const useSoundOnlyWhenActive = computed(defaultStore.makeGetterSetter('sound_useSoundOnlyWhenActive'));
|
||||
const masterVolume = computed(defaultStore.makeGetterSetter('sound_masterVolume'));
|
||||
|
||||
const soundsKeys = ['note', 'noteMy', 'notification', 'antenna', 'channel', 'reaction'] as const;
|
||||
|
||||
const sounds = ref<Record<typeof soundsKeys[number], Ref<any>>>({
|
||||
const sounds = ref<Record<OperationType, Ref<SoundStore>>>({
|
||||
note: defaultStore.reactiveState.sound_note,
|
||||
noteMy: defaultStore.reactiveState.sound_noteMy,
|
||||
notification: defaultStore.reactiveState.sound_notification,
|
||||
|
|
@ -58,9 +59,22 @@ const sounds = ref<Record<typeof soundsKeys[number], Ref<any>>>({
|
|||
reaction: defaultStore.reactiveState.sound_reaction,
|
||||
});
|
||||
|
||||
function getSoundTypeName(f: SoundType): string {
|
||||
switch (f) {
|
||||
case null:
|
||||
return i18n.ts.none;
|
||||
case '_driveFile_':
|
||||
return i18n.ts._soundSettings.driveFile;
|
||||
default:
|
||||
return f;
|
||||
}
|
||||
}
|
||||
|
||||
async function updated(type: keyof typeof sounds.value, sound) {
|
||||
const v = {
|
||||
const v: SoundStore = {
|
||||
type: sound.type,
|
||||
fileId: sound.fileId,
|
||||
fileUrl: sound.fileUrl,
|
||||
volume: sound.volume,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:renote="renote"
|
||||
:initialVisibleUsers="visibleUsers"
|
||||
class="_panel"
|
||||
@posted="state = 'posted'"
|
||||
@posted="onPosted"
|
||||
/>
|
||||
<div v-else-if="state === 'posted'" class="_buttonsCenter">
|
||||
<MkButton primary @click="close">{{ i18n.ts.close }}</MkButton>
|
||||
|
|
@ -32,20 +32,20 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
// SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html
|
||||
|
||||
import { } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkPostForm from '@/components/MkPostForm.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { mainRouter } from '@/router.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { postMessageToParentWindow } from '@/scripts/post-message.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const localOnlyQuery = urlParams.get('localOnly');
|
||||
const visibilityQuery = urlParams.get('visibility') as typeof Misskey.noteVisibilities[number];
|
||||
|
||||
let state = $ref('fetching' as 'fetching' | 'writing' | 'posted');
|
||||
const state = ref<'fetching' | 'writing' | 'posted'>('fetching');
|
||||
let title = $ref(urlParams.get('title'));
|
||||
const text = urlParams.get('text');
|
||||
const url = urlParams.get('url');
|
||||
|
|
@ -144,7 +144,7 @@ async function init() {
|
|||
});
|
||||
}
|
||||
|
||||
state = 'writing';
|
||||
state.value = 'writing';
|
||||
}
|
||||
|
||||
init();
|
||||
|
|
@ -162,6 +162,11 @@ function goToMisskey(): void {
|
|||
location.href = '/';
|
||||
}
|
||||
|
||||
function onPosted(): void {
|
||||
state.value = 'posted';
|
||||
postMessageToParentWindow('misskey:shareForm:shareCompleted');
|
||||
}
|
||||
|
||||
const headerActions = $computed(() => []);
|
||||
|
||||
const headerTabs = $computed(() => []);
|
||||
|
|
|
|||
14
packages/frontend/src/scripts/clear-cache.ts
Normal file
14
packages/frontend/src/scripts/clear-cache.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { unisonReload } from '@/scripts/unison-reload.js';
|
||||
import * as os from '@/os.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { fetchCustomEmojis } from '@/custom-emojis.js';
|
||||
|
||||
export async function clearCache() {
|
||||
os.waiting();
|
||||
miLocalStorage.removeItem('locale');
|
||||
miLocalStorage.removeItem('theme');
|
||||
miLocalStorage.removeItem('emojis');
|
||||
miLocalStorage.removeItem('lastEmojisFetchedAt');
|
||||
await fetchCustomEmojis(true);
|
||||
unisonReload();
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ 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';
|
||||
import { isSupportShare } from '@/scripts/navigator.js';
|
||||
|
||||
export async function getNoteClipMenu(props: {
|
||||
note: Misskey.entities.Note;
|
||||
|
|
@ -284,11 +285,11 @@ export function getNoteMenu(props: {
|
|||
window.open(appearNote.url ?? appearNote.uri, '_blank');
|
||||
},
|
||||
} : undefined,
|
||||
{
|
||||
...(isSupportShare() ? [{
|
||||
icon: 'ti ti-share',
|
||||
text: i18n.ts.share,
|
||||
action: share,
|
||||
},
|
||||
}] : []),
|
||||
$i && $i.policies.canUseTranslator && instance.translatorAvailable ? {
|
||||
icon: 'ti ti-language-hiragana',
|
||||
text: i18n.ts.translate,
|
||||
|
|
@ -493,7 +494,7 @@ export function getRenoteMenu(props: {
|
|||
}]);
|
||||
}
|
||||
|
||||
if (!appearNote.channel || appearNote.channel?.allowRenoteToExternal) {
|
||||
if (!appearNote.channel || appearNote.channel.allowRenoteToExternal) {
|
||||
normalRenoteItems.push(...[{
|
||||
text: i18n.ts.renote,
|
||||
icon: 'ti ti-repeat',
|
||||
|
|
|
|||
8
packages/frontend/src/scripts/navigator.ts
Normal file
8
packages/frontend/src/scripts/navigator.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export function isSupportShare(): boolean {
|
||||
return 'share' in navigator;
|
||||
}
|
||||
25
packages/frontend/src/scripts/post-message.ts
Normal file
25
packages/frontend/src/scripts/post-message.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export const postMessageEventTypes = [
|
||||
'misskey:shareForm:shareCompleted',
|
||||
] as const;
|
||||
|
||||
export type PostMessageEventType = typeof postMessageEventTypes[number];
|
||||
|
||||
export type MiPostMessageEvent = {
|
||||
type: PostMessageEventType;
|
||||
payload?: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* 親フレームにイベントを送信
|
||||
*/
|
||||
export function postMessageToParentWindow(type: PostMessageEventType, payload?: any): void {
|
||||
window.postMessage({
|
||||
type,
|
||||
payload,
|
||||
}, '*');
|
||||
}
|
||||
|
|
@ -3,14 +3,22 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { SoundStore } from '@/store.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
let ctx: AudioContext;
|
||||
const cache = new Map<string, AudioBuffer>();
|
||||
let canPlay = true;
|
||||
|
||||
export const soundsTypes = [
|
||||
// 音声なし
|
||||
null,
|
||||
|
||||
// ドライブの音声
|
||||
'_driveFile_',
|
||||
|
||||
// プリインストール
|
||||
'syuilo/n-aec',
|
||||
'syuilo/n-aec-4va',
|
||||
'syuilo/n-aec-4vb',
|
||||
|
|
@ -64,32 +72,96 @@ export const soundsTypes = [
|
|||
'noizenecio/kick_gaba7',
|
||||
] as const;
|
||||
|
||||
export async function loadAudio(file: string, useCache = true) {
|
||||
export const operationTypes = [
|
||||
'noteMy',
|
||||
'note',
|
||||
'antenna',
|
||||
'channel',
|
||||
'notification',
|
||||
'reaction',
|
||||
] as const;
|
||||
|
||||
/** サウンドの種類 */
|
||||
export type SoundType = typeof soundsTypes[number];
|
||||
|
||||
/** スプライトの種類 */
|
||||
export type OperationType = typeof operationTypes[number];
|
||||
|
||||
/**
|
||||
* 音声を読み込む
|
||||
* @param soundStore サウンド設定
|
||||
* @param options `useCache`: デフォルトは`true` 一度再生した音声はキャッシュする
|
||||
*/
|
||||
export async function loadAudio(soundStore: SoundStore, options?: { useCache?: boolean; }) {
|
||||
if (_DEV_) console.log('loading audio. opts:', options);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (ctx == null) {
|
||||
ctx = new AudioContext();
|
||||
}
|
||||
if (useCache && cache.has(file)) {
|
||||
return cache.get(file)!;
|
||||
if (options?.useCache ?? true) {
|
||||
if (soundStore.type === '_driveFile_' && cache.has(soundStore.fileId)) {
|
||||
if (_DEV_) console.log('use cache');
|
||||
return cache.get(soundStore.fileId) as AudioBuffer;
|
||||
} else if (cache.has(soundStore.type)) {
|
||||
if (_DEV_) console.log('use cache');
|
||||
return cache.get(soundStore.type) as AudioBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
if (soundStore.type === '_driveFile_') {
|
||||
try {
|
||||
response = await fetch(soundStore.fileUrl);
|
||||
} catch (err) {
|
||||
try {
|
||||
// URLが変わっている可能性があるのでドライブ側からURLを取得するフォールバック
|
||||
const apiRes = await os.api('drive/files/show', {
|
||||
fileId: soundStore.fileId,
|
||||
});
|
||||
response = await fetch(apiRes.url);
|
||||
} catch (fbErr) {
|
||||
// それでも無理なら諦める
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
response = await fetch(`/client-assets/sounds/${soundStore.type}.mp3`);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`/client-assets/sounds/${file}.mp3`);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
|
||||
|
||||
if (useCache) {
|
||||
cache.set(file, audioBuffer);
|
||||
if (options?.useCache ?? true) {
|
||||
if (soundStore.type === '_driveFile_') {
|
||||
cache.set(soundStore.fileId, audioBuffer);
|
||||
} else {
|
||||
cache.set(soundStore.type, audioBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
return audioBuffer;
|
||||
}
|
||||
|
||||
export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notification' | 'reaction') {
|
||||
const sound = defaultStore.state[`sound_${type}`];
|
||||
if (_DEV_) console.log('play', type, sound);
|
||||
/**
|
||||
* 既定のスプライトを再生する
|
||||
* @param type スプライトの種類を指定
|
||||
*/
|
||||
export function play(operationType: OperationType) {
|
||||
const sound = defaultStore.state[`sound_${operationType}`];
|
||||
if (_DEV_) console.log('play', operationType, sound);
|
||||
if (sound.type == null || !canPlay) return;
|
||||
|
||||
canPlay = false;
|
||||
playFile(sound.type, sound.volume).then(() => {
|
||||
playFile(sound).finally(() => {
|
||||
// ごく短時間に音が重複しないように
|
||||
setTimeout(() => {
|
||||
canPlay = true;
|
||||
|
|
@ -97,9 +169,14 @@ export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notifica
|
|||
});
|
||||
}
|
||||
|
||||
export async function playFile(file: string, volume: number) {
|
||||
const buffer = await loadAudio(file);
|
||||
createSourceNode(buffer, volume)?.start();
|
||||
/**
|
||||
* サウンド設定形式で指定された音声を再生する
|
||||
* @param soundStore サウンド設定
|
||||
*/
|
||||
export async function playFile(soundStore: SoundStore) {
|
||||
const buffer = await loadAudio(soundStore);
|
||||
if (!buffer) return;
|
||||
createSourceNode(buffer, soundStore.volume)?.start();
|
||||
}
|
||||
|
||||
export function createSourceNode(buffer: AudioBuffer, volume: number) : AudioBufferSourceNode | null {
|
||||
|
|
@ -118,6 +195,27 @@ export function createSourceNode(buffer: AudioBuffer, volume: number) : AudioBuf
|
|||
return soundSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* 音声の長さをミリ秒で取得する
|
||||
* @param file ファイルのURL(ドライブIDではない)
|
||||
*/
|
||||
export async function getSoundDuration(file: string): Promise<number> {
|
||||
const audioEl = document.createElement('audio');
|
||||
audioEl.src = file;
|
||||
return new Promise((resolve) => {
|
||||
const si = setInterval(() => {
|
||||
if (audioEl.readyState > 0) {
|
||||
resolve(audioEl.duration * 1000);
|
||||
clearInterval(si);
|
||||
audioEl.remove();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ミュートすべきかどうかを判断する
|
||||
*/
|
||||
export function isMute(): boolean {
|
||||
if (defaultStore.state.sound_notUseSound) {
|
||||
// サウンドを出力しない
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { markRaw, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { miLocalStorage } from './local-storage.js';
|
||||
import type { SoundType } from '@/scripts/sound.js';
|
||||
import { Storage } from '@/pizzax.js';
|
||||
|
||||
interface PostFormAction {
|
||||
|
|
@ -35,6 +36,22 @@ interface PageViewInterruptor {
|
|||
handler: (page: Misskey.entities.Page) => unknown;
|
||||
}
|
||||
|
||||
/** サウンド設定 */
|
||||
export type SoundStore = {
|
||||
type: Exclude<SoundType, '_driveFile_'>;
|
||||
volume: number;
|
||||
} | {
|
||||
type: '_driveFile_';
|
||||
|
||||
/** ドライブのファイルID */
|
||||
fileId: string;
|
||||
|
||||
/** ファイルURL(こちらが優先される) */
|
||||
fileUrl: string;
|
||||
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export const postFormActions: PostFormAction[] = [];
|
||||
export const userActions: UserAction[] = [];
|
||||
export const noteActions: NoteAction[] = [];
|
||||
|
|
@ -500,27 +517,27 @@ export const defaultStore = markRaw(new Storage('base', {
|
|||
},
|
||||
sound_note: {
|
||||
where: 'device',
|
||||
default: { type: 'syuilo/n-aec', volume: 1 },
|
||||
default: { type: 'syuilo/n-aec', volume: 1 } as SoundStore,
|
||||
},
|
||||
sound_noteMy: {
|
||||
where: 'device',
|
||||
default: { type: 'syuilo/n-cea-4va', volume: 1 },
|
||||
default: { type: 'syuilo/n-cea-4va', volume: 1 } as SoundStore,
|
||||
},
|
||||
sound_notification: {
|
||||
where: 'device',
|
||||
default: { type: 'syuilo/n-ea', volume: 1 },
|
||||
default: { type: 'syuilo/n-ea', volume: 1 } as SoundStore,
|
||||
},
|
||||
sound_antenna: {
|
||||
where: 'device',
|
||||
default: { type: 'syuilo/triple', volume: 1 },
|
||||
default: { type: 'syuilo/triple', volume: 1 } as SoundStore,
|
||||
},
|
||||
sound_channel: {
|
||||
where: 'device',
|
||||
default: { type: 'syuilo/square-pico', volume: 1 },
|
||||
default: { type: 'syuilo/square-pico', volume: 1 } as SoundStore,
|
||||
},
|
||||
sound_reaction: {
|
||||
where: 'device',
|
||||
default: { type: 'syuilo/bubble2', volume: 1 },
|
||||
default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore,
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
11
packages/frontend/src/types/page-header.ts
Normal file
11
packages/frontend/src/types/page-header.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export type PageHeaderItem = {
|
||||
text: string;
|
||||
icon: string;
|
||||
highlighted?: boolean;
|
||||
handler: (ev: MouseEvent) => void;
|
||||
};
|
||||
127
packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
Normal file
127
packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkContainer :showHeader="widgetProps.showHeader" class="mkw-bdayfollowings">
|
||||
<template #icon><i class="ti ti-cake"></i></template>
|
||||
<template #header>{{ i18n.ts._widgets.birthdayFollowings }}</template>
|
||||
|
||||
<div :class="$style.bdayFRoot">
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-else-if="users.length > 0" :class="$style.bdayFGrid">
|
||||
<MkAvatar v-for="user in users" :key="user.id" :user="user.followee" link preview></MkAvatar>
|
||||
</div>
|
||||
<div v-else :class="$style.bdayFFallback">
|
||||
<img :src="infoImageUrl" class="_ghost" :class="$style.bdayFFallbackImage"/>
|
||||
<div>{{ i18n.ts.nothing }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
||||
import { GetFormResultType } from '@/scripts/form.js';
|
||||
import MkContainer from '@/components/MkContainer.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { useInterval } from '@/scripts/use-interval.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import { $i } from '@/account.js';
|
||||
|
||||
const name = i18n.ts._widgets.birthdayFollowings;
|
||||
|
||||
const widgetPropsDef = {
|
||||
showHeader: {
|
||||
type: 'boolean' as const,
|
||||
default: true,
|
||||
},
|
||||
};
|
||||
|
||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
|
||||
|
||||
const props = defineProps<WidgetComponentProps<WidgetProps>>();
|
||||
const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
|
||||
|
||||
const { widgetProps, configure } = useWidgetPropsManager(name,
|
||||
widgetPropsDef,
|
||||
props,
|
||||
emit,
|
||||
);
|
||||
|
||||
const users = ref<Misskey.entities.FollowingFolloweePopulated[]>([]);
|
||||
const fetching = ref(true);
|
||||
let lastFetchedAt = '1970-01-01';
|
||||
|
||||
const fetch = () => {
|
||||
if (!$i) {
|
||||
users.value = [];
|
||||
fetching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const lfAtD = new Date(lastFetchedAt);
|
||||
lfAtD.setHours(0, 0, 0, 0);
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
|
||||
if (now > lfAtD) {
|
||||
os.api('users/following', {
|
||||
limit: 18,
|
||||
birthday: now.toISOString(),
|
||||
userId: $i.id,
|
||||
}).then(res => {
|
||||
users.value = res;
|
||||
fetching.value = false;
|
||||
});
|
||||
|
||||
lastFetchedAt = now.toISOString();
|
||||
}
|
||||
};
|
||||
|
||||
useInterval(fetch, 1000 * 60, {
|
||||
immediate: true,
|
||||
afterMounted: true,
|
||||
});
|
||||
|
||||
defineExpose<WidgetComponentExpose>({
|
||||
name,
|
||||
configure,
|
||||
id: props.widget ? props.widget.id : null,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.bdayFRoot {
|
||||
overflow: hidden;
|
||||
min-height: calc(calc(calc(50px * 3) - 8px) + calc(var(--margin) * 2));
|
||||
}
|
||||
.bdayFGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 42px);
|
||||
grid-template-rows: repeat(3, 42px);
|
||||
place-content: center;
|
||||
gap: 8px;
|
||||
margin: var(--margin) auto;
|
||||
}
|
||||
|
||||
.bdayFFallback {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bdayFFallbackImage {
|
||||
height: 96px;
|
||||
width: auto;
|
||||
max-width: 90%;
|
||||
margin-bottom: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -104,7 +104,13 @@ let jammedAudioBuffer: AudioBuffer | null = $ref(null);
|
|||
let jammedSoundNodePlaying: boolean = $ref(false);
|
||||
|
||||
if (defaultStore.state.sound_masterVolume) {
|
||||
sound.loadAudio('syuilo/queue-jammed').then(buf => jammedAudioBuffer = buf);
|
||||
sound.loadAudio({
|
||||
type: 'syuilo/queue-jammed',
|
||||
volume: 1,
|
||||
}).then(buf => {
|
||||
if (!buf) throw new Error('[WidgetJobQueue] Failed to initialize AudioBuffer');
|
||||
jammedAudioBuffer = buf;
|
||||
});
|
||||
}
|
||||
|
||||
for (const domain of ['inbox', 'deliver']) {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export default function(app: App) {
|
|||
app.component('WidgetAichan', defineAsyncComponent(() => import('./WidgetAichan.vue')));
|
||||
app.component('WidgetUserList', defineAsyncComponent(() => import('./WidgetUserList.vue')));
|
||||
app.component('WidgetClicker', defineAsyncComponent(() => import('./WidgetClicker.vue')));
|
||||
app.component('WidgetBirthdayFollowings', defineAsyncComponent(() => import('./WidgetBirthdayFollowings.vue')));
|
||||
}
|
||||
|
||||
export const widgets = [
|
||||
|
|
@ -67,4 +68,5 @@ export const widgets = [
|
|||
'aichan',
|
||||
'userList',
|
||||
'clicker',
|
||||
'birthdayFollowings',
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue