Merge branch 'develop' into sw-notification-action

This commit is contained in:
tamaina
2021-02-21 13:52:54 +09:00
64 changed files with 1253 additions and 248 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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: " ";
}
}
}
}
}

View File

@@ -28,6 +28,7 @@ export default defineComponent({
limit: 10,
params: () => ({
query: this.$route.query.q,
channelId: this.$route.query.channel,
})
},
};

View File

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

View File

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

View File

@@ -192,8 +192,6 @@ export default (opts) => ({
this.items = this.items.slice(-opts.displayLimit);
this.more = true;
}
} else {
}
this.items.push(item);
// TODO

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,4 +10,8 @@ export const store = markRaw(new Storage('chatUi', {
data: Record<string, any>;
}[]
},
tl: {
where: 'deviceAccount',
default: 'home'
},
}));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +1,2 @@
# フォロー
# Ikuti
ユーザーをフォローすると、タイムラインにそのユーザーの投稿が表示されるようになります。ただし、他のユーザーに対する返信は含まれません。 ユーザーをフォローするには、ユーザーページの「フォロー」ボタンをクリックします。フォローを解除するには、もう一度クリックします。

View File

@@ -1,4 +1,4 @@
# AiScript
## 関数
## Funzione
デフォルトで値渡しです。

View File

@@ -1,7 +1,7 @@
# プラグインの作成
Misskey Webクライアントのプラグイン機能を使うと、クライアントを拡張し、様々な機能を追加できます。 ここではプラグインの作成にあたってのメタデータ定義や、AiScript APIリファレンスを掲載します。
## メタデータ
## Metadato
プラグインは、AiScriptのメタデータ埋め込み機能を使って、デフォルトとしてプラグインのメタデータを定義する必要があります。 メタデータは次のプロパティを含むオブジェクトです。
### name

View File

@@ -1,2 +1,2 @@
# フォロー
# Seiguiti
ユーザーをフォローすると、タイムラインにそのユーザーの投稿が表示されるようになります。ただし、他のユーザーに対する返信は含まれません。 ユーザーをフォローするには、ユーザーページの「フォロー」ボタンをクリックします。フォローを解除するには、もう一度クリックします。

View File

@@ -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>を参照することはできません。

View File

@@ -1,4 +1,4 @@
# リアクション
# Reazione
他の人のノートに、絵文字を付けて簡単にあなたの反応を伝えられる機能です。 リアクションするには、ノートの + アイコンをクリックしてピッカーを表示し、絵文字を選択します。 リアクションには[カスタム絵文字](./custom-emoji)も使用できます。
## リアクションピッカーのカスタマイズ

View File

@@ -31,7 +31,7 @@
**ストリームでのやり取りはすべてJSONです。**
## チャンネル
## Canale
MisskeyのストリーミングAPIにはチャンネルという概念があります。これは、送受信する情報を分離するための仕組みです。 Misskeyのストリームに接続しただけでは、まだリアルタイムでタイムラインの投稿を受信したりはできません。 ストリーム上でチャンネルに接続することで、様々な情報を受け取ったり情報を送信したりすることができるようになります。
### チャンネルに接続する

View File

@@ -1,4 +1,4 @@
# テーマ
# Tema
テーマを設定して、Misskeyクライアントの見た目を変更できます。
@@ -61,8 +61,8 @@
* 関数(後述)
* `:{関数名}<{引数}<{色}`
#### 定数
#### Costante
「CSS変数として出力はしたくないが、他のCSS変数の値として使いまわしたい」値があるときは、定数を使うと便利です。 キー名を`$`で始めると、そのキーはCSS変数として出力されません。
#### 関数
#### Funzione
wip

View File

@@ -2,10 +2,10 @@
https://docs.google.com/spreadsheets/d/1lxQ2ugKrhz58Bg96HTDK_2F98BUritkMyIiBkOByjHA/edit?usp=sharing
## ホーム
## Home
自分のフォローしているユーザーの投稿
## ローカル
## Locale
全てのローカルユーザーの「ホーム」指定されていない投稿
## ソーシャル

View File

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

View File

@@ -1,4 +1,4 @@
# プラグインの作成
# Створення плагінів
Misskey Webクライアントのプラグイン機能を使うと、クライアントを拡張し、様々な機能を追加できます。 ここではプラグインの作成にあたってのメタデータ定義や、AiScript APIリファレンスを掲載します。
## Метадані
@@ -34,7 +34,7 @@ Misskey Webクライアントのプラグイン機能を使うと、クライア
#### default
設定のデフォルト値
## APIリファレンス
## Довідник API
AiScript標準で組み込まれているAPIは掲載しません。
### Mk:dialog(title text type)

View File

@@ -1,4 +1,4 @@
# ストリーミングAPI
# Потокове API
ストリーミングAPIを使うと、リアルタイムで様々な情報(例えばタイムラインに新しい投稿が流れてきた、メッセージが届いた、フォローされた、など)を受け取ったり、様々な操作を行ったりすることができます。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,10 @@
'use strict';
window.onerror = (e) => {
document.documentElement.innerHTML = '問題が発生しました。';
};
// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
(async () => {
const v = localStorage.getItem('v') || VERSION;

View File

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