* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wop

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* add notes

* wip

* wip

* wip

* wip

* sound

* wip

* add kick_gaba2

* wip
This commit is contained in:
syuilo 2020-08-18 22:44:21 +09:00 committed by GitHub
parent 122076e8ea
commit 9855405b89
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
70 changed files with 2191 additions and 184 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,141 @@
<template>
<button class="hdcaacmi _button"
:class="{ wait, active: isFollowing, full }"
@click="onClick"
:disabled="wait"
>
<template v-if="!wait">
<template v-if="isFollowing">
<span v-if="full">{{ $t('unfollow') }}</span><fa :icon="faMinus"/>
</template>
<template v-else>
<span v-if="full">{{ $t('follow') }}</span><fa :icon="faPlus"/>
</template>
</template>
<template v-else>
<span v-if="full">{{ $t('processing') }}</span><fa :icon="faSpinner" pulse fixed-width/>
</template>
</button>
</template>
<script lang="ts">
import Vue from 'vue';
import { faSpinner, faPlus, faMinus, } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
props: {
channel: {
type: Object,
required: true
},
full: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
isFollowing: this.channel.isFollowing,
wait: false,
faSpinner, faPlus, faMinus,
};
},
methods: {
async onClick() {
this.wait = true;
try {
if (this.isFollowing) {
await this.$root.api('channels/unfollow', {
channelId: this.channel.id
});
this.isFollowing = false;
} else {
await this.$root.api('channels/follow', {
channelId: this.channel.id
});
this.isFollowing = true;
}
} catch (e) {
console.error(e);
} finally {
this.wait = false;
}
}
}
});
</script>
<style lang="scss" scoped>
.hdcaacmi {
position: relative;
display: inline-block;
font-weight: bold;
color: var(--accent);
background: transparent;
border: solid 1px var(--accent);
padding: 0;
height: 31px;
font-size: 16px;
border-radius: 32px;
background: #fff;
&.full {
padding: 0 8px 0 12px;
font-size: 14px;
}
&:not(.full) {
width: 31px;
}
&:focus {
&:after {
content: "";
pointer-events: none;
position: absolute;
top: -5px;
right: -5px;
bottom: -5px;
left: -5px;
border: 2px solid var(--focus);
border-radius: 32px;
}
}
&:hover {
//background: mix($primary, #fff, 20);
}
&:active {
//background: mix($primary, #fff, 40);
}
&.active {
color: #fff;
background: var(--accent);
&:hover {
background: var(--accentLighten);
border-color: var(--accentLighten);
}
&:active {
background: var(--accentDarken);
border-color: var(--accentDarken);
}
}
&.wait {
cursor: wait !important;
opacity: 0.7;
}
> span {
margin-right: 6px;
}
}
</style>

View file

@ -0,0 +1,144 @@
<template>
<router-link :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1">
<div class="banner" v-if="channel.bannerUrl" :style="`background-image: url('${channel.bannerUrl}')`">
<div class="fade"></div>
<div class="name"><fa :icon="faSatelliteDish"/> {{ channel.name }}</div>
<div class="status">
<div><fa :icon="faUsers" fixed-width/><i18n path="_channel.usersCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.usersCount }}</b></i18n></div>
<div><fa :icon="faPencilAlt" fixed-width/><i18n path="_channel.notesCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.notesCount }}</b></i18n></div>
</div>
</div>
<article v-if="channel.description">
<p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p>
</article>
<footer>
<span>
{{ $t('updatedAt') }}: <mk-time :time="channel.lastNotedAt"/>
</span>
</footer>
</router-link>
</template>
<script lang="ts">
import Vue from 'vue';
import { faSatelliteDish, faUsers, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
props: {
channel: {
type: Object,
required: true
},
},
data() {
return {
faSatelliteDish, faUsers, faPencilAlt,
};
},
});
</script>
<style lang="scss" scoped>
.eftoefju {
display: block;
overflow: hidden;
width: 100%;
border: 1px solid var(--divider);
&:hover {
text-decoration: none;
}
> .banner {
position: relative;
width: 100%;
height: 200px;
background-position: center;
background-size: cover;
> .fade {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15));
}
> .name {
position: absolute;
top: 16px;
left: 16px;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 1.2em;
}
> .status {
position: absolute;
z-index: 1;
bottom: 16px;
right: 16px;
padding: 8px 12px;
font-size: 80%;
background: rgba(0, 0, 0, 0.7);
border-radius: 6px;
color: #fff;
}
}
> article {
padding: 16px;
> p {
margin: 0;
font-size: 1em;
}
}
> footer {
padding: 12px 16px;
border-top: solid 1px var(--divider);
> span {
opacity: 0.7;
font-size: 0.9em;
}
}
@media (max-width: 550px) {
font-size: 0.9em;
> .banner {
height: 80px;
> .status {
display: none;
}
}
> article {
padding: 12px;
}
> footer {
display: none;
}
}
@media (max-width: 500px) {
font-size: 0.8em;
> .banner {
height: 70px;
}
> article {
padding: 8px;
}
}
}
</style>

View file

