Merge branch 'develop' into sw-notification-action
This commit is contained in:
@@ -756,7 +756,13 @@ export default defineComponent({
|
||||
};
|
||||
if (isLink(e.target)) return;
|
||||
if (window.getSelection().toString() !== '') return;
|
||||
os.contextMenu(this.getMenu(), e).then(this.focus);
|
||||
|
||||
if (this.$store.state.useReactionPickerForContextMenu) {
|
||||
e.preventDefault();
|
||||
this.react();
|
||||
} else {
|
||||
os.contextMenu(this.getMenu(), e).then(this.focus);
|
||||
}
|
||||
},
|
||||
|
||||
menu(viaKeyboard = false) {
|
||||
|
@@ -731,7 +731,13 @@ export default defineComponent({
|
||||
};
|
||||
if (isLink(e.target)) return;
|
||||
if (window.getSelection().toString() !== '') return;
|
||||
os.contextMenu(this.getMenu(), e).then(this.focus);
|
||||
|
||||
if (this.$store.state.useReactionPickerForContextMenu) {
|
||||
e.preventDefault();
|
||||
this.react();
|
||||
} else {
|
||||
os.contextMenu(this.getMenu(), e).then(this.focus);
|
||||
}
|
||||
},
|
||||
|
||||
menu(viaKeyboard = false) {
|
||||
|
@@ -8,10 +8,10 @@
|
||||
<MkError v-if="error" @retry="init()"/>
|
||||
|
||||
<div v-show="more && reversed" style="margin-bottom: var(--margin);">
|
||||
<button class="_buttonPrimary" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
|
||||
<MkButton style="margin: 0 auto;" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
|
||||
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
|
||||
<template v-if="moreFetching"><MkLoading inline/></template>
|
||||
</button>
|
||||
</MkButton>
|
||||
</div>
|
||||
|
||||
<XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed">
|
||||
@@ -19,10 +19,10 @@
|
||||
</XList>
|
||||
|
||||
<div v-show="more && !reversed" style="margin-top: var(--margin);">
|
||||
<button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
|
||||
<MkButton style="margin: 0 auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
|
||||
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
|
||||
<template v-if="moreFetching"><MkLoading inline/></template>
|
||||
</button>
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -32,10 +32,11 @@ import { defineComponent } from 'vue';
|
||||
import paging from '@/scripts/paging';
|
||||
import XNote from './note.vue';
|
||||
import XList from './date-separated-list.vue';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
XNote, XList,
|
||||
XNote, XList, MkButton,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
|
@@ -70,6 +70,7 @@ import * as os from '@/os';
|
||||
import { selectFile } from '@/scripts/select-file';
|
||||
import { notePostInterruptors, postFormActions } from '@/store';
|
||||
import { isMobile } from '@/scripts/is-mobile';
|
||||
import { throttle } from 'throttle-debounce';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -160,6 +161,11 @@ export default defineComponent({
|
||||
quoteId: null,
|
||||
recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
|
||||
imeText: '',
|
||||
typing: throttle(3000, () => {
|
||||
if (this.channel) {
|
||||
os.stream.send('typingOnChannel', { channel: this.channel.id });
|
||||
}
|
||||
}),
|
||||
postFormActions,
|
||||
faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug
|
||||
};
|
||||
@@ -462,10 +468,12 @@ export default defineComponent({
|
||||
onKeydown(e: KeyboardEvent) {
|
||||
if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
|
||||
if (e.which === 27) this.$emit('esc');
|
||||
this.typing();
|
||||
},
|
||||
|
||||
onCompositionUpdate(e: CompositionEvent) {
|
||||
this.imeText = e.data;
|
||||
this.typing();
|
||||
},
|
||||
|
||||
onCompositionEnd(e: CompositionEvent) {
|
||||
|
@@ -70,6 +70,7 @@ export default defineComponent({
|
||||
// TODO: ResizeObserver無くしたい
|
||||
new ResizeObserver((entries, observer) => {
|
||||
const rect = this.src.getBoundingClientRect();
|
||||
|
||||
const width = popover.offsetWidth;
|
||||
const height = popover.offsetHeight;
|
||||
|
||||
|
@@ -72,6 +72,9 @@ import { getAccountFromId } from '@/scripts/get-account-from-id';
|
||||
|
||||
console.info(`Misskey v${version}`);
|
||||
|
||||
// boot.jsのやつを解除
|
||||
window.onerror = null;
|
||||
|
||||
if (_DEV_) {
|
||||
console.warn('Development mode!!!');
|
||||
|
||||
|
@@ -7,6 +7,7 @@
|
||||
v-model="text"
|
||||
ref="text"
|
||||
@keypress="onKeypress"
|
||||
@compositionupdate="onCompositionUpdate"
|
||||
@paste="onPaste"
|
||||
:placeholder="$ts.inputMessageHere"
|
||||
></textarea>
|
||||
@@ -29,6 +30,7 @@ import { formatTimeString } from '../../../misc/format-time-string';
|
||||
import { selectFile } from '@/scripts/select-file';
|
||||
import * as os from '@/os';
|
||||
import { Autocomplete } from '@/scripts/autocomplete';
|
||||
import { throttle } from 'throttle-debounce';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -46,6 +48,9 @@ export default defineComponent({
|
||||
text: null,
|
||||
file: null,
|
||||
sending: false,
|
||||
typing: throttle(3000, () => {
|
||||
os.stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id });
|
||||
}),
|
||||
faPaperPlane, faPhotoVideo, faLaughSquint
|
||||
};
|
||||
},
|
||||
@@ -147,11 +152,16 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
onKeypress(e) {
|
||||
this.typing();
|
||||
if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canSend) {
|
||||
this.send();
|
||||
}
|
||||
},
|
||||
|
||||
onCompositionUpdate() {
|
||||
this.typing();
|
||||
},
|
||||
|
||||
chooseFile(e) {
|
||||
selectFile(e.currentTarget || e.target, this.$ts.selectFile, false).then(file => {
|
||||
this.file = file;
|
||||
|
@@ -16,6 +16,14 @@
|
||||
</XList>
|
||||
</div>
|
||||
<footer>
|
||||
<div class="typers" v-if="typers.length > 0">
|
||||
<I18n :src="$ts.typingUsers" text-tag="span" class="users">
|
||||
<template #users>
|
||||
<b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b>
|
||||
</template>
|
||||
</I18n>
|
||||
<MkEllipsis/>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div class="new-message" v-show="showIndicator">
|
||||
<button class="_buttonPrimary" @click="onIndicatorClick"><i><Fa :icon="faArrowCircleDown"/></i>{{ $ts.newMessageExists }}</button>
|
||||
@@ -86,6 +94,7 @@ const Component = defineComponent({
|
||||
connection: null,
|
||||
showIndicator: false,
|
||||
timer: null,
|
||||
typers: [],
|
||||
ilObserver: new IntersectionObserver(
|
||||
(entries) => entries.some((entry) => entry.isIntersecting)
|
||||
&& !this.fetching
|
||||
@@ -142,6 +151,9 @@ const Component = defineComponent({
|
||||
this.connection.on('message', this.onMessage);
|
||||
this.connection.on('read', this.onRead);
|
||||
this.connection.on('deleted', this.onDeleted);
|
||||
this.connection.on('typers', typers => {
|
||||
this.typers = typers.filter(u => u.id !== this.$i.id);
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', this.onVisibilitychange);
|
||||
|
||||
@@ -397,6 +409,7 @@ export default Component;
|
||||
|
||||
> footer {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
> .new-message {
|
||||
position: absolute;
|
||||
@@ -422,6 +435,25 @@ export default Component;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .typers {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
padding: 0 8px 0 8px;
|
||||
font-size: 0.9em;
|
||||
color: var(--fgTransparentWeak);
|
||||
|
||||
> .users {
|
||||
> .user + .user:before {
|
||||
content: ", ";
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
> .user:last-of-type:after {
|
||||
content: " ";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -28,6 +28,7 @@ export default defineComponent({
|
||||
limit: 10,
|
||||
params: () => ({
|
||||
query: this.$route.query.q,
|
||||
channelId: this.$route.query.channel,
|
||||
})
|
||||
},
|
||||
};
|
||||
|
@@ -60,7 +60,7 @@ export default defineComponent({
|
||||
}
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
os.api('i/update-email', {
|
||||
os.apiWithDialog('i/update-email', {
|
||||
password: password,
|
||||
email: this.emailAddress,
|
||||
});
|
||||
|
@@ -19,6 +19,7 @@
|
||||
<template #label>{{ $ts.behavior }}</template>
|
||||
<FormSwitch v-model:value="imageNewTab">{{ $ts.openImageInNewTab }}</FormSwitch>
|
||||
<FormSwitch v-model:value="enableInfiniteScroll">{{ $ts.enableInfiniteScroll }}</FormSwitch>
|
||||
<FormSwitch v-model:value="useReactionPickerForContextMenu">{{ $ts.useReactionPickerForContextMenu }}</FormSwitch>
|
||||
<FormSwitch v-model:value="disablePagesScript">{{ $ts.disablePagesScript }}</FormSwitch>
|
||||
</FormGroup>
|
||||
|
||||
@@ -144,6 +145,7 @@ export default defineComponent({
|
||||
chatOpenBehavior: ColdDeviceStorage.makeGetterSetter('chatOpenBehavior'),
|
||||
instanceTicker: defaultStore.makeGetterSetter('instanceTicker'),
|
||||
enableInfiniteScroll: defaultStore.makeGetterSetter('enableInfiniteScroll'),
|
||||
useReactionPickerForContextMenu: defaultStore.makeGetterSetter('useReactionPickerForContextMenu'),
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
@@ -192,8 +192,6 @@ export default (opts) => ({
|
||||
this.items = this.items.slice(-opts.displayLimit);
|
||||
this.more = true;
|
||||
}
|
||||
} else {
|
||||
|
||||
}
|
||||
this.items.push(item);
|
||||
// TODO
|
||||
|
@@ -144,6 +144,10 @@ export const defaultStore = markRaw(new Storage('base', {
|
||||
where: 'device',
|
||||
default: true
|
||||
},
|
||||
useReactionPickerForContextMenu: {
|
||||
where: 'device',
|
||||
default: true
|
||||
},
|
||||
showGapBetweenNotesInTimeline: {
|
||||
where: 'device',
|
||||
default: true
|
||||
|
@@ -32,7 +32,7 @@ export default defineComponent({
|
||||
});
|
||||
}
|
||||
|
||||
return h(TransitionGroup, {
|
||||
return h(this.reversed ? 'div' : TransitionGroup, {
|
||||
class: 'hmjzthxl',
|
||||
name: this.reversed ? 'list-reversed' : 'list',
|
||||
tag: 'div',
|
||||
|
@@ -1,12 +1,15 @@
|
||||
<template>
|
||||
<div class="_monospace">
|
||||
<span>
|
||||
<div class="acemodlh _monospace">
|
||||
<div>
|
||||
<span v-text="y"></span>/<span v-text="m"></span>/<span v-text="d"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span v-text="hh"></span>
|
||||
<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
|
||||
<span v-text="mm"></span>
|
||||
<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
|
||||
<span v-text="ss"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -18,6 +21,9 @@ export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
clock: null,
|
||||
y: null,
|
||||
m: null,
|
||||
d: null,
|
||||
hh: null,
|
||||
mm: null,
|
||||
ss: null,
|
||||
@@ -34,6 +40,9 @@ export default defineComponent({
|
||||
methods: {
|
||||
tick() {
|
||||
const now = new Date();
|
||||
this.y = now.getFullYear().toString();
|
||||
this.m = (now.getMonth() + 1).toString().padStart(2, '0');
|
||||
this.d = now.getDate().toString().padStart(2, '0');
|
||||
this.hh = now.getHours().toString().padStart(2, '0');
|
||||
this.mm = now.getMinutes().toString().padStart(2, '0');
|
||||
this.ss = now.getSeconds().toString().padStart(2, '0');
|
||||
@@ -42,3 +51,12 @@ export default defineComponent({
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.acemodlh {
|
||||
opacity: 0.7;
|
||||
font-size: 0.85em;
|
||||
line-height: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
@@ -99,7 +99,13 @@
|
||||
<div class="right">
|
||||
<div class="instance">{{ instanceName }}</div>
|
||||
<XHeaderClock class="clock"/>
|
||||
<button class="_button button search" @click="search" v-tooltip="$ts.search">
|
||||
<button class="_button button timetravel" @click="timetravel" v-tooltip="$ts.jumpToSpecifiedDate">
|
||||
<Fa :icon="faCalendarAlt"/>
|
||||
</button>
|
||||
<button class="_button button search" v-if="tl.startsWith('channel:') && currentChannel" @click="inChannelSearch" v-tooltip="$ts.inChannelSearch">
|
||||
<Fa :icon="faSearch"/>
|
||||
</button>
|
||||
<button class="_button button search" v-else @click="search" v-tooltip="$ts.search">
|
||||
<Fa :icon="faSearch"/>
|
||||
</button>
|
||||
<button class="_button button follow" v-if="tl.startsWith('channel:') && currentChannel" :class="{ followed: currentChannel.isFollowing }" @click="toggleChannelFollow" v-tooltip="currentChannel.isFollowing ? $ts.unfollow : $ts.follow">
|
||||
@@ -111,14 +117,9 @@
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="body">
|
||||
<XTimeline v-if="tl.startsWith('channel:')" src="channel" :key="tl" :channel="tl.replace('channel:', '')"/>
|
||||
<XTimeline v-else :src="tl" :key="tl"/>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<XPostForm v-if="tl.startsWith('channel:')" :key="tl" :channel="tl.replace('channel:', '')"/>
|
||||
<XPostForm v-else/>
|
||||
</footer>
|
||||
|
||||
<XTimeline class="body" ref="tl" v-if="tl.startsWith('channel:')" src="channel" :key="tl" :channel="tl.replace('channel:', '')"/>
|
||||
<XTimeline class="body" ref="tl" v-else :src="tl" :key="tl"/>
|
||||
</main>
|
||||
|
||||
<XSide class="side" ref="side" @open="sideViewOpening = true" @close="sideViewOpening = false"/>
|
||||
@@ -133,20 +134,20 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, defineAsyncComponent } from 'vue';
|
||||
import { faLayerGroup, faBars, faHome, faCircle, faWindowMaximize, faColumns, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog, faSearch, faPlus, faStar, faAt, faLink, faEllipsisH, faGlobe } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faBell, faStar as farStar, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons';
|
||||
import { faBell, faStar as farStar, faEnvelope, faComments, faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
import { instanceName, url } from '@/config';
|
||||
import XSidebar from '@/components/sidebar.vue';
|
||||
import XWidgets from './widgets.vue';
|
||||
import XCommon from '../_common_/common.vue';
|
||||
import XSide from './side.vue';
|
||||
import XTimeline from './timeline.vue';
|
||||
import XPostForm from './post-form.vue';
|
||||
import XHeaderClock from './header-clock.vue';
|
||||
import * as os from '@/os';
|
||||
import { router } from '@/router';
|
||||
import { sidebarDef } from '@/sidebar';
|
||||
import { search } from '@/scripts/search';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
|
||||
import { store } from './store';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -155,7 +156,6 @@ export default defineComponent({
|
||||
XWidgets,
|
||||
XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる
|
||||
XTimeline,
|
||||
XPostForm,
|
||||
XHeaderClock,
|
||||
},
|
||||
|
||||
@@ -186,7 +186,7 @@ export default defineComponent({
|
||||
|
||||
data() {
|
||||
return {
|
||||
tl: 'home',
|
||||
tl: store.state.tl,
|
||||
lists: null,
|
||||
antennas: null,
|
||||
followedChannels: null,
|
||||
@@ -195,7 +195,7 @@ export default defineComponent({
|
||||
menuDef: sidebarDef,
|
||||
sideViewOpening: false,
|
||||
instanceName,
|
||||
faLayerGroup, faBars, faBell, faHome, faCircle, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog, faSearch, faPlus, faStar, farStar, faAt, faLink, faEllipsisH, faGlobe, faComments, faEnvelope,
|
||||
faLayerGroup, faBars, faBell, faHome, faCircle, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog, faSearch, faPlus, faStar, farStar, faAt, faLink, faEllipsisH, faGlobe, faComments, faEnvelope, faCalendarAlt,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -219,11 +219,12 @@ export default defineComponent({
|
||||
this.antennas = antennas;
|
||||
});
|
||||
|
||||
os.api('channels/followed').then(channels => {
|
||||
os.api('channels/followed', { limit: 20 }).then(channels => {
|
||||
this.followedChannels = channels;
|
||||
});
|
||||
|
||||
os.api('channels/featured').then(channels => {
|
||||
// TODO: pagination
|
||||
os.api('channels/featured', { limit: 20 }).then(channels => {
|
||||
this.featuredChannels = channels;
|
||||
});
|
||||
|
||||
@@ -233,6 +234,7 @@ export default defineComponent({
|
||||
this.currentChannel = channel;
|
||||
});
|
||||
}
|
||||
store.set('tl', this.tl);
|
||||
}, { immediate: true });
|
||||
},
|
||||
|
||||
@@ -245,10 +247,31 @@ export default defineComponent({
|
||||
os.post();
|
||||
},
|
||||
|
||||
async timetravel() {
|
||||
const { canceled, result: date } = await os.dialog({
|
||||
title: this.$ts.date,
|
||||
input: {
|
||||
type: 'date'
|
||||
}
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
this.$refs.tl.timetravel(new Date(date));
|
||||
},
|
||||
|
||||
search() {
|
||||
search();
|
||||
},
|
||||
|
||||
async inChannelSearch() {
|
||||
const { canceled, result: query } = await os.dialog({
|
||||
title: this.$ts.inChannelSearch,
|
||||
input: true
|
||||
});
|
||||
if (canceled || query == null || query === '') return;
|
||||
router.push(`/search?q=${encodeURIComponent(query)}&channel=${this.currentChannel.id}`);
|
||||
},
|
||||
|
||||
top() {
|
||||
window.scroll({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
@@ -458,6 +481,9 @@ export default defineComponent({
|
||||
display: block;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
@@ -569,16 +595,6 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .footer {
|
||||
padding: 0 16px 16px 16px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
> .side {
|
||||
|
@@ -741,7 +741,13 @@ export default defineComponent({
|
||||
};
|
||||
if (isLink(e.target)) return;
|
||||
if (window.getSelection().toString() !== '') return;
|
||||
os.contextMenu(this.getMenu(), e).then(this.focus);
|
||||
|
||||
if (this.$store.state.useReactionPickerForContextMenu) {
|
||||
e.preventDefault();
|
||||
this.react();
|
||||
} else {
|
||||
os.contextMenu(this.getMenu(), e).then(this.focus);
|
||||
}
|
||||
},
|
||||
|
||||
menu(viaKeyboard = false) {
|
||||
@@ -1004,7 +1010,7 @@ export default defineComponent({
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
position: sticky;
|
||||
top: 12px;
|
||||
top: 0;
|
||||
margin: 0 14px 0 0;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
@@ -1085,6 +1091,7 @@ export default defineComponent({
|
||||
|
||||
> .poll {
|
||||
font-size: 80%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
> .renote {
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="" :ref="mounted">
|
||||
<div class="">
|
||||
<div class="_fullinfo" v-if="empty">
|
||||
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
|
||||
<div>{{ $ts.noNotes }}</div>
|
||||
@@ -8,10 +8,10 @@
|
||||
<MkError v-if="error" @retry="init()"/>
|
||||
|
||||
<div v-show="more && reversed" style="margin-bottom: var(--margin);">
|
||||
<button class="_buttonPrimary" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
|
||||
<MkButton style="margin: 0 auto;" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
|
||||
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
|
||||
<template v-if="moreFetching"><MkLoading inline/></template>
|
||||
</button>
|
||||
</MkButton>
|
||||
</div>
|
||||
|
||||
<XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed">
|
||||
@@ -19,10 +19,10 @@
|
||||
</XList>
|
||||
|
||||
<div v-show="more && !reversed" style="margin-top: var(--margin);">
|
||||
<button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
|
||||
<MkButton style="margin: 0 auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
|
||||
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
|
||||
<template v-if="moreFetching"><MkLoading inline/></template>
|
||||
</button>
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -32,10 +32,11 @@ import { defineComponent } from 'vue';
|
||||
import paging from '@/scripts/paging';
|
||||
import XNote from './note.vue';
|
||||
import XList from './date-separated-list.vue';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
XNote, XList,
|
||||
XNote, XList, MkButton,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
|
@@ -65,6 +65,7 @@ import * as os from '@/os';
|
||||
import { selectFile } from '@/scripts/select-file';
|
||||
import { notePostInterruptors, postFormActions } from '@/store';
|
||||
import { isMobile } from '@/scripts/is-mobile';
|
||||
import { throttle } from 'throttle-debounce';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -131,6 +132,11 @@ export default defineComponent({
|
||||
quoteId: null,
|
||||
recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
|
||||
imeText: '',
|
||||
typing: throttle(3000, () => {
|
||||
if (this.channel) {
|
||||
os.stream.send('typingOnChannel', { channel: this.channel });
|
||||
}
|
||||
}),
|
||||
postFormActions,
|
||||
faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug
|
||||
};
|
||||
@@ -421,10 +427,12 @@ export default defineComponent({
|
||||
onKeydown(e: KeyboardEvent) {
|
||||
if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
|
||||
if (e.which === 27) this.$emit('esc');
|
||||
this.typing();
|
||||
},
|
||||
|
||||
onCompositionUpdate(e: CompositionEvent) {
|
||||
this.imeText = e.data;
|
||||
this.typing();
|
||||
},
|
||||
|
||||
onCompositionEnd(e: CompositionEvent) {
|
||||
|
@@ -10,4 +10,8 @@ export const store = markRaw(new Storage('chatUi', {
|
||||
data: Record<string, any>;
|
||||
}[]
|
||||
},
|
||||
tl: {
|
||||
where: 'deviceAccount',
|
||||
default: 'home'
|
||||
},
|
||||
}));
|
||||
|
@@ -1,8 +1,25 @@
|
||||
<template>
|
||||
<div class="dbiokgaf">
|
||||
<div class="dbiokgaf info" v-if="date">
|
||||
<MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo>
|
||||
</div>
|
||||
<div class="dbiokgaf top" v-if="['home', 'local', 'social', 'global'].includes(src)">
|
||||
<XPostForm/>
|
||||
</div>
|
||||
<div class="dbiokgaf tl" ref="body">
|
||||
<div class="new" v-if="queue > 0" :style="{ width: width + 'px', [pagination.reversed ? 'bottom' : 'top']: pagination.reversed ? bottom + 'px' : top + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div>
|
||||
<XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated" v-follow="pagination.reversed"/>
|
||||
</div>
|
||||
<div class="dbiokgaf bottom" v-if="src === 'channel'">
|
||||
<div class="typers" v-if="typers.length > 0">
|
||||
<I18n :src="$ts.typingUsers" text-tag="span" class="users">
|
||||
<template #users>
|
||||
<b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b>
|
||||
</template>
|
||||
</I18n>
|
||||
<MkEllipsis/>
|
||||
</div>
|
||||
<XPostForm :channel="channel"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -12,10 +29,14 @@ import * as os from '@/os';
|
||||
import * as sound from '@/scripts/sound';
|
||||
import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
|
||||
import follow from '@/directives/follow-append';
|
||||
import XPostForm from './post-form.vue';
|
||||
import MkInfo from '@/components/ui/info.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
XNotes
|
||||
XNotes,
|
||||
XPostForm,
|
||||
MkInfo,
|
||||
},
|
||||
|
||||
directives: {
|
||||
@@ -45,11 +66,6 @@ export default defineComponent({
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
sound: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['note', 'queue', 'before', 'after'],
|
||||
@@ -69,6 +85,8 @@ export default defineComponent({
|
||||
width: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
typers: [],
|
||||
date: null
|
||||
};
|
||||
},
|
||||
|
||||
@@ -78,9 +96,7 @@ export default defineComponent({
|
||||
|
||||
this.$emit('note');
|
||||
|
||||
if (this.sound) {
|
||||
sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
|
||||
}
|
||||
sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
|
||||
};
|
||||
|
||||
const onUserAdded = () => {
|
||||
@@ -166,6 +182,9 @@ export default defineComponent({
|
||||
channelId: this.channel
|
||||
});
|
||||
this.connection.on('note', prepend);
|
||||
this.connection.on('typers', typers => {
|
||||
this.typers = this.$i ? typers.filter(u => u.id !== this.$i.id) : typers;
|
||||
});
|
||||
}
|
||||
|
||||
this.pagination = {
|
||||
@@ -173,7 +192,7 @@ export default defineComponent({
|
||||
reversed,
|
||||
limit: 10,
|
||||
params: init => ({
|
||||
untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
|
||||
untilDate: this.date?.getTime(),
|
||||
...this.baseQuery, ...this.query
|
||||
})
|
||||
};
|
||||
@@ -190,34 +209,73 @@ export default defineComponent({
|
||||
|
||||
methods: {
|
||||
focus() {
|
||||
this.$refs.tl.focus();
|
||||
this.$refs.body.focus();
|
||||
},
|
||||
|
||||
goTop() {
|
||||
const container = getScrollContainer(this.$el);
|
||||
const container = getScrollContainer(this.$refs.body);
|
||||
container.scrollTop = 0;
|
||||
},
|
||||
|
||||
queueUpdated(q) {
|
||||
if (this.$el.offsetWidth !== 0) {
|
||||
const rect = this.$el.getBoundingClientRect();
|
||||
const scrollTop = getScrollPosition(this.$el);
|
||||
this.width = this.$el.offsetWidth;
|
||||
this.top = rect.top + scrollTop;
|
||||
this.bottom = this.$el.offsetHeight;
|
||||
if (this.$refs.body.offsetWidth !== 0) {
|
||||
const rect = this.$refs.body.getBoundingClientRect();
|
||||
this.width = this.$refs.body.offsetWidth;
|
||||
this.top = rect.top;
|
||||
this.bottom = this.$refs.body.offsetHeight;
|
||||
}
|
||||
this.queue = q;
|
||||
},
|
||||
|
||||
timetravel(date?: Date) {
|
||||
this.date = date;
|
||||
this.$refs.tl.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dbiokgaf {
|
||||
padding: 16px 0;
|
||||
.dbiokgaf.info{
|
||||
padding: 16px 16px 0 16px;
|
||||
}
|
||||
|
||||
// TODO: これはノート追加アニメーションによるスクロール発生を抑えるために必要だが、position stickyが効かなくなるので、両者を両立させる良い方法を考える
|
||||
overflow: hidden;
|
||||
.dbiokgaf.top {
|
||||
padding: 16px 16px 0 16px;
|
||||
}
|
||||
|
||||
.dbiokgaf.bottom {
|
||||
padding: 0 16px 16px 16px;
|
||||
position: relative;
|
||||
|
||||
> .typers {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
padding: 0 8px 0 8px;
|
||||
font-size: 0.9em;
|
||||
background: var(--panel);
|
||||
border-radius: 0 8px 0 0;
|
||||
color: var(--fgTransparentWeak);
|
||||
|
||||
> .users {
|
||||
> .user + .user:before {
|
||||
content: ", ";
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
> .user:last-of-type:after {
|
||||
content: " ";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dbiokgaf.tl {
|
||||
position: relative;
|
||||
padding: 16px 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
|
||||
> .new {
|
||||
position: fixed;
|
||||
|
@@ -10,7 +10,7 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, defineAsyncComponent } from 'vue';
|
||||
import XWidgets from '@/components/widgets.vue';
|
||||
import { store } from './store.ts';
|
||||
import { store } from './store';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -34,6 +34,7 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
updateWidget({ id, data }) {
|
||||
// TODO: throttleしたい
|
||||
store.set('widgets', store.state.widgets.map(w => w.id === id ? {
|
||||
...w,
|
||||
data: data
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { throttle } from 'throttle-debounce';
|
||||
import { Form } from '@/scripts/form';
|
||||
import * as os from '@/os';
|
||||
|
||||
@@ -21,7 +22,10 @@ export default function <T extends Form>(data: {
|
||||
|
||||
data() {
|
||||
return {
|
||||
props: this.widget ? JSON.parse(JSON.stringify(this.widget.data)) : {}
|
||||
props: this.widget ? JSON.parse(JSON.stringify(this.widget.data)) : {},
|
||||
save: throttle(3000, () => {
|
||||
this.$emit('updateProps', this.props);
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -66,10 +70,6 @@ export default function <T extends Form>(data: {
|
||||
|
||||
this.save();
|
||||
},
|
||||
|
||||
save() {
|
||||
this.$emit('updateProps', this.props);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@@ -5,19 +5,19 @@
|
||||
<div class="values">
|
||||
<div>
|
||||
<div>Process</div>
|
||||
<div>{{ number(inbox.activeSincePrevTick) }}</div>
|
||||
<div :class="{ inc: inbox.activeSincePrevTick > prev.inbox.activeSincePrevTick, dec: inbox.activeSincePrevTick < prev.inbox.activeSincePrevTick }">{{ number(inbox.activeSincePrevTick) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>Active</div>
|
||||
<div>{{ number(inbox.active) }}</div>
|
||||
<div :class="{ inc: inbox.active > prev.inbox.active, dec: inbox.active < prev.inbox.active }">{{ number(inbox.active) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>Delayed</div>
|
||||
<div>{{ number(inbox.delayed) }}</div>
|
||||
<div :class="{ inc: inbox.delayed > prev.inbox.delayed, dec: inbox.delayed < prev.inbox.delayed }">{{ number(inbox.delayed) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>Waiting</div>
|
||||
<div>{{ number(inbox.waiting) }}</div>
|
||||
<div :class="{ inc: inbox.waiting > prev.inbox.waiting, dec: inbox.waiting < prev.inbox.waiting }">{{ number(inbox.waiting) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,19 +26,19 @@
|
||||
<div class="values">
|
||||
<div>
|
||||
<div>Process</div>
|
||||
<div>{{ number(deliver.activeSincePrevTick) }}</div>
|
||||
<div :class="{ inc: deliver.activeSincePrevTick > prev.deliver.activeSincePrevTick, dec: deliver.activeSincePrevTick < prev.deliver.activeSincePrevTick }">{{ number(deliver.activeSincePrevTick) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>Active</div>
|
||||
<div>{{ number(deliver.active) }}</div>
|
||||
<div :class="{ inc: deliver.active > prev.deliver.active, dec: deliver.active < prev.deliver.active }">{{ number(deliver.active) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>Delayed</div>
|
||||
<div>{{ number(deliver.delayed) }}</div>
|
||||
<div :class="{ inc: deliver.delayed > prev.deliver.delayed, dec: deliver.delayed < prev.deliver.delayed }">{{ number(deliver.delayed) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>Waiting</div>
|
||||
<div>{{ number(deliver.waiting) }}</div>
|
||||
<div :class="{ inc: deliver.waiting > prev.deliver.waiting, dec: deliver.waiting < prev.deliver.waiting }">{{ number(deliver.waiting) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,10 +79,15 @@ export default defineComponent({
|
||||
waiting: 0,
|
||||
delayed: 0,
|
||||
},
|
||||
prev: {},
|
||||
faExclamationTriangle,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
for (const domain of ['inbox', 'deliver']) {
|
||||
this.prev[domain] = JSON.parse(JSON.stringify(this[domain]));
|
||||
}
|
||||
|
||||
this.connection.on('stats', this.onStats);
|
||||
this.connection.on('statsLog', this.onStatsLog);
|
||||
|
||||
@@ -99,6 +104,7 @@ export default defineComponent({
|
||||
methods: {
|
||||
onStats(stats) {
|
||||
for (const domain of ['inbox', 'deliver']) {
|
||||
this.prev[domain] = JSON.parse(JSON.stringify(this[domain]));
|
||||
this[domain].activeSincePrevTick = stats[domain].activeSincePrevTick;
|
||||
this[domain].active = stats[domain].active;
|
||||
this[domain].waiting = stats[domain].waiting;
|
||||
@@ -152,6 +158,16 @@ export default defineComponent({
|
||||
> div:first-child {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> div:last-child {
|
||||
&.inc {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
&.dec {
|
||||
color: var(--success);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
# Creating plugins
|
||||
# New Plugin
|
||||
If you use the plugin function of the Misskey web client, you can expand the web client with a variety of different functionality. This page will list metadata definitions for plugin creation as well as an AiScript API reference for plugins.
|
||||
|
||||
## Metadata
|
||||
|
@@ -1,2 +1,2 @@
|
||||
# フォロー
|
||||
# Ikuti
|
||||
ユーザーをフォローすると、タイムラインにそのユーザーの投稿が表示されるようになります。ただし、他のユーザーに対する返信は含まれません。 ユーザーをフォローするには、ユーザーページの「フォロー」ボタンをクリックします。フォローを解除するには、もう一度クリックします。
|
||||
|
@@ -1,4 +1,4 @@
|
||||
# AiScript
|
||||
|
||||
## 関数
|
||||
## Funzione
|
||||
デフォルトで値渡しです。
|
||||
|
@@ -1,7 +1,7 @@
|
||||
# プラグインの作成
|
||||
Misskey Webクライアントのプラグイン機能を使うと、クライアントを拡張し、様々な機能を追加できます。 ここではプラグインの作成にあたってのメタデータ定義や、AiScript APIリファレンスを掲載します。
|
||||
|
||||
## メタデータ
|
||||
## Metadato
|
||||
プラグインは、AiScriptのメタデータ埋め込み機能を使って、デフォルトとしてプラグインのメタデータを定義する必要があります。 メタデータは次のプロパティを含むオブジェクトです。
|
||||
|
||||
### name
|
||||
|
@@ -1,2 +1,2 @@
|
||||
# フォロー
|
||||
# Seiguiti
|
||||
ユーザーをフォローすると、タイムラインにそのユーザーの投稿が表示されるようになります。ただし、他のユーザーに対する返信は含まれません。 ユーザーをフォローするには、ユーザーページの「フォロー」ボタンをクリックします。フォローを解除するには、もう一度クリックします。
|
||||
|
@@ -1,6 +1,6 @@
|
||||
# Pages
|
||||
|
||||
## 変数
|
||||
## Variabili
|
||||
変数を使うことで動的なページを作成できます。テキスト内で <b>{ 変数名 }</b> と書くとそこに変数の値を埋め込めます。例えば <b>Hello { thing } world!</b> というテキストで、変数(thing)の値が <b>ai</b> だった場合、テキストは <b>Hello ai world!</b> になります。
|
||||
|
||||
変数の評価(値を算出すること)は上から下に行われるので、ある変数の中で自分より下の変数を参照することはできません。例えば上から <b>A、B、C</b> と3つの変数を定義したとき、<b>C</b>の中で<b>A</b>や<b>B</b>を参照することはできますが、<b>A</b>の中で<b>B</b>や<b>C</b>を参照することはできません。
|
||||
|
@@ -1,4 +1,4 @@
|
||||
# リアクション
|
||||
# Reazione
|
||||
他の人のノートに、絵文字を付けて簡単にあなたの反応を伝えられる機能です。 リアクションするには、ノートの + アイコンをクリックしてピッカーを表示し、絵文字を選択します。 リアクションには[カスタム絵文字](./custom-emoji)も使用できます。
|
||||
|
||||
## リアクションピッカーのカスタマイズ
|
||||
|
@@ -31,7 +31,7 @@
|
||||
|
||||
**ストリームでのやり取りはすべてJSONです。**
|
||||
|
||||
## チャンネル
|
||||
## Canale
|
||||
MisskeyのストリーミングAPIにはチャンネルという概念があります。これは、送受信する情報を分離するための仕組みです。 Misskeyのストリームに接続しただけでは、まだリアルタイムでタイムラインの投稿を受信したりはできません。 ストリーム上でチャンネルに接続することで、様々な情報を受け取ったり情報を送信したりすることができるようになります。
|
||||
|
||||
### チャンネルに接続する
|
||||
|
@@ -1,4 +1,4 @@
|
||||
# テーマ
|
||||
# Tema
|
||||
|
||||
テーマを設定して、Misskeyクライアントの見た目を変更できます。
|
||||
|
||||
@@ -61,8 +61,8 @@
|
||||
* 関数(後述)
|
||||
* `:{関数名}<{引数}<{色}`
|
||||
|
||||
#### 定数
|
||||
#### Costante
|
||||
「CSS変数として出力はしたくないが、他のCSS変数の値として使いまわしたい」値があるときは、定数を使うと便利です。 キー名を`$`で始めると、そのキーはCSS変数として出力されません。
|
||||
|
||||
#### 関数
|
||||
#### Funzione
|
||||
wip
|
||||
|
@@ -2,10 +2,10 @@
|
||||
|
||||
https://docs.google.com/spreadsheets/d/1lxQ2ugKrhz58Bg96HTDK_2F98BUritkMyIiBkOByjHA/edit?usp=sharing
|
||||
|
||||
## ホーム
|
||||
## Home
|
||||
自分のフォローしているユーザーの投稿
|
||||
|
||||
## ローカル
|
||||
## Locale
|
||||
全てのローカルユーザーの「ホーム」指定されていない投稿
|
||||
|
||||
## ソーシャル
|
||||
|
@@ -18,21 +18,21 @@ APIを使い始めるには、まずアクセストークンを取得する必
|
||||
### アプリケーション利用者にアクセストークンの発行をリクエストする
|
||||
アプリケーション利用者のアクセストークンを取得するには、以下の手順で発行をリクエストします。
|
||||
|
||||
#### Step 1
|
||||
#### Крок 1
|
||||
|
||||
UUIDを生成する。以後これをセッションIDと呼びます。
|
||||
Створити UUID.以後これをセッションIDと呼びます。
|
||||
|
||||
> このセッションIDは毎回生成し、使いまわさないようにしてください。
|
||||
|
||||
#### Step 2
|
||||
#### Крок 2
|
||||
|
||||
`{_URL_}/miauth/{session}`をユーザーのブラウザで表示させる。`{session}`の部分は、セッションIDに置き換えてください。
|
||||
> 例: `{_URL_}/miauth/c1f6d42b-468b-4fd2-8274-e58abdedef6f`
|
||||
|
||||
表示する際、URLにクエリパラメータとしていくつかのオプションを設定できます:
|
||||
* `name` ... アプリケーション名
|
||||
* `name` ... Назва додатка
|
||||
* > 例: `MissDeck`
|
||||
* `icon` ... アプリケーションのアイコン画像URL
|
||||
* `icon` ... URL піктограми додатка
|
||||
* > 例: `https://missdeck.example.com/icon.png`
|
||||
* `callback` ... 認証が終わった後にリダイレクトするURL
|
||||
* > 例: `https://missdeck.example.com/callback`
|
||||
@@ -42,7 +42,7 @@ UUIDを生成する。以後これをセッションIDと呼びます。
|
||||
* 要求する権限を`,`で区切って列挙します
|
||||
* どのような権限があるかは[APIリファレンス](/api-doc)で確認できます
|
||||
|
||||
#### Step 3
|
||||
#### Крок 3
|
||||
ユーザーが発行を許可した後、`{_URL_}/api/miauth/{session}/check`にPOSTリクエストすると、レスポンスとしてアクセストークンを含むJSONが返ります。
|
||||
|
||||
レスポンスに含まれるプロパティ:
|
||||
@@ -51,8 +51,8 @@ UUIDを生成する。以後これをセッションIDと呼びます。
|
||||
|
||||
[「APIの使い方」へ進む](#APIの使い方)
|
||||
|
||||
## APIの使い方
|
||||
**APIはすべてPOSTで、リクエスト/レスポンスともにJSON形式です。RESTではありません。** アクセストークンは、`i`というパラメータ名でリクエストに含めます。
|
||||
## Використання API
|
||||
**APIはすべてPOSTで、リクエスト/レスポンスともにJSON形式です。Підтримка REST відсутня.** アクセストークンは、`i`というパラメータ名でリクエストに含めます。
|
||||
|
||||
* [APIリファレンス](/api-doc)
|
||||
* [ストリーミングAPI](./stream)
|
||||
* [Довідник API](/api-doc)
|
||||
* [Потокове API](./stream)
|
||||
|
@@ -1,4 +1,4 @@
|
||||
# プラグインの作成
|
||||
# Створення плагінів
|
||||
Misskey Webクライアントのプラグイン機能を使うと、クライアントを拡張し、様々な機能を追加できます。 ここではプラグインの作成にあたってのメタデータ定義や、AiScript APIリファレンスを掲載します。
|
||||
|
||||
## Метадані
|
||||
@@ -34,7 +34,7 @@ Misskey Webクライアントのプラグイン機能を使うと、クライア
|
||||
#### default
|
||||
設定のデフォルト値
|
||||
|
||||
## APIリファレンス
|
||||
## Довідник API
|
||||
AiScript標準で組み込まれているAPIは掲載しません。
|
||||
|
||||
### Mk:dialog(title text type)
|
||||
|
@@ -1,4 +1,4 @@
|
||||
# ストリーミングAPI
|
||||
# Потокове API
|
||||
|
||||
ストリーミングAPIを使うと、リアルタイムで様々な情報(例えばタイムラインに新しい投稿が流れてきた、メッセージが届いた、フォローされた、など)を受け取ったり、様々な操作を行ったりすることができます。
|
||||
|
||||
|
@@ -85,7 +85,7 @@ export default define(meta, async (ps, user) => {
|
||||
}
|
||||
|
||||
//#region Construct query
|
||||
const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.channelId = :channelId', { channelId: channel.id })
|
||||
.leftJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
@@ -46,6 +46,11 @@ export const meta = {
|
||||
validator: $.optional.nullable.type(ID),
|
||||
default: null
|
||||
},
|
||||
|
||||
channelId: {
|
||||
validator: $.optional.nullable.type(ID),
|
||||
default: null
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
@@ -64,7 +69,15 @@ export const meta = {
|
||||
|
||||
export default define(meta, async (ps, me) => {
|
||||
if (es == null) {
|
||||
const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId);
|
||||
|
||||
if (ps.userId) {
|
||||
query.andWhere('note.userId = :userId', { userId: ps.userId });
|
||||
} else if (ps.channelId) {
|
||||
query.andWhere('note.channelId = :channelId', { channelId: ps.channelId });
|
||||
}
|
||||
|
||||
query
|
||||
.andWhere('note.text ILIKE :q', { q: `%${ps.query}%` })
|
||||
.leftJoinAndSelect('note.user', 'user');
|
||||
|
||||
|
@@ -1,14 +1,17 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import Channel from '../channel';
|
||||
import { Notes } from '../../../../models';
|
||||
import { Notes, Users } from '../../../../models';
|
||||
import { isMutedUserRelated } from '../../../../misc/is-muted-user-related';
|
||||
import { PackedNote } from '../../../../models/repositories/note';
|
||||
import { User } from '../../../../models/entities/user';
|
||||
|
||||
export default class extends Channel {
|
||||
public readonly chName = 'channel';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = false;
|
||||
private channelId: string;
|
||||
private typers: Record<User['id'], Date> = {};
|
||||
private emitTypersIntervalId: ReturnType<typeof setInterval>;
|
||||
|
||||
@autobind
|
||||
public async init(params: any) {
|
||||
@@ -16,6 +19,8 @@ export default class extends Channel {
|
||||
|
||||
// Subscribe stream
|
||||
this.subscriber.on('notesStream', this.onNote);
|
||||
this.subscriber.on(`channelStream:${this.channelId}`, this.onEvent);
|
||||
this.emitTypersIntervalId = setInterval(this.emitTypers, 5000);
|
||||
}
|
||||
|
||||
@autobind
|
||||
@@ -41,9 +46,41 @@ export default class extends Channel {
|
||||
this.send('note', note);
|
||||
}
|
||||
|
||||
@autobind
|
||||
private onEvent(data: any) {
|
||||
if (data.type === 'typing') {
|
||||
const id = data.body;
|
||||
const begin = this.typers[id] == null;
|
||||
this.typers[id] = new Date();
|
||||
if (begin) {
|
||||
this.emitTypers();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async emitTypers() {
|
||||
const now = new Date();
|
||||
|
||||
// Remove not typing users
|
||||
for (const [userId, date] of Object.entries(this.typers)) {
|
||||
if (now.getTime() - date.getTime() > 5000) delete this.typers[userId];
|
||||
}
|
||||
|
||||
const users = await Users.packMany(Object.keys(this.typers), null, { detail: false });
|
||||
|
||||
this.send({
|
||||
type: 'typers',
|
||||
body: users,
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
public dispose() {
|
||||
// Unsubscribe events
|
||||
this.subscriber.off('notesStream', this.onNote);
|
||||
this.subscriber.off(`channelStream:${this.channelId}`, this.onEvent);
|
||||
|
||||
clearInterval(this.emitTypersIntervalId);
|
||||
}
|
||||
}
|
||||
|
@@ -15,7 +15,7 @@ export default class extends Channel {
|
||||
|
||||
private gameId: ReversiGame['id'] | null = null;
|
||||
private watchers: Record<User['id'], Date> = {};
|
||||
private emitWatchersIntervalId: any;
|
||||
private emitWatchersIntervalId: ReturnType<typeof setInterval>;
|
||||
|
||||
@autobind
|
||||
public async init(params: any) {
|
||||
|
@@ -12,6 +12,9 @@ export default class extends Channel {
|
||||
private otherpartyId: string | null;
|
||||
private otherparty?: User;
|
||||
private groupId: string | null;
|
||||
private subCh: string;
|
||||
private typers: Record<User['id'], Date> = {};
|
||||
private emitTypersIntervalId: ReturnType<typeof setInterval>;
|
||||
|
||||
@autobind
|
||||
public async init(params: any) {
|
||||
@@ -31,14 +34,28 @@ export default class extends Channel {
|
||||
}
|
||||
}
|
||||
|
||||
const subCh = this.otherpartyId
|
||||
this.emitTypersIntervalId = setInterval(this.emitTypers, 5000);
|
||||
|
||||
this.subCh = this.otherpartyId
|
||||
? `messagingStream:${this.user!.id}-${this.otherpartyId}`
|
||||
: `messagingStream:${this.groupId}`;
|
||||
|
||||
// Subscribe messaging stream
|
||||
this.subscriber.on(subCh, data => {
|
||||
this.subscriber.on(this.subCh, this.onEvent);
|
||||
}
|
||||
|
||||
@autobind
|
||||
private onEvent(data: any) {
|
||||
if (data.type === 'typing') {
|
||||
const id = data.body;
|
||||
const begin = this.typers[id] == null;
|
||||
this.typers[id] = new Date();
|
||||
if (begin) {
|
||||
this.emitTypers();
|
||||
}
|
||||
} else {
|
||||
this.send(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
@@ -60,4 +77,28 @@ export default class extends Channel {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async emitTypers() {
|
||||
const now = new Date();
|
||||
|
||||
// Remove not typing users
|
||||
for (const [userId, date] of Object.entries(this.typers)) {
|
||||
if (now.getTime() - date.getTime() > 5000) delete this.typers[userId];
|
||||
}
|
||||
|
||||
const users = await Users.packMany(Object.keys(this.typers), null, { detail: false });
|
||||
|
||||
this.send({
|
||||
type: 'typers',
|
||||
body: users,
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
public dispose() {
|
||||
this.subscriber.off(this.subCh, this.onEvent);
|
||||
|
||||
clearInterval(this.emitTypersIntervalId);
|
||||
}
|
||||
}
|
||||
|
@@ -12,6 +12,8 @@ import { Users, Followings, Mutings, UserProfiles, ChannelFollowings } from '../
|
||||
import { ApiError } from '../error';
|
||||
import { AccessToken } from '../../../models/entities/access-token';
|
||||
import { UserProfile } from '../../../models/entities/user-profile';
|
||||
import { publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from '../../../services/stream';
|
||||
import { UserGroup } from '../../../models/entities/user-group';
|
||||
|
||||
/**
|
||||
* Main stream connection
|
||||
@@ -27,10 +29,10 @@ export default class Connection {
|
||||
public subscriber: EventEmitter;
|
||||
private channels: Channel[] = [];
|
||||
private subscribingNotes: any = {};
|
||||
private followingClock: NodeJS.Timer;
|
||||
private mutingClock: NodeJS.Timer;
|
||||
private followingChannelsClock: NodeJS.Timer;
|
||||
private userProfileClock: NodeJS.Timer;
|
||||
private followingClock: ReturnType<typeof setInterval>;
|
||||
private mutingClock: ReturnType<typeof setInterval>;
|
||||
private followingChannelsClock: ReturnType<typeof setInterval>;
|
||||
private userProfileClock: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(
|
||||
wsConnection: websocket.connection,
|
||||
@@ -93,6 +95,12 @@ export default class Connection {
|
||||
case 'disconnect': this.onChannelDisconnectRequested(body); break;
|
||||
case 'channel': this.onChannelMessageRequested(body); break;
|
||||
case 'ch': this.onChannelMessageRequested(body); break; // alias
|
||||
|
||||
// 個々のチャンネルではなくルートレベルでこれらのメッセージを受け取る理由は、
|
||||
// クライアントの事情を考慮したとき、入力フォームはノートチャンネルやメッセージのメインコンポーネントとは別
|
||||
// なこともあるため、それらのコンポーネントがそれぞれ各チャンネルに接続するようにするのは面倒なため。
|
||||
case 'typingOnChannel': this.typingOnChannel(body.channel); break;
|
||||
case 'typingOnMessaging': this.typingOnMessaging(body); break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +266,24 @@ export default class Connection {
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
private typingOnChannel(channel: ChannelModel['id']) {
|
||||
if (this.user) {
|
||||
publishChannelStream(channel, 'typing', this.user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
private typingOnMessaging(param: { partner?: User['id']; group?: UserGroup['id']; }) {
|
||||
if (this.user) {
|
||||
if (param.partner) {
|
||||
publishMessagingStream(param.partner, this.user.id, 'typing', this.user.id);
|
||||
} else if (param.group) {
|
||||
publishGroupMessagingStream(param.group, 'typing', this.user.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async updateFollowing() {
|
||||
const followings = await Followings.find({
|
||||
|
@@ -11,6 +11,10 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
window.onerror = (e) => {
|
||||
document.documentElement.innerHTML = '問題が発生しました。';
|
||||
};
|
||||
|
||||
// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
|
||||
(async () => {
|
||||
const v = localStorage.getItem('v') || VERSION;
|
||||
|
@@ -6,6 +6,7 @@ import { ReversiGame } from '../models/entities/games/reversi/game';
|
||||
import { UserGroup } from '../models/entities/user-group';
|
||||
import config from '../config';
|
||||
import { Antenna } from '../models/entities/antenna';
|
||||
import { Channel } from '../models/entities/channel';
|
||||
|
||||
class Publisher {
|
||||
private publish = (channel: string, type: string | null, value?: any): void => {
|
||||
@@ -38,6 +39,10 @@ class Publisher {
|
||||
});
|
||||
}
|
||||
|
||||
public publishChannelStream = (channelId: Channel['id'], type: string, value?: any): void => {
|
||||
this.publish(`channelStream:${channelId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishUserListStream = (listId: UserList['id'], type: string, value?: any): void => {
|
||||
this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
@@ -84,6 +89,7 @@ export const publishMainStream = publisher.publishMainStream;
|
||||
export const publishDriveStream = publisher.publishDriveStream;
|
||||
export const publishNoteStream = publisher.publishNoteStream;
|
||||
export const publishNotesStream = publisher.publishNotesStream;
|
||||
export const publishChannelStream = publisher.publishChannelStream;
|
||||
export const publishUserListStream = publisher.publishUserListStream;
|
||||
export const publishAntennaStream = publisher.publishAntennaStream;
|
||||
export const publishMessagingStream = publisher.publishMessagingStream;
|
||||
|
Reference in New Issue
Block a user