@ -57,6 +57,7 @@
<mk-url-preview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="detail" class="url-preview"/>
<div class="renote" v-if="appearNote.renote"><x-note-preview :note="appearNote.renote"/></div>
</div>
<router-link v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><fa :icon="faSatelliteDish"/> {{ appearNote.channel.name }}</router-link>
</div>
<footer class="footer">
<x-reactions-viewer :note="appearNote" ref="reactionsViewer"/>
@ -96,7 +97,7 @@
<script lang="ts">
import Vue from 'vue';
import { faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
import { parse } from '../../mfm/parse';
import { sum, unique } from '../../prelude/array';
@ -133,6 +134,12 @@ export default Vue.extend({
MkUrlPreview,
},
inject: {
inChannel: {
default: null
}
},
props: {
note: {
type: Object,
@ -159,7 +166,7 @@ export default Vue.extend({
isDeleted: false,
muted: false,
noteBody: this.$refs.noteBody,
faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug
faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug, faSatelliteDish
};
},
@ -954,6 +961,11 @@ export default Vue.extend({
}
}
}
> .channel {
opacity: 0.7;
font-size: 80%;
}
}
> .footer {

View file

@ -10,7 +10,7 @@
<div>
<span class="local-only" v-if="localOnly" v-text="$t('_visibility.localOnly')" />
<span class="text-count" :class="{ over: trimmedLength(text) > max }">{{ max - trimmedLength(text) }}</span>
<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$t('visibility')">
<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$t('visibility')" :disabled="channel != null">
<span v-if="visibility === 'public'"><fa :icon="faGlobe"/></span>
<span v-if="visibility === 'home'"><fa :icon="faHome"/></span>
<span v-if="visibility === 'followers'"><fa :icon="faUnlock"/></span>
@ -88,6 +88,10 @@ export default Vue.extend({
type: Object,
required: false
},
channel: {
type: Object,
required: false
},
mention: {
type: Object,
required: false
@ -140,30 +144,38 @@ export default Vue.extend({
},
computed: {
draftId(): string {
return this.renote
? `renote:${this.renote.id}`
: this.reply
? `reply:${this.reply.id}`
: 'note';
draftKey(): string {
let key = this.channel ? `channel:${this.channel.id}` : '';
if (this.renote) {
key += `renote:${this.renote.id}`;
} else if (this.reply) {
key += `reply:${this.reply.id}`;
} else {
key += 'note';
}
return key;
},
placeholder(): string {
const xs = [
this.$t('_postForm._placeholders.a'),
this.$t('_postForm._placeholders.b'),
this.$t('_postForm._placeholders.c'),
this.$t('_postForm._placeholders.d'),
this.$t('_postForm._placeholders.e'),
this.$t('_postForm._placeholders.f')
];
const x = xs[Math.floor(Math.random() * xs.length)];
return this.renote
? this.$t('_postForm.quotePlaceholder')
: this.reply
? this.$t('_postForm.replyPlaceholder')
: x;
if (this.renote) {
return this.$t('_postForm.quotePlaceholder');
} else if (this.reply) {
return this.$t('_postForm.replyPlaceholder');
} else if (this.channel) {
return this.$t('_postForm.channelPlaceholder');
} else {
const xs = [
this.$t('_postForm._placeholders.a'),
this.$t('_postForm._placeholders.b'),
this.$t('_postForm._placeholders.c'),
this.$t('_postForm._placeholders.d'),
this.$t('_postForm._placeholders.e'),
this.$t('_postForm._placeholders.f')
];
return xs[Math.floor(Math.random() * xs.length)];
}
},
submitText(): string {
@ -224,9 +236,11 @@ export default Vue.extend({
}
//
this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.visibility : this.$store.state.settings.defaultNoteVisibility);
if (this.channel == null) {
this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.visibility : this.$store.state.settings.defaultNoteVisibility);
this.localOnly = this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.localOnly : this.$store.state.settings.defaultNoteLocalOnly;
this.localOnly = this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.localOnly : this.$store.state.settings.defaultNoteLocalOnly;
}
//
if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
@ -266,7 +280,7 @@ export default Vue.extend({
this.$nextTick(() => {
// 稿
if (!this.instant && !this.mention) {
const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId];
const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey];
if (draft) {
this.text = draft.data.text;
this.useCw = draft.data.useCw;
@ -398,6 +412,10 @@ export default Vue.extend({
},
setVisibility() {
if (this.channel) {
// TODO: information dialog
return;
}
const w = this.$root.new(MkVisibilityChooser, {
source: this.$refs.visibilityButton,
currentVisibility: this.visibility,
@ -510,7 +528,7 @@ export default Vue.extend({
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
data[this.draftId] = {
data[this.draftKey] = {
updatedAt: new Date(),
data: {
text: this.text,
@ -529,7 +547,7 @@ export default Vue.extend({
deleteDraft() {
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
delete data[this.draftId];
delete data[this.draftKey];
localStorage.setItem('drafts', JSON.stringify(data));
},
@ -540,6 +558,7 @@ export default Vue.extend({
fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
replyId: this.reply ? this.reply.id : undefined,
renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined,
channelId: this.channel ? this.channel.id : undefined,
poll: this.poll ? (this.$refs.poll as any).get() : undefined,
cw: this.useCw ? this.cw || '' : undefined,
localOnly: this.localOnly,

View file

@ -24,6 +24,10 @@ export default Vue.extend({
type: String,
required: false
},
channel: {
type: String,
required: false
},
sound: {
type: Boolean,
required: false,
@ -31,6 +35,12 @@ export default Vue.extend({
}
},
provide() {
return {
inChannel: this.src === 'channel'
};
},
data() {
return {
connection: null,
@ -117,6 +127,15 @@ export default Vue.extend({
this.connection.on('note', prepend);
this.connection.on('userAdded', onUserAdded);
this.connection.on('userRemoved', onUserRemoved);
} else if (this.src == 'channel') {
endpoint = 'channels/timeline';
this.query = {
channelId: this.channel
};
this.connection = this.$root.stream.connectToChannel('channel', {
channelId: this.channel
});
this.connection.on('note', prepend);
}
this.pagination = {

View file

@ -350,6 +350,20 @@ os.init(async () => {
app.sound('antenna');
});
main.on('readAllChannels', () => {
store.dispatch('mergeMe', {
hasUnreadChannel: false
});
});
main.on('unreadChannel', () => {
store.dispatch('mergeMe', {
hasUnreadChannel: true
});
app.sound('channel');
});
main.on('readAllAnnouncements', () => {
store.dispatch('mergeMe', {
hasUnreadAnnouncement: false

View file

@ -0,0 +1,128 @@
<template>
<div>
<portal to="icon"><fa :icon="faSatelliteDish"/></portal>
<portal to="title">{{ channelId ? $t('_channel.edit') : $t('_channel.create') }}</portal>
<div class="_card">
<div class="_content">
<mk-input v-model="name">{{ $t('name') }}</mk-input>
<mk-textarea v-model="description">{{ $t('description') }}</mk-textarea>
<div class="banner">
<mk-button v-if="bannerId == null" @click="setBannerImage"><fa :icon="faPlus"/> {{ $t('_channel.setBanner') }}</mk-button>
<div v-else-if="bannerUrl">
<img :src="bannerUrl" style="width: 100%;"/>
<mk-button @click="removeBannerImage()"><fa :icon="faTrashAlt"/> {{ $t('_channel.removeBanner') }}</mk-button>
</div>
</div>
</div>
<div class="_footer">
<mk-button @click="save()" primary><fa :icon="faSave"/> {{ channelId ? $t('save') : $t('create') }}</mk-button>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faPlus, faSatelliteDish } from '@fortawesome/free-solid-svg-icons';
import { faSave, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
import MkTextarea from '../components/ui/textarea.vue';
import MkButton from '../components/ui/button.vue';
import MkInput from '../components/ui/input.vue';
import { selectFile } from '../scripts/select-file';
export default Vue.extend({
components: {
MkTextarea, MkButton, MkInput,
},
props: {
channelId: {
type: String,
required: false
},
},
data() {
return {
channel: null,
name: null,
description: null,
bannerUrl: null,
bannerId: null,
faSave, faTrashAlt, faPlus,faSatelliteDish,
};
},
watch: {
async bannerId() {
if (this.bannerId == null) {
this.bannerUrl = null;
} else {
this.bannerUrl = (await this.$root.api('drive/files/show', {
fileId: this.bannerId,
})).url;
}
},
},
async created() {
if (this.channelId) {
this.channel = await this.$root.api('channels/show', {
channelId: this.channelId,
});
this.name = this.channel.name;
this.description = this.channel.description;
this.bannerId = this.channel.bannerId;
this.bannerUrl = this.channel.bannerUrl;
}
},
methods: {
save() {
const params = {
name: this.name,
description: this.description,
bannerId: this.bannerId,
};
if (this.channelId) {
params.channelId = this.channelId;
this.$root.api('channels/update', params)
.then(channel => {
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
});
} else {
this.$root.api('channels/create', params)
.then(channel => {
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
this.$router.push(`/channels/${channel.id}`);
});
}
},
setBannerImage(e) {
selectFile(this, e.currentTarget || e.target, null, false).then(file => {
this.bannerId = file.id;
});
},
removeBannerImage() {
this.bannerId = null;
}
}
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,190 @@
<template>
<div v-if="channel">
<portal to="icon"><fa :icon="faSatelliteDish"/></portal>
<portal to="title">{{ channel.name }}</portal>
<div class="wpgynlbz _panel _vMargin" :class="{ hide: !showBanner }">
<x-channel-follow-button :channel="channel" :full="true" class="subscribe"/>
<button class="_button toggle" @click="() => showBanner = !showBanner">
<template v-if="showBanner"><fa :icon="faAngleUp"/></template>
<template v-else><fa :icon="faAngleDown"/></template>
</button>
<div class="hideOverlay" v-if="!showBanner">
</div>
<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
<div class="status">
<div><fa :icon="faUsers" fixed-width/><i18n path="_channel.usersCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.usersCount }}</b></i18n></div>
<div><fa :icon="faPencilAlt" fixed-width/><i18n path="_channel.notesCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.notesCount }}</b></i18n></div>
</div>
<div class="fade"></div>
</div>
<div class="description" v-if="channel.description">
<mfm :text="channel.description" :is-note="false" :i="$store.state.i"/>
</div>
</div>
<x-post-form :channel="channel" class="post-form _panel _vMargin" fixed/>
<x-timeline class="_vMargin" src="channel" :channel="channelId" @before="before" @after="after"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faSatelliteDish, faUsers, faPencilAlt, faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons';
import { } from '@fortawesome/free-regular-svg-icons';
import MkContainer from '../components/ui/container.vue';
import XPostForm from '../components/post-form.vue';
import XTimeline from '../components/timeline.vue';
import XChannelFollowButton from '../components/channel-follow-button.vue';
export default Vue.extend({
metaInfo() {
return {
title: this.$t('channel') as string
};
},
components: {
MkContainer,
XPostForm,
XTimeline,
XChannelFollowButton
},
props: {
channelId: {
type: String,
required: true
}
},
data() {
return {
channel: null,
showBanner: true,
pagination: {
endpoint: 'channels/timeline',
limit: 10,
params: () => ({
channelId: this.channelId,
})
},
faSatelliteDish, faUsers, faPencilAlt, faAngleUp, faAngleDown,
};
},
watch: {
channelId: {
async handler() {
this.channel = await this.$root.api('channels/show', {
channelId: this.channelId,
});
},
immediate: true
}
},
created() {
},
});
</script>
<style lang="scss" scoped>
.wpgynlbz {
> .subscribe {
position: absolute;
z-index: 1;
top: 16px;
left: 16px;
}
> .toggle {
position: absolute;
z-index: 2;
top: 8px;
right: 8px;
font-size: 1.2em;
width: 48px;
height: 48px;
color: #fff;
background: rgba(0, 0, 0, 0.5);
border-radius: 100%;
> [data-icon] {
vertical-align: middle;
}
}
> .banner {
position: relative;
height: 200px;
background-position: center;
background-size: cover;
> .fade {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15));
}
> .status {
position: absolute;
z-index: 1;
bottom: 16px;
right: 16px;
padding: 8px 12px;
font-size: 80%;
background: rgba(0, 0, 0, 0.7);
border-radius: 6px;
color: #fff;
}
}
> .description {
padding: 16px;
}
> .hideOverlay {
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
background: rgba(0, 0, 0, 0.3);
}
&.hide {
> .subscribe {
display: none;
}
> .toggle {
top: 0;
right: 0;
height: 100%;
background: transparent;
}
> .banner {
height: 42px;
filter: blur(8px);
> * {
display: none;
}
}
> .description {
display: none;
}
}
}
</style>

View file

@ -0,0 +1,86 @@
<template>
<div>
<portal to="icon"><fa :icon="faSatelliteDish"/></portal>
<portal to="title">{{ $t('channel') }}</portal>
<mk-tab v-model="tab" :items="[{ label: $t('_channel.featured'), value: 'featured', icon: faFireAlt }, { label: $t('_channel.following'), value: 'following', icon: faHeart }, { label: $t('_channel.owned'), value: 'owned', icon: faEdit }]"/>
<div class="grwlizim featured" v-if="tab === 'featured'">
<mk-pagination :pagination="featuredPagination" #default="{items}">
<mk-channel-preview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/>
</mk-pagination>
</div>
<div class="grwlizim following" v-if="tab === 'following'">
<mk-pagination :pagination="followingPagination" #default="{items}">
<mk-channel-preview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/>
</mk-pagination>
</div>
<div class="grwlizim owned" v-if="tab === 'owned'">
<mk-button class="new" @click="create()"><fa :icon="faPlus"/></mk-button>
<mk-pagination :pagination="ownedPagination" #default="{items}">
<mk-channel-preview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/>
</mk-pagination>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faSatelliteDish, faPlus, faEdit, faFireAlt } from '@fortawesome/free-solid-svg-icons';
import { faHeart } from '@fortawesome/free-regular-svg-icons';
import MkChannelPreview from '../components/channel-preview.vue';
import MkPagination from '../components/ui/pagination.vue';
import MkButton from '../components/ui/button.vue';
import MkTab from '../components/tab.vue';
export default Vue.extend({
components: {
MkChannelPreview, MkPagination, MkButton, MkTab
},
data() {
return {
tab: 'featured',
featuredPagination: {
endpoint: 'channels/featured',
limit: 5,
},
followingPagination: {
endpoint: 'channels/followed',
limit: 5,
},
ownedPagination: {
endpoint: 'channels/owned',
limit: 5,
},
faSatelliteDish, faPlus, faEdit, faHeart, faFireAlt
};
},
methods: {
create() {
this.$router.push(`/channels/new`);
}
}
});
</script>
<style lang="scss" scoped>
.grwlizim {
padding: 16px 0;
&.my .uveselbe:first-child {
margin-top: 16px;
}
.uveselbe:not(:last-child) {
margin-bottom: 8px;
}
@media (min-width: 500px) {
.uveselbe:not(:last-child) {
margin-bottom: 16px;
}
}
}
</style>

View file

@ -2,14 +2,15 @@
<div class="mk-home" v-hotkey.global="keymap">
<portal to="header" v-if="showTitle">
<button @click="choose" class="_button _kjvfvyph_">
<i><fa v-if="$store.state.i.hasUnreadAntenna" :icon="faCircle"/></i>
<i><fa v-if="$store.state.i.hasUnreadAntenna || $store.state.i.hasUnreadChannel" :icon="faCircle"/></i>
<fa v-if="src === 'home'" :icon="faHome"/>
<fa v-if="src === 'local'" :icon="faComments"/>
<fa v-if="src === 'social'" :icon="faShareAlt"/>
<fa v-if="src === 'global'" :icon="faGlobe"/>
<fa v-if="src === 'list'" :icon="faListUl"/>
<fa v-if="src === 'antenna'" :icon="faSatellite"/>
<span style="margin-left: 8px;">{{ src === 'list' ? list.name : src === 'antenna' ? antenna.name : $t('_timelines.' + src) }}</span>
<fa v-if="src === 'channel'" :icon="faSatelliteDish"/>
<span style="margin-left: 8px;">{{ src === 'list' ? list.name : src === 'antenna' ? antenna.name : src === 'channel' ? channel.name : $t('_timelines.' + src) }}</span>
<fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/>
</button>
</portal>
@ -19,13 +20,13 @@
<x-tutorial class="tutorial" v-if="$store.state.settings.tutorial != -1"/>
<x-post-form class="post-form _panel" fixed v-if="$store.state.device.showFixedPostForm"/>
<x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src" :src="src" :list="list ? list.id : null" :antenna="antenna ? antenna.id : null" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/>
<x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src === 'channel' ? `channel:${channel.id}` : src" :src="src" :list="list ? list.id : null" :antenna="antenna ? antenna.id : null" :channel="channel ? channel.id : null" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite, faCircle } from '@fortawesome/free-solid-svg-icons';
import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite, faSatelliteDish, faCircle } from '@fortawesome/free-solid-svg-icons';
import { faComments } from '@fortawesome/free-regular-svg-icons';
import Progress from '../scripts/loading';
import XTimeline from '../components/timeline.vue';
@ -57,10 +58,11 @@ export default Vue.extend({
src: 'home',
list: null,
antenna: null,
channel: null,
menuOpened: false,
queue: 0,
width: 0,
faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite, faCircle
faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite, faSatelliteDish, faCircle
};
},
@ -79,16 +81,20 @@ export default Vue.extend({
watch: {
src() {
this.showNav = false;
this.saveSrc();
},
list(x) {
this.showNav = false;
this.saveSrc();
if (x != null) this.antenna = null;
if (x != null) this.channel = null;
},
antenna(x) {
this.showNav = false;
this.saveSrc();
if (x != null) this.list = null;
if (x != null) this.channel = null;
},
channel(x) {
this.showNav = false;
if (x != null) this.antenna = null;
if (x != null) this.list = null;
},
},
@ -99,6 +105,8 @@ export default Vue.extend({
this.list = this.$store.state.deviceUser.tl.arg;
} else if (this.src === 'antenna') {
this.antenna = this.$store.state.deviceUser.tl.arg;
} else if (this.src === 'channel') {
this.channel = this.$store.state.deviceUser.tl.arg;
}
},
@ -127,9 +135,10 @@ export default Vue.extend({
async choose(ev) {
if (this.meta == null) return;
this.menuOpened = true;
const [antennas, lists] = await Promise.all([
const [antennas, lists, channels] = await Promise.all([
this.$root.api('antennas/list'),
this.$root.api('users/lists/list')
this.$root.api('users/lists/list'),
this.$root.api('channels/followed'),
]);
const antennaItems = antennas.map(antenna => ({
text: antenna.name,
@ -137,7 +146,8 @@ export default Vue.extend({
indicate: antenna.hasUnreadNote,
action: () => {
this.antenna = antenna;
this.setSrc('antenna');
this.src = 'antenna';
this.saveSrc();
}
}));
const listItems = lists.map(list => ({
@ -145,27 +155,40 @@ export default Vue.extend({
icon: faListUl,
action: () => {
this.list = list;
this.setSrc('list');
this.src = 'list';
this.saveSrc();
}
}));
const channelItems = channels.map(channel => ({
text: channel.name,
icon: faSatelliteDish,
indicate: channel.hasUnreadNote,
action: () => {
// NOTE: 稿
//this.channel = channel;
//this.src = 'channel';
//this.saveSrc();
this.$router.push(`/channels/${channel.id}`);
}
}));
this.$root.menu({
items: [{
text: this.$t('_timelines.home'),
icon: faHome,
action: () => { this.setSrc('home') }
action: () => { this.src = 'home'; this.saveSrc(); }
}, this.meta.disableLocalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : {
text: this.$t('_timelines.local'),
icon: faComments,
action: () => { this.setSrc('local') }
action: () => { this.src = 'local'; this.saveSrc(); }
}, this.meta.disableLocalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : {
text: this.$t('_timelines.social'),
icon: faShareAlt,
action: () => { this.setSrc('social') }
action: () => { this.src = 'social'; this.saveSrc(); }
}, this.meta.disableGlobalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : {
text: this.$t('_timelines.global'),
icon: faGlobe,
action: () => { this.setSrc('global') }
}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems],
action: () => { this.src = 'global'; this.saveSrc(); }
}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems, channelItems.length > 0 ? null : undefined, ...channelItems],
fixed: true,
noCenter: true,
source: ev.currentTarget || ev.target
@ -174,14 +197,13 @@ export default Vue.extend({
});
},
setSrc(src) {
this.src = src;
},
saveSrc() {
this.$store.commit('deviceUser/setTl', {
src: this.src,
arg: this.src == 'list' ? this.list : this.antenna
arg:
this.src === 'list' ? this.list :
this.src === 'antenna' ? this.antenna :
this.channel
});
},

View file

@ -53,7 +53,7 @@ export default Vue.extend({
};
},
computed: {
draftId(): string {
draftKey(): string {
return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
},
canSend(): boolean {
@ -79,7 +79,7 @@ export default Vue.extend({
autosize(this.$refs.text);
// 稿
const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftId];
const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey];
if (draft) {
this.text = draft.data.text;
this.file = draft.data.file;
@ -199,7 +199,7 @@ export default Vue.extend({
saveDraft() {
const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
data[this.draftId] = {
data[this.draftKey] = {
updatedAt: new Date(),
data: {
text: this.text,
@ -213,7 +213,7 @@ export default Vue.extend({
deleteDraft() {
const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
delete data[this.draftId];
delete data[this.draftKey];
localStorage.setItem('message_drafts', JSON.stringify(data));
},

View file

@ -3,11 +3,11 @@
<portal to="icon"><fa :icon="faCog"/></portal>
<portal to="title">{{ $t('accountSettings') }}</portal>
<x-profile-setting/>
<x-privacy-setting/>
<x-reaction-setting/>
<x-profile-setting class="_vMargin"/>
<x-privacy-setting class="_vMargin"/>
<x-reaction-setting class="_vMargin"/>
<section class="_card">
<section class="_card _vMargin">
<div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div>
<div class="_content">
<mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch">
@ -24,14 +24,14 @@
</div>
</section>
<x-import-export/>
<x-drive/>
<x-mute-block/>
<x-word-mute/>
<x-security/>
<x-2fa/>
<x-integration/>
<x-api/>
<x-import-export class="_vMargin"/>
<x-drive class="_vMargin"/>
<x-mute-block class="_vMargin"/>
<x-word-mute class="_vMargin"/>
<x-security class="_vMargin"/>
<x-2fa class="_vMargin"/>
<x-integration class="_vMargin"/>
<x-api class="_vMargin"/>
<router-link class="_panel _buttonPrimary" to="/my/apps" style="margin: var(--margin) auto;">{{ $t('installedApps') }}</router-link>

View file

@ -5,11 +5,11 @@
<router-link v-if="$store.getters.isSignedIn" class="_panel _buttonPrimary" to="/my/settings" style="margin-bottom: var(--margin);">{{ $t('accountSettings') }}</router-link>
<x-theme/>
<x-theme class="_vMargin"/>
<x-sidebar/>
<x-sidebar class="_vMargin"/>
<x-plugins/>
<x-plugins class="_vMargin"/>
<section class="_card _vMargin">
<div class="_title"><fa :icon="faMusic"/> {{ $t('sounds') }}</div>
@ -50,6 +50,11 @@
<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
<template #text><button class="_textButton" @click="listen(sfxAntenna)" v-if="sfxAntenna"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
</mk-select>
<mk-select v-model="sfxChannel">
<template #label>{{ $t('_sfx.channel') }}</template>
<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
<template #text><button class="_textButton" @click="listen(sfxChannel)" v-if="sfxChannel"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
</mk-select>
</div>
</section>
@ -142,10 +147,14 @@ const sounds = [
'syuilo/pirori',
'syuilo/pirori-wet',
'syuilo/pirori-square-wet',
'syuilo/square-pico',
'syuilo/reverved',
'syuilo/ryukyu',
'aisha/1',
'aisha/2',
'aisha/3',
'noizenecio/kick_gaba',
'noizenecio/kick_gaba2',
];
export default Vue.extend({
@ -272,6 +281,11 @@ export default Vue.extend({
set(value) { this.$store.commit('device/set', { key: 'sfxAntenna', value }); }
},
sfxChannel: {
get() { return this.$store.state.device.sfxChannel; },
set(value) { this.$store.commit('device/set', { key: 'sfxChannel', value }); }
},
volumeIcon: {
get() {
return this.sfxVolume === 0 ? faVolumeMute : faVolumeUp;

View file

@ -29,6 +29,10 @@ export const router = new VueRouter({
{ path: '/explore', component: page('explore') },
{ path: '/explore/tags/:tag', props: true, component: page('explore') },
{ path: '/search', component: page('search') },
{ path: '/channels', component: page('channels') },
{ path: '/channels/new', component: page('channel-editor') },
{ path: '/channels/:channelId/edit', component: page('channel-editor'), props: true },
{ path: '/channels/:channelId', component: page('channel'), props: true },
{ path: '/my/notifications', component: page('notifications') },
{ path: '/my/favorites', component: page('favorites') },
{ path: '/my/messages', component: page('messages') },

View file

@ -1,7 +1,7 @@
import Vuex from 'vuex';
import createPersistedState from 'vuex-persistedstate';
import * as nestedProperty from 'nested-property';
import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons';
import { faSatelliteDish, faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons';
import { faBell, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons';
import { AiScript, utils, values } from '@syuilo/aiscript';
import { apiUrl, deckmode } from './config';
@ -90,6 +90,7 @@ export const defaultDeviceSettings = {
sfxChat: 'syuilo/pope1',
sfxChatBg: 'syuilo/waon',
sfxAntenna: 'syuilo/triple',
sfxChannel: 'syuilo/square-pico',
userData: {},
};
@ -213,6 +214,11 @@ export default () => new Vuex.Store({
get show() { return getters.isSignedIn; },
to: '/my/pages',
},
channels: {
title: 'channel',
icon: faSatelliteDish,
to: '/channels',
},
games: {
title: 'games',
icon: faGamepad,

View file

@ -72,5 +72,6 @@
X12: 'rgba(255, 255, 255, 0.1)',
X13: 'rgba(255, 255, 255, 0.15)',
X14: ':alpha<0.5<@navBg',
X15: ':alpha<0<@panel',
},
}

View file

@ -72,5 +72,6 @@
X12: 'rgba(0, 0, 0, 0.1)',
X13: 'rgba(0, 0, 0, 0.15)',
X14: ':alpha<0.5<@navBg',
X15: ':alpha<0<@panel',
},
}

View file

@ -38,7 +38,7 @@ import { FollowRequest } from '../models/entities/follow-request';
import { Emoji } from '../models/entities/emoji';
import { ReversiGame } from '../models/entities/games/reversi/game';
import { ReversiMatching } from '../models/entities/games/reversi/matching';
import { UserNotePining } from '../models/entities/user-note-pinings';
import { UserNotePining } from '../models/entities/user-note-pining';
import { Poll } from '../models/entities/poll';
import { UserKeypair } from '../models/entities/user-keypair';
import { UserPublickey } from '../models/entities/user-publickey';
@ -60,6 +60,9 @@ import { PromoRead } from '../models/entities/promo-read';
import { program } from '../argv';
import { Relay } from '../models/entities/relay';
import { MutedNote } from '../models/entities/muted-note';
import { Channel } from '../models/entities/channel';
import { ChannelFollowing } from '../models/entities/channel-following';
import { ChannelNotePining } from '../models/entities/channel-note-pining';
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
@ -153,6 +156,9 @@ export const entities = [
ReversiMatching,
Relay,
MutedNote,
Channel,
ChannelFollowing,
ChannelNotePining,
...charts as any
];

View file

@ -25,4 +25,6 @@ export const kinds = [
'read:page-likes',
'read:user-groups',
'write:user-groups',
'read:channels',
'write:channels',
];

View file

@ -0,0 +1,43 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { User } from './user';
import { id } from '../id';
import { Channel } from './channel';
@Entity()
@Index(['followerId', 'followeeId'], { unique: true })
export class ChannelFollowing {
@PrimaryColumn(id())
public id: string;
@Index()
@Column('timestamp with time zone', {
comment: 'The created date of the ChannelFollowing.'
})
public createdAt: Date;
@Index()
@Column({
...id(),
comment: 'The followee channel ID.'
})
public followeeId: Channel['id'];
@ManyToOne(type => Channel, {
onDelete: 'CASCADE'
})
@JoinColumn()
public followee: Channel | null;
@Index()
@Column({
...id(),
comment: 'The follower user ID.'
})
public followerId: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE'
})
@JoinColumn()
public follower: User | null;
}

View file

@ -0,0 +1,35 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { Note } from './note';
import { Channel } from './channel';
import { id } from '../id';
@Entity()
@Index(['channelId', 'noteId'], { unique: true })
export class ChannelNotePining {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone', {
comment: 'The created date of the ChannelNotePining.'
})
public createdAt: Date;
@Index()
@Column(id())
public channelId: Channel['id'];
@ManyToOne(type => Channel, {
onDelete: 'CASCADE'
})
@JoinColumn()
public channel: Channel | null;
@Column(id())
public noteId: Note['id'];
@ManyToOne(type => Note, {
onDelete: 'CASCADE'
})
@JoinColumn()
public note: Note | null;
}

View file

@ -0,0 +1,74 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { User } from './user';
import { id } from '../id';
import { DriveFile } from './drive-file';
@Entity()
export class Channel {
@PrimaryColumn(id())
public id: string;
@Index()
@Column('timestamp with time zone', {
comment: 'The created date of the Channel.'
})
public createdAt: Date;
@Index()
@Column('timestamp with time zone', {
nullable: true
})
public lastNotedAt: Date | null;
@Index()
@Column({
...id(),
comment: 'The owner ID.'
})
public userId: User['id'];
@ManyToOne(type => User, {
onDelete: 'SET NULL'
})
@JoinColumn()
public user: User | null;
@Column('varchar', {
length: 128,
comment: 'The name of the Channel.'
})
public name: string;
@Column('varchar', {
length: 2048, nullable: true,
comment: 'The description of the Channel.'
})
public description: string | null;
@Column({
...id(),
nullable: true,
comment: 'The ID of banner Channel.'
})
public bannerId: DriveFile['id'] | null;
@ManyToOne(type => DriveFile, {
onDelete: 'SET NULL'
})
@JoinColumn()
public banner: DriveFile | null;
@Index()
@Column('integer', {
default: 0,
comment: 'The count of notes.'
})
public notesCount: number;
@Index()
@Column('integer', {
default: 0,
comment: 'The count of users.'
})
public usersCount: number;
}

View file

@ -2,6 +2,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typ
import { User } from './user';
import { Note } from './note';
import { id } from '../id';
import { Channel } from './channel';
@Entity()
@Index(['userId', 'noteId'], { unique: true })
@ -29,15 +30,34 @@ export class NoteUnread {
@JoinColumn()
public note: Note | null;
/**
*
*/
@Index()
@Column('boolean')
public isMentioned: boolean;
/**
* 稿
*/
@Index()
@Column('boolean')
public isSpecified: boolean;
//#region Denormalized fields
@Index()
@Column({
...id(),
comment: '[Denormalized]'
})
public noteUserId: User['id'];
/**
* 稿
*/
@Column('boolean')
public isSpecified: boolean;
@Index()
@Column({
...id(),
nullable: true,
comment: '[Denormalized]'
})
public noteChannelId: Channel['id'] | null;
//#endregion
}

View file

@ -3,7 +3,7 @@ import { User } from './user';
import { DriveFile } from './drive-file';
import { id } from '../id';
import { noteVisibilities } from '../../types';
import { Channel } from './channel';
@Entity()
@Index('IDX_NOTE_TAGS', { synchronize: false })
@ -173,6 +173,20 @@ export class Note {
})
public hasPoll: boolean;
@Index()
@Column({
...id(),
nullable: true, default: null,
comment: 'The ID of source channel.'
})
public channelId: Channel['id'] | null;
@ManyToOne(type => Channel, {
onDelete: 'CASCADE'
})
@JoinColumn()
public channel: Channel | null;
//#region Denormalized fields
@Index()
@Column('varchar', {

View file

@ -15,7 +15,7 @@ import { DriveFileRepository } from './repositories/drive-file';
import { DriveFolderRepository } from './repositories/drive-folder';
import { Log } from './entities/log';
import { AccessToken } from './entities/access-token';
import { UserNotePining } from './entities/user-note-pinings';
import { UserNotePining } from './entities/user-note-pining';
import { SigninRepository } from './repositories/signin';
import { MessagingMessageRepository } from './repositories/messaging-message';
import { ReversiGameRepository } from './repositories/games/reversi/game';
@ -53,7 +53,10 @@ import { PromoNote } from './entities/promo-note';
import { PromoRead } from './entities/promo-read';
import { EmojiRepository } from './repositories/emoji';
import { RelayRepository } from './repositories/relay';
import { ChannelRepository } from './repositories/channel';
import { MutedNote } from './entities/muted-note';
import { ChannelFollowing } from './entities/channel-following';
import { ChannelNotePining } from './entities/channel-note-pining';
export const Announcements = getRepository(Announcement);
export const AnnouncementReads = getRepository(AnnouncementRead);
@ -110,3 +113,6 @@ export const PromoNotes = getRepository(PromoNote);
export const PromoReads = getRepository(PromoRead);
export const Relays = getCustomRepository(RelayRepository);
export const MutedNotes = getRepository(MutedNote);
export const Channels = getCustomRepository(ChannelRepository);
export const ChannelFollowings = getRepository(ChannelFollowing);
export const ChannelNotePinings = getRepository(ChannelNotePining);

View file

@ -0,0 +1,101 @@
import { EntityRepository, Repository } from 'typeorm';
import { Channel } from '../entities/channel';
import { ensure } from '../../prelude/ensure';
import { SchemaType } from '../../misc/schema';
import { DriveFiles, ChannelFollowings, NoteUnreads } from '..';
import { User } from '../entities/user';
export type PackedChannel = SchemaType<typeof packedChannelSchema>;
@EntityRepository(Channel)
export class ChannelRepository extends Repository<Channel> {
public async pack(
src: Channel['id'] | Channel,
me?: User['id'] | User | null | undefined,
): Promise<PackedChannel> {
const channel = typeof src === 'object' ? src : await this.findOne(src).then(ensure);
const meId = me ? typeof me === 'string' ? me : me.id : null;
const banner = channel.bannerId ? await DriveFiles.findOne(channel.bannerId) : null;
const hasUnreadNote = me ? (await NoteUnreads.findOne({ noteChannelId: channel.id, userId: meId })) != null : undefined;
const following = await ChannelFollowings.findOne({
followerId: meId,
followeeId: channel.id,
});
return {
id: channel.id,
createdAt: channel.createdAt.toISOString(),
lastNotedAt: channel.lastNotedAt ? channel.lastNotedAt.toISOString() : null,
name: channel.name,
description: channel.description,
userId: channel.userId,
bannerUrl: banner ? DriveFiles.getPublicUrl(banner, false) : null,
usersCount: channel.usersCount,
notesCount: channel.notesCount,
...(me ? {
isFollowing: following != null,
hasUnreadNote,
} : {})
};
}
}
export const packedChannelSchema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
id: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
description: 'The unique identifier for this Channel.',
example: 'xxxxxxxxxx',
},
createdAt: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'date-time',
description: 'The date that the Channel was created.'
},
lastNotedAt: {
type: 'string' as const,
optional: false as const, nullable: true as const,
format: 'date-time',
},
name: {
type: 'string' as const,
optional: false as const, nullable: false as const,
description: 'The name of the Channel.'
},
description: {
type: 'string' as const,
nullable: true as const, optional: false as const,
},
bannerUrl: {
type: 'string' as const,
format: 'url',
nullable: true as const, optional: false as const,
},
notesCount: {
type: 'number' as const,
nullable: false as const, optional: false as const,
},
usersCount: {
type: 'number' as const,
nullable: false as const, optional: false as const,
},
isFollowing: {
type: 'boolean' as const,
optional: true as const, nullable: false as const,
},
userId: {
type: 'string' as const,
nullable: false as const, optional: false as const,
format: 'id',
},
},
};

View file

@ -1,7 +1,7 @@
import { EntityRepository, Repository, In } from 'typeorm';
import { Note } from '../entities/note';
import { User } from '../entities/user';
import { Emojis, Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls } from '..';
import { Emojis, Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls, Channels } from '..';
import { ensure } from '../../prelude/ensure';
import { SchemaType } from '../../misc/schema';
import { awaitAll } from '../../prelude/await-all';
@ -207,6 +207,12 @@ export class NoteRepository extends Repository<Note> {
text = `${note.name}\n${(note.text || '').trim()}\n\n${note.url || note.uri}`;
}
const channel = note.channelId
? note.channel
? note.channel
: await Channels.findOne(note.channelId)
: null;
const packed = await awaitAll({
id: note.id,
createdAt: note.createdAt.toISOString(),
@ -227,6 +233,11 @@ export class NoteRepository extends Repository<Note> {
files: DriveFiles.packMany(note.fileIds),
replyId: note.replyId,
renoteId: note.renoteId,
channelId: note.channelId || undefined,
channel: channel ? {
id: channel.id,
name: channel.name,
} : undefined,
mentions: note.mentions.length > 0 ? note.mentions : undefined,
uri: note.uri || undefined,
url: note.url || undefined,
@ -391,6 +402,16 @@ export const packedNoteSchema = {
type: 'object' as const,
optional: true as const, nullable: true as const,
},
channelId: {
type: 'string' as const,
optional: true as const, nullable: true as const,
format: 'id',
example: 'xxxxxxxxxx',
},
channel: {
type: 'object' as const,
optional: true as const, nullable: true as const,
ref: 'Channel'
},
},
};

View file

@ -1,7 +1,7 @@
import $ from 'cafy';
import { EntityRepository, Repository, In, Not } from 'typeorm';
import { User, ILocalUser, IRemoteUser } from '../entities/user';
import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes } from '..';
import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes, ChannelFollowings } from '..';
import { ensure } from '../../prelude/ensure';
import config from '../../config';
import { SchemaType } from '../../misc/schema';
@ -107,6 +107,17 @@ export class UserRepository extends Repository<User> {
return unread != null;
}
public async getHasUnreadChannel(userId: User['id']): Promise<boolean> {
const channels = await ChannelFollowings.find({ followerId: userId });
const unread = channels.length > 0 ? await NoteUnreads.findOne({
userId: userId,
noteChannelId: In(channels.map(x => x.id)),
}) : null;
return unread != null;
}
public async getHasUnreadNotification(userId: User['id']): Promise<boolean> {
const mute = await Mutings.find({
muterId: userId
@ -139,7 +150,6 @@ export class UserRepository extends Repository<User> {
options?: {
detail?: boolean,
includeSecrets?: boolean,
includeHasUnreadNotes?: boolean
}
): Promise<PackedUser> {
const opts = Object.assign({
@ -181,17 +191,6 @@ export class UserRepository extends Repository<User> {
select: ['name', 'host', 'url', 'aliases']
}) : [],
...(opts.includeHasUnreadNotes ? {
hasUnreadSpecifiedNotes: NoteUnreads.count({
where: { userId: user.id, isSpecified: true },
take: 1
}).then(count => count > 0),
hasUnreadMentions: NoteUnreads.count({
where: { userId: user.id },
take: 1
}).then(count => count > 0),
} : {}),
...(opts.detail ? {
url: profile!.url,
createdAt: user.createdAt.toISOString(),
@ -233,8 +232,17 @@ export class UserRepository extends Repository<User> {
alwaysMarkNsfw: profile!.alwaysMarkNsfw,
carefulBot: profile!.carefulBot,
autoAcceptFollowed: profile!.autoAcceptFollowed,
hasUnreadSpecifiedNotes: NoteUnreads.count({
where: { userId: user.id, isSpecified: true },
take: 1
}).then(count => count > 0),
hasUnreadMentions: NoteUnreads.count({
where: { userId: user.id, isMentioned: true },
take: 1
}).then(count => count > 0),
hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id),
hasUnreadAntenna: this.getHasUnreadAntenna(user.id),
hasUnreadChannel: this.getHasUnreadChannel(user.id),
hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id),
hasUnreadNotification: this.getHasUnreadNotification(user.id),
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
@ -276,7 +284,6 @@ export class UserRepository extends Repository<User> {
options?: {
detail?: boolean,
includeSecrets?: boolean,
includeHasUnreadNotes?: boolean
}
) {
return Promise.all(users.map(u => this.pack(u, me, options)));

View file

@ -15,7 +15,7 @@ import { updateUsertags } from '../../../services/update-hashtag';
import { Users, UserNotePinings, Instances, DriveFiles, Followings, UserProfiles, UserPublickeys } from '../../../models';
import { User, IRemoteUser } from '../../../models/entities/user';
import { Emoji } from '../../../models/entities/emoji';
import { UserNotePining } from '../../../models/entities/user-note-pinings';
import { UserNotePining } from '../../../models/entities/user-note-pining';
import { genId } from '../../../misc/gen-id';
import { instanceChart, usersChart } from '../../../services/chart';
import { UserPublickey } from '../../../models/entities/user-publickey';

View file

@ -0,0 +1,24 @@
import { User } from '../../../models/entities/user';
import { ChannelFollowings } from '../../../models';
import { Brackets, SelectQueryBuilder } from 'typeorm';
export function generateChannelQuery(q: SelectQueryBuilder<any>, me?: User | null) {
if (me == null) {
q.andWhere('note.channelId IS NULL');
} else {
q.leftJoinAndSelect('note.channel', 'channel');
const channelFollowingQuery = ChannelFollowings.createQueryBuilder('channelFollowing')
.select('channelFollowing.followeeId')
.where('channelFollowing.followerId = :followerId', { followerId: me.id });
q.andWhere(new Brackets(qb => { qb
// チャンネルのノートではない
.where('note.channelId IS NULL')
// または自分がフォローしているチャンネルのノート
.orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`);
}));
q.setParameters(channelFollowingQuery.getParameters());
}
}

View file

@ -0,0 +1,68 @@
import $ from 'cafy';
import define from '../../define';
import { ApiError } from '../../error';
import { Channels, DriveFiles } from '../../../../models';
import { Channel } from '../../../../models/entities/channel';
import { genId } from '../../../../misc/gen-id';
import { ID } from '../../../../misc/cafy-id';
export const meta = {
tags: ['channels'],
requireCredential: true as const,
kind: 'write:channels',
params: {
name: {
validator: $.str.range(1, 128)
},
description: {
validator: $.nullable.optional.str.range(1, 2048)
},
bannerId: {
validator: $.nullable.optional.type(ID),
}
},
res: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'Channel',
},
errors: {
noSuchFile: {
message: 'No such file.',
code: 'NO_SUCH_FILE',
id: 'cd1e9f3e-5a12-4ab4-96f6-5d0a2cc32050'
},
}
};
export default define(meta, async (ps, user) => {
let banner = null;
if (ps.bannerId != null) {
banner = await DriveFiles.findOne({
id: ps.bannerId,
userId: user.id
});
if (banner == null) {
throw new ApiError(meta.errors.noSuchFile);
}
}
const channel = await Channels.save({
id: genId(),
createdAt: new Date(),
userId: user.id,
name: ps.name,
description: ps.description || null,
bannerId: banner ? banner.id : null,
} as Channel);
return await Channels.pack(channel, user);
});

View file

@ -0,0 +1,28 @@
import define from '../../define';
import { Channels } from '../../../../models';
export const meta = {
tags: ['channels'],
requireCredential: false as const,
res: {
type: 'array' as const,
optional: false as const, nullable: false as const,
items: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'Channel',
}
},
};
export default define(meta, async (ps, me) => {
const query = Channels.createQueryBuilder('channel')
.where('channel.lastNotedAt IS NOT NULL')
.orderBy('channel.lastNotedAt', 'DESC');
const channels = await query.take(10).getMany();
return await Promise.all(channels.map(x => Channels.pack(x, me)));
});

View file

@ -0,0 +1,45 @@
import $ from 'cafy';
import { ID } from '../../../../misc/cafy-id';
import define from '../../define';
import { ApiError } from '../../error';
import { Channels, ChannelFollowings } from '../../../../models';
import { genId } from '../../../../misc/gen-id';
export const meta = {
tags: ['channels'],
requireCredential: true as const,
kind: 'write:channels',
params: {
channelId: {
validator: $.type(ID),
},
},
errors: {
noSuchChannel: {
message: 'No such channel.',
code: 'NO_SUCH_CHANNEL',
id: 'c0031718-d573-4e85-928e-10039f1fbb68'
},
}
};
export default define(meta, async (ps, user) => {
const channel = await Channels.findOne({
id: ps.channelId,
});
if (channel == null) {
throw new ApiError(meta.errors.noSuchChannel);
}
await ChannelFollowings.save({
id: genId(),
createdAt: new Date(),
followerId: user.id,
followeeId: channel.id,
});
});

View file

@ -0,0 +1,28 @@
import define from '../../define';
import { Channels, ChannelFollowings } from '../../../../models';
export const meta = {
tags: ['channels', 'account'],
requireCredential: true as const,
kind: 'read:channels',
res: {
type: 'array' as const,
optional: false as const, nullable: false as const,
items: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'Channel',
}
},
};
export default define(meta, async (ps, me) => {
const followings = await ChannelFollowings.find({
followerId: me.id,
});
return await Promise.all(followings.map(x => Channels.pack(x.followeeId, me)));
});

View file

@ -0,0 +1,28 @@
import define from '../../define';
import { Channels } from '../../../../models';
export const meta = {
tags: ['channels', 'account'],
requireCredential: true as const,
kind: 'read:channels',
res: {
type: 'array' as const,
optional: false as const, nullable: false as const,
items: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'Channel',
}
},
};
export default define(meta, async (ps, me) => {
const channels = await Channels.find({
userId: me.id,
});
return await Promise.all(channels.map(x => Channels.pack(x, me)));
});

View file

@ -0,0 +1,43 @@
import $ from 'cafy';
import { ID } from '../../../../misc/cafy-id';
import define from '../../define';
import { ApiError } from '../../error';
import { Channels } from '../../../../models';
export const meta = {
tags: ['channels'],
requireCredential: false as const,
params: {
channelId: {
validator: $.type(ID),
},
},
res: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'Channel',
},
errors: {
noSuchChannel: {
message: 'No such channel.',
code: 'NO_SUCH_CHANNEL',
id: '6f6c314b-7486-4897-8966-c04a66a02923'
},
}
};
export default define(meta, async (ps, me) => {
const channel = await Channels.findOne({
id: ps.channelId,
});
if (channel == null) {
throw new ApiError(meta.errors.noSuchChannel);
}
return await Channels.pack(channel, me);
});

View file

@ -0,0 +1,99 @@
import $ from 'cafy';
import { ID } from '../../../../misc/cafy-id';
import define from '../../define';
import { ApiError } from '../../error';
import { Notes, Channels } from '../../../../models';
import { makePaginationQuery } from '../../common/make-pagination-query';
import { activeUsersChart } from '../../../../services/chart';
export const meta = {
tags: ['notes', 'channels'],
requireCredential: false as const,
params: {
channelId: {
validator: $.type(ID),
desc: {
'ja-JP': 'チャンネルのID'
}
},
limit: {
validator: $.optional.num.range(1, 100),
default: 10,
desc: {
'ja-JP': '最大数'
}
},
sinceId: {
validator: $.optional.type(ID),
desc: {
'ja-JP': '指定すると、その投稿を基点としてより新しい投稿を取得します'
}
},
untilId: {
validator: $.optional.type(ID),
desc: {
'ja-JP': '指定すると、その投稿を基点としてより古い投稿を取得します'
}
},
sinceDate: {
validator: $.optional.num,
desc: {
'ja-JP': '指定した時間を基点としてより新しい投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。'
}
},
untilDate: {
validator: $.optional.num,
desc: {
'ja-JP': '指定した時間を基点としてより古い投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。'
}
},
},
res: {
type: 'array' as const,
optional: false as const, nullable: false as const,
items: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'Note',
}
},
errors: {
noSuchChannel: {
message: 'No such channel.',
code: 'NO_SUCH_CHANNEL',
id: '4d0eeeba-a02c-4c3c-9966-ef60d38d2e7f'
}
}
};
export default define(meta, async (ps, user) => {
const channel = await Channels.findOne({
id: ps.channelId,
});
if (channel == null) {
throw new ApiError(meta.errors.noSuchChannel);
}
//#region Construct query
const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere('note.channelId = :channelId', { channelId: channel.id })
.leftJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.channel', 'channel');
//#endregion
const timeline = await query.take(ps.limit!).getMany();
activeUsersChart.update(user);
return await Notes.packMany(timeline, user);
});

View file

@ -0,0 +1,42 @@
import $ from 'cafy';
import { ID } from '../../../../misc/cafy-id';
import define from '../../define';
import { ApiError } from '../../error';
import { Channels, ChannelFollowings } from '../../../../models';
export const meta = {
tags: ['channels'],
requireCredential: true as const,
kind: 'write:channels',
params: {
channelId: {
validator: $.type(ID),
},
},
errors: {
noSuchChannel: {
message: 'No such channel.',
code: 'NO_SUCH_CHANNEL',
id: '19959ee9-0153-4c51-bbd9-a98c49dc59d6'
},
}
};
export default define(meta, async (ps, user) => {
const channel = await Channels.findOne({
id: ps.channelId,
});
if (channel == null) {
throw new ApiError(meta.errors.noSuchChannel);
}
await ChannelFollowings.delete({
followerId: user.id,
followeeId: channel.id,
});
});

View file

@ -0,0 +1,93 @@
import $ from 'cafy';
import { ID } from '../../../../misc/cafy-id';
import define from '../../define';
import { ApiError } from '../../error';
import { Channels, DriveFiles } from '../../../../models';
export const meta = {
tags: ['channels'],
requireCredential: true as const,
kind: 'write:channels',
params: {
channelId: {
validator: $.type(ID),
},
name: {
validator: $.optional.str.range(1, 128)
},
description: {
validator: $.nullable.optional.str.range(1, 2048)
},
bannerId: {
validator: $.nullable.optional.type(ID),
}
},
res: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'Channel',
},
errors: {
noSuchChannel: {
message: 'No such channel.',
code: 'NO_SUCH_CHANNEL',
id: 'f9c5467f-d492-4c3c-9a8d-a70dacc86512'
},
accessDenied: {
message: 'You do not have edit privilege of the channel.',
code: 'ACCESS_DENIED',
id: '1fb7cb09-d46a-4fdf-b8df-057788cce513'
},
noSuchFile: {
message: 'No such file.',
code: 'NO_SUCH_FILE',
id: 'e86c14a4-0da2-4032-8df3-e737a04c7f3b'
},
}
};
export default define(meta, async (ps, me) => {
const channel = await Channels.findOne({
id: ps.channelId,
});
if (channel == null) {
throw new ApiError(meta.errors.noSuchChannel);
}
if (channel.userId !== me.id) {
throw new ApiError(meta.errors.accessDenied);
}
let banner = undefined;
if (ps.bannerId != null) {
banner = await DriveFiles.findOne({
id: ps.bannerId,
userId: me.id
});
if (banner == null) {
throw new ApiError(meta.errors.noSuchFile);
}
} else if (ps.bannerId === null) {
banner = null;
}
await Channels.update(channel.id, {
...(ps.name !== undefined ? { name: ps.name } : {}),
...(ps.description !== undefined ? { description: ps.description } : {}),
...(banner ? { bannerId: banner.id } : {}),
});
return await Channels.pack(channel.id, me);
});

View file

@ -24,7 +24,6 @@ export default define(meta, async (ps, user, token) => {
return await Users.pack(user, user, {
detail: true,
includeHasUnreadNotes: true,
includeSecrets: isSecure
});
});

View file

@ -7,11 +7,12 @@ import { fetchMeta } from '../../../../misc/fetch-meta';
import { ApiError } from '../../error';
import { ID } from '../../../../misc/cafy-id';
import { User } from '../../../../models/entities/user';
import { Users, DriveFiles, Notes } from '../../../../models';
import { Users, DriveFiles, Notes, Channels } from '../../../../models';
import { DriveFile } from '../../../../models/entities/drive-file';
import { Note } from '../../../../models/entities/note';
import { DB_MAX_NOTE_TEXT_LENGTH } from '../../../../misc/hard-limits';
import { noteVisibilities } from '../../../../types';
import { Channel } from '../../../../models/entities/channel';
let maxNoteTextLength = 500;
@ -128,19 +129,26 @@ export const meta = {
},
replyId: {
validator: $.optional.type(ID),
validator: $.optional.nullable.type(ID),
desc: {
'ja-JP': '返信対象'
}
},
renoteId: {
validator: $.optional.type(ID),
validator: $.optional.nullable.type(ID),
desc: {
'ja-JP': 'Renote対象'
}
},
channelId: {
validator: $.optional.nullable.type(ID),
desc: {
'ja-JP': 'チャンネル'
}
},
poll: {
validator: $.optional.obj({
choices: $.arr($.str)
@ -206,7 +214,13 @@ export const meta = {
message: 'Poll is already expired.',
code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
id: '04da457d-b083-4055-9082-955525eda5a5'
}
},
noSuchChannel: {
message: 'No such channel.',
code: 'NO_SUCH_CHANNEL',
id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb'
},
}
};
@ -269,6 +283,15 @@ export default define(meta, async (ps, user) => {
throw new ApiError(meta.errors.contentRequired);
}
let channel: Channel | undefined;
if (ps.channelId != null) {
channel = await Channels.findOne(ps.channelId);
if (channel == null) {
throw new ApiError(meta.errors.noSuchChannel);
}
}
// 投稿を作成
const note = await create(user, {
createdAt: new Date(),
@ -286,6 +309,7 @@ export default define(meta, async (ps, user) => {
localOnly: ps.localOnly,
visibility: ps.visibility,
visibleUsers,
channel,
apMentions: ps.noExtractMentions ? [] : undefined,
apHashtags: ps.noExtractHashtags ? [] : undefined,
apEmojis: ps.noExtractEmojis ? [] : undefined,

View file

@ -80,6 +80,7 @@ export default define(meta, async (ps, user) => {
const query = makePaginationQuery(Notes.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.visibility = \'public\'')
.andWhere('note.channelId IS NULL')
.leftJoinAndSelect('note.user', 'user');
generateRepliesQuery(query, user);

View file

@ -13,6 +13,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query';
import { injectPromo } from '../../common/inject-promo';
import { injectFeatured } from '../../common/inject-featured';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
import { generateChannelQuery } from '../../common/generate-channel-query';
export const meta = {
desc: {
@ -131,6 +132,7 @@ export default define(meta, async (ps, user) => {
.leftJoinAndSelect('note.user', 'user')
.setParameters(followingQuery.getParameters());
generateChannelQuery(query, user);
generateRepliesQuery(query, user);
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);

View file

@ -13,6 +13,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query';
import { injectPromo } from '../../common/inject-promo';
import { injectFeatured } from '../../common/inject-featured';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
import { generateChannelQuery } from '../../common/generate-channel-query';
export const meta = {
desc: {
@ -99,6 +100,7 @@ export default define(meta, async (ps, user) => {
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
.leftJoinAndSelect('note.user', 'user');
generateChannelQuery(query, user);
generateRepliesQuery(query, user);
generateVisibilityQuery(query, user);
if (user) generateMutedUserQuery(query, user);

View file

@ -11,6 +11,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query';
import { injectPromo } from '../../common/inject-promo';
import { injectFeatured } from '../../common/inject-featured';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
import { generateChannelQuery } from '../../common/generate-channel-query';
export const meta = {
desc: {
@ -124,6 +125,7 @@ export default define(meta, async (ps, user) => {
.leftJoinAndSelect('note.user', 'user')
.setParameters(followingQuery.getParameters());
generateChannelQuery(query, user);
generateRepliesQuery(query, user);
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);

View file

@ -27,6 +27,10 @@ export default abstract class Channel {
return this.connection.muting;
}
protected get followingChannels() {
return this.connection.followingChannels;
}
protected get subscriber() {
return this.connection.subscriber;
}

View file

@ -0,0 +1,49 @@
import autobind from 'autobind-decorator';
import Channel from '../channel';
import { Notes } from '../../../../models';
import { isMutedUserRelated } from '../../../../misc/is-muted-user-related';
import { PackedNote } from '../../../../models/repositories/note';
export default class extends Channel {
public readonly chName = 'channel';
public static shouldShare = false;
public static requireCredential = false;
private channelId: string;
@autobind
public async init(params: any) {
this.channelId = params.channelId as string;
// Subscribe stream
this.subscriber.on('notesStream', this.onNote);
}
@autobind
private async onNote(note: PackedNote) {
if (note.channelId !== this.channelId) return;
// リプライなら再pack
if (note.replyId != null) {
note.reply = await Notes.pack(note.replyId, this.user, {
detail: true
});
}
// Renoteなら再pack
if (note.renoteId != null) {
note.renote = await Notes.pack(note.renoteId, this.user, {
detail: true
});
}
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isMutedUserRelated(note, this.muting)) return;
this.send('note', note);
}
@autobind
public dispose() {
// Unsubscribe events
this.subscriber.off('notesStream', this.onNote);
}
}

View file

@ -25,6 +25,7 @@ export default class extends Channel {
@autobind
private async onNote(note: PackedNote) {
if (note.visibility !== 'public') return;
if (note.channelId != null) return;
// リプライなら再pack
if (note.replyId != null) {

View file

@ -18,8 +18,12 @@ export default class extends Channel {
@autobind
private async onNote(note: PackedNote) {
// その投稿のユーザーをフォローしていなかったら弾く
if (this.user!.id !== note.userId && !this.following.includes(note.userId)) return;
if (note.channelId) {
if (!this.followingChannels.includes(note.channelId)) return;
} else {
// その投稿のユーザーをフォローしていなかったら弾く
if ((this.user!.id !== note.userId) && !this.following.includes(note.userId)) return;
}
if (['followers', 'specified'].includes(note.visibility)) {
note = await Notes.pack(note.id, this.user!, {

View file

@ -23,11 +23,15 @@ export default class extends Channel {
@autobind
private async onNote(note: PackedNote) {
// 自分自身の投稿 または その投稿のユーザーをフォローしている または 全体公開のローカルの投稿 の場合だけ
// チャンネルの投稿ではなく、自分自身の投稿 または
// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
// チャンネルの投稿ではなく、全体公開のローカルの投稿 または
// フォローしているチャンネルの投稿 の場合だけ
if (!(
this.user!.id === note.userId ||
this.following.includes(note.userId) ||
((note.user as PackedUser).host == null && note.visibility === 'public')
(note.channelId == null && this.user!.id === note.userId) ||
(note.channelId == null && this.following.includes(note.userId)) ||
(note.channelId == null && ((note.user as PackedUser).host == null && note.visibility === 'public')) ||
(note.channelId != null && this.followingChannels.includes(note.channelId))
)) return;
if (['followers', 'specified'].includes(note.visibility)) {

View file

@ -11,6 +11,7 @@ import messaging from './messaging';
import messagingIndex from './messaging-index';
import drive from './drive';
import hashtag from './hashtag';
import channel from './channel';
import admin from './admin';
import gamesReversi from './games/reversi';
import gamesReversiGame from './games/reversi-game';
@ -29,6 +30,7 @@ export default {
messagingIndex,
drive,
hashtag,
channel,
admin,
gamesReversi,
gamesReversiGame

View file

@ -27,6 +27,7 @@ export default class extends Channel {
private async onNote(note: PackedNote) {
if ((note.user as PackedUser).host !== null) return;
if (note.visibility !== 'public') return;
if (note.channelId != null && !this.followingChannels.includes(note.channelId)) return;
// リプライなら再pack
if (note.replyId != null) {

View file

@ -7,7 +7,8 @@ import Channel from './channel';
import channels from './channels';
import { EventEmitter } from 'events';
import { User } from '../../../models/entities/user';
import { Users, Followings, Mutings, UserProfiles } from '../../../models';
import { Channel as ChannelModel } from '../../../models/entities/channel';
import { Users, Followings, Mutings, UserProfiles, ChannelFollowings } from '../../../models';
import { ApiError } from '../error';
import { AccessToken } from '../../../models/entities/access-token';
import { UserProfile } from '../../../models/entities/user-profile';
@ -20,6 +21,7 @@ export default class Connection {
public userProfile?: UserProfile;
public following: User['id'][] = [];
public muting: User['id'][] = [];
public followingChannels: ChannelModel['id'][] = [];
public token?: AccessToken;
private wsConnection: websocket.connection;
public subscriber: EventEmitter;
@ -27,6 +29,7 @@ export default class Connection {
private subscribingNotes: any = {};
private followingClock: NodeJS.Timer;
private mutingClock: NodeJS.Timer;
private followingChannelsClock: NodeJS.Timer;
private userProfileClock: NodeJS.Timer;
constructor(
@ -53,6 +56,9 @@ export default class Connection {
this.updateMuting();
this.mutingClock = setInterval(this.updateMuting, 5000);
this.updateFollowingChannels();
this.followingChannelsClock = setInterval(this.updateFollowingChannels, 5000);
this.updateUserProfile();
this.userProfileClock = setInterval(this.updateUserProfile, 5000);
}
@ -268,6 +274,18 @@ export default class Connection {
this.muting = mutings.map(x => x.muteeId);
}
@autobind
private async updateFollowingChannels() {
const followings = await ChannelFollowings.find({
where: {
followerId: this.user!.id
},
select: ['followeeId']
});
this.followingChannels = followings.map(x => x.followeeId);
}
@autobind
private async updateUserProfile() {
this.userProfile = await UserProfiles.findOne({
@ -286,6 +304,7 @@ export default class Connection {
if (this.followingClock) clearInterval(this.followingClock);
if (this.mutingClock) clearInterval(this.mutingClock);
if (this.followingChannelsClock) clearInterval(this.followingChannelsClock);
if (this.userProfileClock) clearInterval(this.userProfileClock);
}
}

View file

@ -17,7 +17,7 @@ import packFeed from './feed';
import { fetchMeta } from '../../misc/fetch-meta';
import { genOpenapiSpec } from '../api/openapi/gen-spec';
import config from '../../config';
import { Users, Notes, Emojis, UserProfiles, Pages } from '../../models';
import { Users, Notes, Emojis, UserProfiles, Pages, Channels } from '../../models';
import parseAcct from '../../misc/acct/parse';
import getNoteSummary from '../../misc/get-note-summary';
import { ensure } from '../../prelude/ensure';
@ -188,7 +188,7 @@ router.get('/@:user.json', async ctx => {
}
});
//#region for crawlers
//#region SSR (for crawlers)
// User
router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => {
const { username, host } = parseAcct(ctx.params.user);
@ -297,6 +297,28 @@ router.get('/@:user/pages/:page', async ctx => {
ctx.status = 404;
});
// Channel
router.get('/channels/:channel', async ctx => {
const channel = await Channels.findOne({
id: ctx.params.channel,
});
if (channel) {
const _channel = await Channels.pack(channel);
const meta = await fetchMeta();
await ctx.render('channel', {
channel: _channel,
instanceName: meta.name || 'Misskey'
});
ctx.set('Cache-Control', 'public, max-age=180');
return;
}
ctx.status = 404;
});
//#endregion
router.get('/info', async ctx => {

View file

@ -0,0 +1,21 @@
extends ./base
block vars
- const title = channel.name;
- const url = `${config.url}/channels/${channel.id}`;
block title
= `${title} | ${instanceName}`
block desc
meta(name='description' content= channel.description)
block og
meta(property='og:type' content='article')
meta(property='og:title' content= title)
meta(property='og:description' content= channel.description)
meta(property='og:url' content= url)
meta(property='og:image' content= channel.bannerUrl)
block meta
meta(name='twitter:card' content='summary')

View file

@ -28,6 +28,7 @@ export async function addNoteToAntenna(antenna: Antenna, note: Note, noteUser: U
select: ['muteeId']
});
// Copy
const _note: Note = {
...note
};

View file

@ -6,7 +6,7 @@ import { IdentifiableError } from '../../misc/identifiable-error';
import { User } from '../../models/entities/user';
import { Note } from '../../models/entities/note';
import { Notes, UserNotePinings, Users } from '../../models';
import { UserNotePining } from '../../models/entities/user-note-pinings';
import { UserNotePining } from '../../models/entities/user-note-pining';
import { genId } from '../../misc/gen-id';
import { deliverToFollowers } from '../../remote/activitypub/deliver-manager';
import { deliverToRelays } from '../relay';

View file

@ -17,7 +17,7 @@ import extractMentions from '../../misc/extract-mentions';
import extractEmojis from '../../misc/extract-emojis';
import extractHashtags from '../../misc/extract-hashtags';
import { Note, IMentionedRemoteUsers } from '../../models/entities/note';
import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes } from '../../models';
import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes, Channels, ChannelFollowings } from '../../models';
import { DriveFile } from '../../models/entities/drive-file';
import { App } from '../../models/entities/app';
import { Not, getConnection, In } from 'typeorm';
@ -33,6 +33,7 @@ import { checkWordMute } from '../../misc/check-word-mute';
import { addNoteToAntenna } from '../add-note-to-antenna';
import { countSameRenotes } from '../../misc/count-same-renotes';
import { deliverToRelays } from '../relay';
import { Channel } from '../../models/entities/channel';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -102,6 +103,7 @@ type Option = {
cw?: string | null;
visibility?: string;
visibleUsers?: User[] | null;
channel?: Channel | null;
apMentions?: User[] | null;
apHashtags?: string[] | null;
apEmojis?: string[] | null;
@ -111,13 +113,31 @@ type Option = {
};
export default async (user: User, data: Option, silent = false) => new Promise<Note>(async (res, rej) => {
// チャンネル外にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
if (data.reply && data.channel && data.reply.channelId !== data.channel.id) {
if (data.reply.channelId) {
data.channel = await Channels.findOne(data.reply.channelId);
} else {
data.channel = null;
}
}
// チャンネル内にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
if (data.reply && (data.channel == null) && data.reply.channelId) {
data.channel = await Channels.findOne(data.reply.channelId);
}
if (data.createdAt == null) data.createdAt = new Date();
if (data.visibility == null) data.visibility = 'public';
if (data.viaMobile == null) data.viaMobile = false;
if (data.localOnly == null) data.localOnly = false;
if (data.channel != null) data.visibility = 'public';
if (data.channel != null) data.visibleUsers = [];
// サイレンス
if (user.isSilenced && data.visibility === 'public') {
if (user.isSilenced && data.visibility === 'public' && data.channel == null) {
data.visibility = 'home';
}
@ -142,12 +162,12 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
}
// ローカルのみをRenoteしたらローカルのみにする
if (data.renote && data.renote.localOnly) {
if (data.renote && data.renote.localOnly && data.channel == null) {
data.localOnly = true;
}
// ローカルのみにリプライしたらローカルのみにする
if (data.reply && data.reply.localOnly) {
if (data.reply && data.reply.localOnly && data.channel == null) {
data.localOnly = true;
}
@ -255,6 +275,18 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
}
});
// Channel
if (note.channelId) {
ChannelFollowings.find({ followeeId: note.channelId }).then(followings => {
for (const following of followings) {
insertNoteUnread(following.followerId, note, {
isSpecified: false,
isMentioned: false,
});
}
});
}
if (data.reply) {
saveReply(data.reply, note);
}
@ -273,11 +305,23 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
if (data.visibleUsers == null) throw new Error('invalid param');
for (const u of data.visibleUsers) {
insertNoteUnread(u, note, true);
// ローカルユーザーのみ
if (!Users.isLocalUser(u)) continue;
insertNoteUnread(u.id, note, {
isSpecified: true,
isMentioned: false,
});
}
} else {
for (const u of mentionedUsers) {
insertNoteUnread(u, note, false);
// ローカルユーザーのみ
if (!Users.isLocalUser(u)) continue;
insertNoteUnread(u.id, note, {
isSpecified: false,
isMentioned: true,
});
}
}
@ -379,6 +423,24 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
//#endregion
}
if (data.channel) {
Channels.increment({ id: data.channel.id }, 'notesCount', 1);
Channels.update(data.channel.id, {
lastNotedAt: new Date(),
});
Notes.count({
userId: user.id,
channelId: data.channel.id,
}).then(count => {
// この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる
// TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい
if (count === 1) {
Channels.increment({ id: data.channel.id }, 'usersCount', 1);
}
});
}
// Register to search database
index(note);
});
@ -405,6 +467,7 @@ async function insertNote(user: User, data: Option, tags: string[], emojis: stri
fileIds: data.files ? data.files.map(file => file.id) : [],
replyId: data.reply ? data.reply.id : null,
renoteId: data.renote ? data.renote.id : null,
channelId: data.channel ? data.channel.id : null,
name: data.name,
text: data.text,
hasPoll: data.poll != null,

View file

@ -2,71 +2,104 @@ import { publishMainStream } from '../stream';
import { Note } from '../../models/entities/note';
import { User } from '../../models/entities/user';
import { NoteUnreads, Antennas, AntennaNotes, Users } from '../../models';
// TODO: 状態が変化していない場合は各種イベントを送信しない
import { Not, IsNull } from 'typeorm';
/**
* Mark a note as read
*/
export default (
export default async function(
userId: User['id'],
noteId: Note['id']
) => new Promise<any>(async (resolve, reject) => {
// Remove document
/*const res = */await NoteUnreads.delete({
userId: userId,
noteId: noteId
});
// v11 TODO: https://github.com/typeorm/typeorm/issues/2415
//if (res.affected === 0) {
// return;
//}
const [count1, count2] = await Promise.all([
NoteUnreads.count({
) {
async function careNoteUnreads() {
const exist = await NoteUnreads.findOne({
userId: userId,
isSpecified: false
}),
NoteUnreads.count({
userId: userId,
isSpecified: true
})
]);
if (count1 === 0) {
// 全て既読になったイベントを発行
publishMainStream(userId, 'readAllUnreadMentions');
}
if (count2 === 0) {
// 全て既読になったイベントを発行
publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
}
const antennas = await Antennas.find({ userId });
await Promise.all(antennas.map(async antenna => {
await AntennaNotes.update({
antennaId: antenna.id,
noteId: noteId
}, {
read: true
noteId: noteId,
});
const count = await AntennaNotes.count({
antennaId: antenna.id,
read: false
if (!exist) return;
// Remove the record
await NoteUnreads.delete({
userId: userId,
noteId: noteId,
});
if (count === 0) {
publishMainStream(userId, 'readAntenna', antenna);
if (exist.isMentioned) {
NoteUnreads.count({
userId: userId,
isMentioned: true
}).then(mentionsCount => {
if (mentionsCount === 0) {
// 全て既読になったイベントを発行
publishMainStream(userId, 'readAllUnreadMentions');
}
});
}
}));
Users.getHasUnreadAntenna(userId).then(unread => {
if (!unread) {
publishMainStream(userId, 'readAllAntennas');
if (exist.isSpecified) {
NoteUnreads.count({
userId: userId,
isSpecified: true
}).then(specifiedCount => {
if (specifiedCount === 0) {
// 全て既読になったイベントを発行
publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
}
});
}
});
});
if (exist.noteChannelId) {
NoteUnreads.count({
userId: userId,
noteChannelId: Not(IsNull())
}).then(channelNoteCount => {
if (channelNoteCount === 0) {
// 全て既読になったイベントを発行
publishMainStream(userId, 'readAllChannels');
}
});
}
}
async function careAntenna() {
const beforeUnread = await Users.getHasUnreadAntenna(userId);
if (!beforeUnread) return;
const antennas = await Antennas.find({ userId });
await Promise.all(antennas.map(async antenna => {
const countBefore = await AntennaNotes.count({
antennaId: antenna.id,
read: false
});
if (countBefore === 0) return;
await AntennaNotes.update({
antennaId: antenna.id,
noteId: noteId
}, {
read: true
});
const countAfter = await AntennaNotes.count({
antennaId: antenna.id,
read: false
});
if (countAfter === 0) {
publishMainStream(userId, 'readAntenna', antenna);
}
}));
Users.getHasUnreadAntenna(userId).then(unread => {
if (!unread) {
publishMainStream(userId, 'readAllAntennas');
}
});
}
careNoteUnreads();
careAntenna();
}

View file

@ -1,16 +1,18 @@
import { Note } from '../../models/entities/note';
import { publishMainStream } from '../stream';
import { User } from '../../models/entities/user';
import { Mutings, NoteUnreads, Users } from '../../models';
import { Mutings, NoteUnreads } from '../../models';
import { genId } from '../../misc/gen-id';
export default async function(user: User, note: Note, isSpecified = false) {
// ローカルユーザーのみ
if (!Users.isLocalUser(user)) return;
export default async function(userId: User['id'], note: Note, params: {
// NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
isSpecified: boolean;
isMentioned: boolean;
}) {
//#region ミュートしているなら無視
// TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする
const mute = await Mutings.find({
muterId: user.id
muterId: userId
});
if (mute.map(m => m.muteeId).includes(note.userId)) return;
//#endregion
@ -18,20 +20,27 @@ export default async function(user: User, note: Note, isSpecified = false) {
const unread = await NoteUnreads.save({
id: genId(),
noteId: note.id,
userId: user.id,
isSpecified,
noteUserId: note.userId
userId: userId,
isSpecified: params.isSpecified,
isMentioned: params.isMentioned,
noteChannelId: note.channelId,
noteUserId: note.userId,
});
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
setTimeout(async () => {
const exist = await NoteUnreads.findOne(unread.id);
if (exist == null) return;
publishMainStream(user.id, 'unreadMention', note.id);
if (isSpecified) {
publishMainStream(user.id, 'unreadSpecifiedNote', note.id);
if (params.isMentioned) {
publishMainStream(userId, 'unreadMention', note.id);
}
if (params.isSpecified) {
publishMainStream(userId, 'unreadSpecifiedNote', note.id);
}
if (note.channelId) {
publishMainStream(userId, 'unreadChannel', note.id);
}
}, 2000);
}