* 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

View file

@ -564,6 +564,19 @@ overview: "概要"
logs: "ログ" logs: "ログ"
delayed: "遅延" delayed: "遅延"
database: "データベース" database: "データベース"
channel: "チャンネル"
create: "作成"
_channel:
create: "チャンネルを作成"
edit: "チャンネルを編集"
setBanner: "バナーを設定"
removeBanner: "バナーを削除"
featured: "トレンド"
owned: "管理中"
following: "フォロー中"
usersCount: "{n}人が参加中"
notesCount: "{n}投稿があります"
_sidebar: _sidebar:
full: "フル" full: "フル"
@ -660,6 +673,7 @@ _sfx:
chat: "チャット" chat: "チャット"
chatBg: "チャット(バックグラウンド)" chatBg: "チャット(バックグラウンド)"
antenna: "アンテナ受信" antenna: "アンテナ受信"
channel: "チャンネル通知"
_ago: _ago:
unknown: "謎" unknown: "謎"
@ -740,6 +754,8 @@ _permissions:
"write:page-likes": "ページのいいねを操作する" "write:page-likes": "ページのいいねを操作する"
"read:user-groups": "ユーザーグループを見る" "read:user-groups": "ユーザーグループを見る"
"write:user-groups": "ユーザーグループを操作する" "write:user-groups": "ユーザーグループを操作する"
"read:channels": "チャンネルを見る"
"write:channels": "チャンネルを操作する"
_auth: _auth:
shareAccess: "「{name}」がアカウントにアクセスすることを許可しますか?" shareAccess: "「{name}」がアカウントにアクセスすることを許可しますか?"
@ -822,6 +838,7 @@ _visibility:
_postForm: _postForm:
replyPlaceholder: "このノートに返信..." replyPlaceholder: "このノートに返信..."
quotePlaceholder: "このノートを引用..." quotePlaceholder: "このノートを引用..."
channelPlaceholder: "チャンネルに投稿..."
_placeholders: _placeholders:
a: "いまどうしてる?" a: "いまどうしてる?"
b: "何かありましたか?" b: "何かありましたか?"

View file

@ -0,0 +1,58 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class channel1596548170836 implements MigrationInterface {
name = 'channel1596548170836'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "channel" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "lastNotedAt" TIMESTAMP WITH TIME ZONE, "userId" character varying(32) NOT NULL, "name" character varying(128) NOT NULL, "description" character varying(2048), "bannerId" character varying(32), "notesCount" integer NOT NULL DEFAULT 0, "usersCount" integer NOT NULL DEFAULT 0, CONSTRAINT "PK_590f33ee6ee7d76437acf362e39" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_71cb7b435b7c0d4843317e7e16" ON "channel" ("createdAt") `);
await queryRunner.query(`CREATE INDEX "IDX_29ef80c6f13bcea998447fce43" ON "channel" ("lastNotedAt") `);
await queryRunner.query(`CREATE INDEX "IDX_823bae55bd81b3be6e05cff438" ON "channel" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_0f58c11241e649d2a638a8de94" ON "channel" ("notesCount") `);
await queryRunner.query(`CREATE INDEX "IDX_094b86cd36bb805d1aa1e8cc9a" ON "channel" ("usersCount") `);
await queryRunner.query(`CREATE TABLE "channel_following" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "followeeId" character varying(32) NOT NULL, "followerId" character varying(32) NOT NULL, CONSTRAINT "PK_8b104be7f7415113f2a02cd5bdd" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_11e71f2511589dcc8a4d3214f9" ON "channel_following" ("createdAt") `);
await queryRunner.query(`CREATE INDEX "IDX_0e43068c3f92cab197c3d3cd86" ON "channel_following" ("followeeId") `);
await queryRunner.query(`CREATE INDEX "IDX_6d8084ec9496e7334a4602707e" ON "channel_following" ("followerId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_2e230dd45a10e671d781d99f3e" ON "channel_following" ("followerId", "followeeId") `);
await queryRunner.query(`CREATE TABLE "channel_note_pining" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "channelId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, CONSTRAINT "PK_44f7474496bcf2e4b741681146d" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_8125f950afd3093acb10d2db8a" ON "channel_note_pining" ("channelId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_f36fed37d6d4cdcc68c803cd9c" ON "channel_note_pining" ("channelId", "noteId") `);
await queryRunner.query(`ALTER TABLE "note" ADD "channelId" character varying(32) DEFAULT null`);
await queryRunner.query(`CREATE INDEX "IDX_f22169eb10657bded6d875ac8f" ON "note" ("channelId") `);
await queryRunner.query(`ALTER TABLE "channel" ADD CONSTRAINT "FK_823bae55bd81b3be6e05cff4383" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "channel" ADD CONSTRAINT "FK_999da2bcc7efadbfe0e92d3bc19" FOREIGN KEY ("bannerId") REFERENCES "drive_file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_f22169eb10657bded6d875ac8f9" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "channel_following" ADD CONSTRAINT "FK_0e43068c3f92cab197c3d3cd86e" FOREIGN KEY ("followeeId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "channel_following" ADD CONSTRAINT "FK_6d8084ec9496e7334a4602707e1" FOREIGN KEY ("followerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "channel_note_pining" ADD CONSTRAINT "FK_8125f950afd3093acb10d2db8a8" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "channel_note_pining" ADD CONSTRAINT "FK_10b19ef67d297ea9de325cd4502" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "channel_note_pining" DROP CONSTRAINT "FK_10b19ef67d297ea9de325cd4502"`);
await queryRunner.query(`ALTER TABLE "channel_note_pining" DROP CONSTRAINT "FK_8125f950afd3093acb10d2db8a8"`);
await queryRunner.query(`ALTER TABLE "channel_following" DROP CONSTRAINT "FK_6d8084ec9496e7334a4602707e1"`);
await queryRunner.query(`ALTER TABLE "channel_following" DROP CONSTRAINT "FK_0e43068c3f92cab197c3d3cd86e"`);
await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_f22169eb10657bded6d875ac8f9"`);
await queryRunner.query(`ALTER TABLE "channel" DROP CONSTRAINT "FK_999da2bcc7efadbfe0e92d3bc19"`);
await queryRunner.query(`ALTER TABLE "channel" DROP CONSTRAINT "FK_823bae55bd81b3be6e05cff4383"`);
await queryRunner.query(`DROP INDEX "IDX_f22169eb10657bded6d875ac8f"`);
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "channelId"`);
await queryRunner.query(`DROP INDEX "IDX_f36fed37d6d4cdcc68c803cd9c"`);
await queryRunner.query(`DROP INDEX "IDX_8125f950afd3093acb10d2db8a"`);
await queryRunner.query(`DROP TABLE "channel_note_pining"`);
await queryRunner.query(`DROP INDEX "IDX_2e230dd45a10e671d781d99f3e"`);
await queryRunner.query(`DROP INDEX "IDX_6d8084ec9496e7334a4602707e"`);
await queryRunner.query(`DROP INDEX "IDX_0e43068c3f92cab197c3d3cd86"`);
await queryRunner.query(`DROP INDEX "IDX_11e71f2511589dcc8a4d3214f9"`);
await queryRunner.query(`DROP TABLE "channel_following"`);
await queryRunner.query(`DROP INDEX "IDX_094b86cd36bb805d1aa1e8cc9a"`);
await queryRunner.query(`DROP INDEX "IDX_0f58c11241e649d2a638a8de94"`);
await queryRunner.query(`DROP INDEX "IDX_823bae55bd81b3be6e05cff438"`);
await queryRunner.query(`DROP INDEX "IDX_29ef80c6f13bcea998447fce43"`);
await queryRunner.query(`DROP INDEX "IDX_71cb7b435b7c0d4843317e7e16"`);
await queryRunner.query(`DROP TABLE "channel"`);
}
}

View file

@ -0,0 +1,14 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class channel21596786425167 implements MigrationInterface {
name = 'channel21596786425167'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "channel_following" ADD "readCursor" TIMESTAMP WITH TIME ZONE NOT NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "channel_following" DROP COLUMN "readCursor"`);
}
}

View file

@ -0,0 +1,27 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class channelUnread1597459042300 implements MigrationInterface {
name = 'channelUnread1597459042300'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`TRUNCATE TABLE "note_unread"`, undefined);
await queryRunner.query(`ALTER TABLE "channel_following" DROP COLUMN "readCursor"`);
await queryRunner.query(`ALTER TABLE "note_unread" ADD "isMentioned" boolean NOT NULL`);
await queryRunner.query(`ALTER TABLE "note_unread" ADD "noteChannelId" character varying(32)`);
await queryRunner.query(`CREATE INDEX "IDX_25b1dd384bec391b07b74b861c" ON "note_unread" ("isMentioned") `);
await queryRunner.query(`CREATE INDEX "IDX_89a29c9237b8c3b6b3cbb4cb30" ON "note_unread" ("isSpecified") `);
await queryRunner.query(`CREATE INDEX "IDX_29e8c1d579af54d4232939f994" ON "note_unread" ("noteUserId") `);
await queryRunner.query(`CREATE INDEX "IDX_6a57f051d82c6d4036c141e107" ON "note_unread" ("noteChannelId") `);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_6a57f051d82c6d4036c141e107"`);
await queryRunner.query(`DROP INDEX "IDX_29e8c1d579af54d4232939f994"`);
await queryRunner.query(`DROP INDEX "IDX_89a29c9237b8c3b6b3cbb4cb30"`);
await queryRunner.query(`DROP INDEX "IDX_25b1dd384bec391b07b74b861c"`);
await queryRunner.query(`ALTER TABLE "note_unread" DROP COLUMN "noteChannelId"`);
await queryRunner.query(`ALTER TABLE "note_unread" DROP COLUMN "isMentioned"`);
await queryRunner.query(`ALTER TABLE "channel_following" ADD "readCursor" TIMESTAMP WITH TIME ZONE NOT NULL`);
}
}

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"/> <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 class="renote" v-if="appearNote.renote"><x-note-preview :note="appearNote.renote"/></div>
</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> </div>
<footer class="footer"> <footer class="footer">
<x-reactions-viewer :note="appearNote" ref="reactionsViewer"/> <x-reactions-viewer :note="appearNote" ref="reactionsViewer"/>
@ -96,7 +97,7 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; 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 { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
import { parse } from '../../mfm/parse'; import { parse } from '../../mfm/parse';
import { sum, unique } from '../../prelude/array'; import { sum, unique } from '../../prelude/array';
@ -133,6 +134,12 @@ export default Vue.extend({
MkUrlPreview, MkUrlPreview,
}, },
inject: {
inChannel: {
default: null
}
},
props: { props: {
note: { note: {
type: Object, type: Object,
@ -159,7 +166,7 @@ export default Vue.extend({
isDeleted: false, isDeleted: false,
muted: false, muted: false,
noteBody: this.$refs.noteBody, 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 { > .footer {

View file

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

View file

@ -24,6 +24,10 @@ export default Vue.extend({
type: String, type: String,
required: false required: false
}, },
channel: {
type: String,
required: false
},
sound: { sound: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -31,6 +35,12 @@ export default Vue.extend({
} }
}, },
provide() {
return {
inChannel: this.src === 'channel'
};
},
data() { data() {
return { return {
connection: null, connection: null,
@ -117,6 +127,15 @@ export default Vue.extend({
this.connection.on('note', prepend); this.connection.on('note', prepend);
this.connection.on('userAdded', onUserAdded); this.connection.on('userAdded', onUserAdded);
this.connection.on('userRemoved', onUserRemoved); 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 = { this.pagination = {

View file

@ -350,6 +350,20 @@ os.init(async () => {
app.sound('antenna'); 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', () => { main.on('readAllAnnouncements', () => {
store.dispatch('mergeMe', { store.dispatch('mergeMe', {
hasUnreadAnnouncement: false 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"> <div class="mk-home" v-hotkey.global="keymap">
<portal to="header" v-if="showTitle"> <portal to="header" v-if="showTitle">
<button @click="choose" class="_button _kjvfvyph_"> <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 === 'home'" :icon="faHome"/>
<fa v-if="src === 'local'" :icon="faComments"/> <fa v-if="src === 'local'" :icon="faComments"/>
<fa v-if="src === 'social'" :icon="faShareAlt"/> <fa v-if="src === 'social'" :icon="faShareAlt"/>
<fa v-if="src === 'global'" :icon="faGlobe"/> <fa v-if="src === 'global'" :icon="faGlobe"/>
<fa v-if="src === 'list'" :icon="faListUl"/> <fa v-if="src === 'list'" :icon="faListUl"/>
<fa v-if="src === 'antenna'" :icon="faSatellite"/> <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;"/> <fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/>
</button> </button>
</portal> </portal>
@ -19,13 +20,13 @@
<x-tutorial class="tutorial" v-if="$store.state.settings.tutorial != -1"/> <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-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> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; 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 { faComments } from '@fortawesome/free-regular-svg-icons';
import Progress from '../scripts/loading'; import Progress from '../scripts/loading';
import XTimeline from '../components/timeline.vue'; import XTimeline from '../components/timeline.vue';
@ -57,10 +58,11 @@ export default Vue.extend({
src: 'home', src: 'home',
list: null, list: null,
antenna: null, antenna: null,
channel: null,
menuOpened: false, menuOpened: false,
queue: 0, queue: 0,
width: 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: { watch: {
src() { src() {
this.showNav = false; this.showNav = false;
this.saveSrc();
}, },
list(x) { list(x) {
this.showNav = false; this.showNav = false;
this.saveSrc();
if (x != null) this.antenna = null; if (x != null) this.antenna = null;
if (x != null) this.channel = null;
}, },
antenna(x) { antenna(x) {
this.showNav = false; 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; if (x != null) this.list = null;
}, },
}, },
@ -99,6 +105,8 @@ export default Vue.extend({
this.list = this.$store.state.deviceUser.tl.arg; this.list = this.$store.state.deviceUser.tl.arg;
} else if (this.src === 'antenna') { } else if (this.src === 'antenna') {
this.antenna = this.$store.state.deviceUser.tl.arg; 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) { async choose(ev) {
if (this.meta == null) return; if (this.meta == null) return;
this.menuOpened = true; this.menuOpened = true;
const [antennas, lists] = await Promise.all([ const [antennas, lists, channels] = await Promise.all([
this.$root.api('antennas/list'), 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 => ({ const antennaItems = antennas.map(antenna => ({
text: antenna.name, text: antenna.name,
@ -137,7 +146,8 @@ export default Vue.extend({
indicate: antenna.hasUnreadNote, indicate: antenna.hasUnreadNote,
action: () => { action: () => {
this.antenna = antenna; this.antenna = antenna;
this.setSrc('antenna'); this.src = 'antenna';
this.saveSrc();
} }
})); }));
const listItems = lists.map(list => ({ const listItems = lists.map(list => ({
@ -145,27 +155,40 @@ export default Vue.extend({
icon: faListUl, icon: faListUl,
action: () => { action: () => {
this.list = list; 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({ this.$root.menu({
items: [{ items: [{
text: this.$t('_timelines.home'), text: this.$t('_timelines.home'),
icon: faHome, 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 : { }, this.meta.disableLocalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : {
text: this.$t('_timelines.local'), text: this.$t('_timelines.local'),
icon: faComments, 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 : { }, this.meta.disableLocalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : {
text: this.$t('_timelines.social'), text: this.$t('_timelines.social'),
icon: faShareAlt, 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 : { }, this.meta.disableGlobalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : {
text: this.$t('_timelines.global'), text: this.$t('_timelines.global'),
icon: faGlobe, icon: faGlobe,
action: () => { this.setSrc('global') } action: () => { this.src = 'global'; this.saveSrc(); }
}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], }, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems, channelItems.length > 0 ? null : undefined, ...channelItems],
fixed: true, fixed: true,
noCenter: true, noCenter: true,
source: ev.currentTarget || ev.target source: ev.currentTarget || ev.target
@ -174,14 +197,13 @@ export default Vue.extend({
}); });
}, },
setSrc(src) {
this.src = src;
},
saveSrc() { saveSrc() {
this.$store.commit('deviceUser/setTl', { this.$store.commit('deviceUser/setTl', {
src: this.src, 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: { computed: {
draftId(): string { draftKey(): string {
return this.user ? 'user:' + this.user.id : 'group:' + this.group.id; return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
}, },
canSend(): boolean { canSend(): boolean {
@ -79,7 +79,7 @@ export default Vue.extend({
autosize(this.$refs.text); 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) { if (draft) {
this.text = draft.data.text; this.text = draft.data.text;
this.file = draft.data.file; this.file = draft.data.file;
@ -199,7 +199,7 @@ export default Vue.extend({
saveDraft() { saveDraft() {
const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
data[this.draftId] = { data[this.draftKey] = {
updatedAt: new Date(), updatedAt: new Date(),
data: { data: {
text: this.text, text: this.text,
@ -213,7 +213,7 @@ export default Vue.extend({
deleteDraft() { deleteDraft() {
const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
delete data[this.draftId]; delete data[this.draftKey];
localStorage.setItem('message_drafts', JSON.stringify(data)); localStorage.setItem('message_drafts', JSON.stringify(data));
}, },

View file

@ -3,11 +3,11 @@
<portal to="icon"><fa :icon="faCog"/></portal> <portal to="icon"><fa :icon="faCog"/></portal>
<portal to="title">{{ $t('accountSettings') }}</portal> <portal to="title">{{ $t('accountSettings') }}</portal>
<x-profile-setting/> <x-profile-setting class="_vMargin"/>
<x-privacy-setting/> <x-privacy-setting class="_vMargin"/>
<x-reaction-setting/> <x-reaction-setting class="_vMargin"/>
<section class="_card"> <section class="_card _vMargin">
<div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div> <div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div>
<div class="_content"> <div class="_content">
<mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch"> <mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch">
@ -24,14 +24,14 @@
</div> </div>
</section> </section>
<x-import-export/> <x-import-export class="_vMargin"/>
<x-drive/> <x-drive class="_vMargin"/>
<x-mute-block/> <x-mute-block class="_vMargin"/>
<x-word-mute/> <x-word-mute class="_vMargin"/>
<x-security/> <x-security class="_vMargin"/>
<x-2fa/> <x-2fa class="_vMargin"/>
<x-integration/> <x-integration class="_vMargin"/>
<x-api/> <x-api class="_vMargin"/>
<router-link class="_panel _buttonPrimary" to="/my/apps" style="margin: var(--margin) auto;">{{ $t('installedApps') }}</router-link> <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> <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"> <section class="_card _vMargin">
<div class="_title"><fa :icon="faMusic"/> {{ $t('sounds') }}</div> <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> <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> <template #text><button class="_textButton" @click="listen(sfxAntenna)" v-if="sfxAntenna"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
</mk-select> </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> </div>
</section> </section>
@ -142,10 +147,14 @@ const sounds = [
'syuilo/pirori', 'syuilo/pirori',
'syuilo/pirori-wet', 'syuilo/pirori-wet',
'syuilo/pirori-square-wet', 'syuilo/pirori-square-wet',
'syuilo/square-pico',
'syuilo/reverved',
'syuilo/ryukyu',
'aisha/1', 'aisha/1',
'aisha/2', 'aisha/2',
'aisha/3', 'aisha/3',
'noizenecio/kick_gaba', 'noizenecio/kick_gaba',
'noizenecio/kick_gaba2',
]; ];
export default Vue.extend({ export default Vue.extend({
@ -272,6 +281,11 @@ export default Vue.extend({
set(value) { this.$store.commit('device/set', { key: 'sfxAntenna', value }); } 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: { volumeIcon: {
get() { get() {
return this.sfxVolume === 0 ? faVolumeMute : faVolumeUp; return this.sfxVolume === 0 ? faVolumeMute : faVolumeUp;

View file

@ -29,6 +29,10 @@ export const router = new VueRouter({
{ path: '/explore', component: page('explore') }, { path: '/explore', component: page('explore') },
{ path: '/explore/tags/:tag', props: true, component: page('explore') }, { path: '/explore/tags/:tag', props: true, component: page('explore') },
{ path: '/search', component: page('search') }, { 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/notifications', component: page('notifications') },
{ path: '/my/favorites', component: page('favorites') }, { path: '/my/favorites', component: page('favorites') },
{ path: '/my/messages', component: page('messages') }, { path: '/my/messages', component: page('messages') },

View file

@ -1,7 +1,7 @@
import Vuex from 'vuex'; import Vuex from 'vuex';
import createPersistedState from 'vuex-persistedstate'; import createPersistedState from 'vuex-persistedstate';
import * as nestedProperty from 'nested-property'; 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 { faBell, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons';
import { AiScript, utils, values } from '@syuilo/aiscript'; import { AiScript, utils, values } from '@syuilo/aiscript';
import { apiUrl, deckmode } from './config'; import { apiUrl, deckmode } from './config';
@ -90,6 +90,7 @@ export const defaultDeviceSettings = {
sfxChat: 'syuilo/pope1', sfxChat: 'syuilo/pope1',
sfxChatBg: 'syuilo/waon', sfxChatBg: 'syuilo/waon',
sfxAntenna: 'syuilo/triple', sfxAntenna: 'syuilo/triple',
sfxChannel: 'syuilo/square-pico',
userData: {}, userData: {},
}; };
@ -213,6 +214,11 @@ export default () => new Vuex.Store({
get show() { return getters.isSignedIn; }, get show() { return getters.isSignedIn; },
to: '/my/pages', to: '/my/pages',
}, },
channels: {
title: 'channel',
icon: faSatelliteDish,
to: '/channels',
},
games: { games: {
title: 'games', title: 'games',
icon: faGamepad, icon: faGamepad,

View file

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

View file

@ -72,5 +72,6 @@
X12: 'rgba(0, 0, 0, 0.1)', X12: 'rgba(0, 0, 0, 0.1)',
X13: 'rgba(0, 0, 0, 0.15)', X13: 'rgba(0, 0, 0, 0.15)',
X14: ':alpha<0.5<@navBg', 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 { Emoji } from '../models/entities/emoji';
import { ReversiGame } from '../models/entities/games/reversi/game'; import { ReversiGame } from '../models/entities/games/reversi/game';
import { ReversiMatching } from '../models/entities/games/reversi/matching'; 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 { Poll } from '../models/entities/poll';
import { UserKeypair } from '../models/entities/user-keypair'; import { UserKeypair } from '../models/entities/user-keypair';
import { UserPublickey } from '../models/entities/user-publickey'; import { UserPublickey } from '../models/entities/user-publickey';
@ -60,6 +60,9 @@ import { PromoRead } from '../models/entities/promo-read';
import { program } from '../argv'; import { program } from '../argv';
import { Relay } from '../models/entities/relay'; import { Relay } from '../models/entities/relay';
import { MutedNote } from '../models/entities/muted-note'; 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); const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
@ -153,6 +156,9 @@ export const entities = [
ReversiMatching, ReversiMatching,
Relay, Relay,
MutedNote, MutedNote,
Channel,
ChannelFollowing,
ChannelNotePining,
...charts as any ...charts as any
]; ];

View file

@ -25,4 +25,6 @@ export const kinds = [
'read:page-likes', 'read:page-likes',
'read:user-groups', 'read:user-groups',
'write: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 { User } from './user';
import { Note } from './note'; import { Note } from './note';
import { id } from '../id'; import { id } from '../id';
import { Channel } from './channel';
@Entity() @Entity()
@Index(['userId', 'noteId'], { unique: true }) @Index(['userId', 'noteId'], { unique: true })
@ -29,15 +30,34 @@ export class NoteUnread {
@JoinColumn() @JoinColumn()
public note: Note | null; public note: Note | null;
/**
*
*/
@Index()
@Column('boolean')
public isMentioned: boolean;
/**
* 稿
*/
@Index()
@Column('boolean')
public isSpecified: boolean;
//#region Denormalized fields
@Index()
@Column({ @Column({
...id(), ...id(),
comment: '[Denormalized]' comment: '[Denormalized]'
}) })
public noteUserId: User['id']; public noteUserId: User['id'];
/** @Index()
* 稿 @Column({
*/ ...id(),
@Column('boolean') nullable: true,
public isSpecified: boolean; comment: '[Denormalized]'
})
public noteChannelId: Channel['id'] | null;
//#endregion
} }

View file

@ -3,7 +3,7 @@ import { User } from './user';
import { DriveFile } from './drive-file'; import { DriveFile } from './drive-file';
import { id } from '../id'; import { id } from '../id';
import { noteVisibilities } from '../../types'; import { noteVisibilities } from '../../types';
import { Channel } from './channel';
@Entity() @Entity()
@Index('IDX_NOTE_TAGS', { synchronize: false }) @Index('IDX_NOTE_TAGS', { synchronize: false })
@ -173,6 +173,20 @@ export class Note {
}) })
public hasPoll: boolean; 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 //#region Denormalized fields
@Index() @Index()
@Column('varchar', { @Column('varchar', {

View file

@ -15,7 +15,7 @@ import { DriveFileRepository } from './repositories/drive-file';
import { DriveFolderRepository } from './repositories/drive-folder'; import { DriveFolderRepository } from './repositories/drive-folder';
import { Log } from './entities/log'; import { Log } from './entities/log';
import { AccessToken } from './entities/access-token'; 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 { SigninRepository } from './repositories/signin';
import { MessagingMessageRepository } from './repositories/messaging-message'; import { MessagingMessageRepository } from './repositories/messaging-message';
import { ReversiGameRepository } from './repositories/games/reversi/game'; import { ReversiGameRepository } from './repositories/games/reversi/game';
@ -53,7 +53,10 @@ import { PromoNote } from './entities/promo-note';
import { PromoRead } from './entities/promo-read'; import { PromoRead } from './entities/promo-read';
import { EmojiRepository } from './repositories/emoji'; import { EmojiRepository } from './repositories/emoji';
import { RelayRepository } from './repositories/relay'; import { RelayRepository } from './repositories/relay';
import { ChannelRepository } from './repositories/channel';
import { MutedNote } from './entities/muted-note'; 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 Announcements = getRepository(Announcement);
export const AnnouncementReads = getRepository(AnnouncementRead); export const AnnouncementReads = getRepository(AnnouncementRead);
@ -110,3 +113,6 @@ export const PromoNotes = getRepository(PromoNote);
export const PromoReads = getRepository(PromoRead); export const PromoReads = getRepository(PromoRead);
export const Relays = getCustomRepository(RelayRepository); export const Relays = getCustomRepository(RelayRepository);
export const MutedNotes = getRepository(MutedNote); 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 { EntityRepository, Repository, In } from 'typeorm';
import { Note } from '../entities/note'; import { Note } from '../entities/note';
import { User } from '../entities/user'; 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 { ensure } from '../../prelude/ensure';
import { SchemaType } from '../../misc/schema'; import { SchemaType } from '../../misc/schema';
import { awaitAll } from '../../prelude/await-all'; 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}`; 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({ const packed = await awaitAll({
id: note.id, id: note.id,
createdAt: note.createdAt.toISOString(), createdAt: note.createdAt.toISOString(),
@ -227,6 +233,11 @@ export class NoteRepository extends Repository<Note> {
files: DriveFiles.packMany(note.fileIds), files: DriveFiles.packMany(note.fileIds),
replyId: note.replyId, replyId: note.replyId,
renoteId: note.renoteId, renoteId: note.renoteId,
channelId: note.channelId || undefined,
channel: channel ? {
id: channel.id,
name: channel.name,
} : undefined,
mentions: note.mentions.length > 0 ? note.mentions : undefined, mentions: note.mentions.length > 0 ? note.mentions : undefined,
uri: note.uri || undefined, uri: note.uri || undefined,
url: note.url || undefined, url: note.url || undefined,
@ -391,6 +402,16 @@ export const packedNoteSchema = {
type: 'object' as const, type: 'object' as const,
optional: true as const, nullable: true 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 $ from 'cafy';
import { EntityRepository, Repository, In, Not } from 'typeorm'; import { EntityRepository, Repository, In, Not } from 'typeorm';
import { User, ILocalUser, IRemoteUser } from '../entities/user'; 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 { ensure } from '../../prelude/ensure';
import config from '../../config'; import config from '../../config';
import { SchemaType } from '../../misc/schema'; import { SchemaType } from '../../misc/schema';
@ -107,6 +107,17 @@ export class UserRepository extends Repository<User> {
return unread != null; 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> { public async getHasUnreadNotification(userId: User['id']): Promise<boolean> {
const mute = await Mutings.find({ const mute = await Mutings.find({
muterId: userId muterId: userId
@ -139,7 +150,6 @@ export class UserRepository extends Repository<User> {
options?: { options?: {
detail?: boolean, detail?: boolean,
includeSecrets?: boolean, includeSecrets?: boolean,
includeHasUnreadNotes?: boolean
} }
): Promise<PackedUser> { ): Promise<PackedUser> {
const opts = Object.assign({ const opts = Object.assign({
@ -181,17 +191,6 @@ export class UserRepository extends Repository<User> {
select: ['name', 'host', 'url', 'aliases'] 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 ? { ...(opts.detail ? {
url: profile!.url, url: profile!.url,
createdAt: user.createdAt.toISOString(), createdAt: user.createdAt.toISOString(),
@ -233,8 +232,17 @@ export class UserRepository extends Repository<User> {
alwaysMarkNsfw: profile!.alwaysMarkNsfw, alwaysMarkNsfw: profile!.alwaysMarkNsfw,
carefulBot: profile!.carefulBot, carefulBot: profile!.carefulBot,
autoAcceptFollowed: profile!.autoAcceptFollowed, 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), hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id),
hasUnreadAntenna: this.getHasUnreadAntenna(user.id), hasUnreadAntenna: this.getHasUnreadAntenna(user.id),
hasUnreadChannel: this.getHasUnreadChannel(user.id),
hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id), hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id),
hasUnreadNotification: this.getHasUnreadNotification(user.id), hasUnreadNotification: this.getHasUnreadNotification(user.id),
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
@ -276,7 +284,6 @@ export class UserRepository extends Repository<User> {
options?: { options?: {
detail?: boolean, detail?: boolean,
includeSecrets?: boolean, includeSecrets?: boolean,
includeHasUnreadNotes?: boolean
} }
) { ) {
return Promise.all(users.map(u => this.pack(u, me, options))); 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 { Users, UserNotePinings, Instances, DriveFiles, Followings, UserProfiles, UserPublickeys } from '../../../models';
import { User, IRemoteUser } from '../../../models/entities/user'; import { User, IRemoteUser } from '../../../models/entities/user';
import { Emoji } from '../../../models/entities/emoji'; 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 { genId } from '../../../misc/gen-id';
import { instanceChart, usersChart } from '../../../services/chart'; import { instanceChart, usersChart } from '../../../services/chart';
import { UserPublickey } from '../../../models/entities/user-publickey'; 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, { return await Users.pack(user, user, {
detail: true, detail: true,
includeHasUnreadNotes: true,
includeSecrets: isSecure includeSecrets: isSecure
}); });
}); });

View file

@ -7,11 +7,12 @@ import { fetchMeta } from '../../../../misc/fetch-meta';
import { ApiError } from '../../error'; import { ApiError } from '../../error';
import { ID } from '../../../../misc/cafy-id'; import { ID } from '../../../../misc/cafy-id';
import { User } from '../../../../models/entities/user'; 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 { DriveFile } from '../../../../models/entities/drive-file';
import { Note } from '../../../../models/entities/note'; import { Note } from '../../../../models/entities/note';
import { DB_MAX_NOTE_TEXT_LENGTH } from '../../../../misc/hard-limits'; import { DB_MAX_NOTE_TEXT_LENGTH } from '../../../../misc/hard-limits';
import { noteVisibilities } from '../../../../types'; import { noteVisibilities } from '../../../../types';
import { Channel } from '../../../../models/entities/channel';
let maxNoteTextLength = 500; let maxNoteTextLength = 500;
@ -128,19 +129,26 @@ export const meta = {
}, },
replyId: { replyId: {
validator: $.optional.type(ID), validator: $.optional.nullable.type(ID),
desc: { desc: {
'ja-JP': '返信対象' 'ja-JP': '返信対象'
} }
}, },
renoteId: { renoteId: {
validator: $.optional.type(ID), validator: $.optional.nullable.type(ID),
desc: { desc: {
'ja-JP': 'Renote対象' 'ja-JP': 'Renote対象'
} }
}, },
channelId: {
validator: $.optional.nullable.type(ID),
desc: {
'ja-JP': 'チャンネル'
}
},
poll: { poll: {
validator: $.optional.obj({ validator: $.optional.obj({
choices: $.arr($.str) choices: $.arr($.str)
@ -206,7 +214,13 @@ export const meta = {
message: 'Poll is already expired.', message: 'Poll is already expired.',
code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
id: '04da457d-b083-4055-9082-955525eda5a5' 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); 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, { const note = await create(user, {
createdAt: new Date(), createdAt: new Date(),
@ -286,6 +309,7 @@ export default define(meta, async (ps, user) => {
localOnly: ps.localOnly, localOnly: ps.localOnly,
visibility: ps.visibility, visibility: ps.visibility,
visibleUsers, visibleUsers,
channel,
apMentions: ps.noExtractMentions ? [] : undefined, apMentions: ps.noExtractMentions ? [] : undefined,
apHashtags: ps.noExtractHashtags ? [] : undefined, apHashtags: ps.noExtractHashtags ? [] : undefined,
apEmojis: ps.noExtractEmojis ? [] : undefined, apEmojis: ps.noExtractEmojis ? [] : undefined,

View file

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

View file

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

View file

@ -27,6 +27,10 @@ export default abstract class Channel {
return this.connection.muting; return this.connection.muting;
} }
protected get followingChannels() {
return this.connection.followingChannels;
}
protected get subscriber() { protected get subscriber() {
return this.connection.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 @autobind
private async onNote(note: PackedNote) { private async onNote(note: PackedNote) {
if (note.visibility !== 'public') return; if (note.visibility !== 'public') return;
if (note.channelId != null) return;
// リプライなら再pack // リプライなら再pack
if (note.replyId != null) { if (note.replyId != null) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,7 +17,7 @@ import packFeed from './feed';
import { fetchMeta } from '../../misc/fetch-meta'; import { fetchMeta } from '../../misc/fetch-meta';
import { genOpenapiSpec } from '../api/openapi/gen-spec'; import { genOpenapiSpec } from '../api/openapi/gen-spec';
import config from '../../config'; 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 parseAcct from '../../misc/acct/parse';
import getNoteSummary from '../../misc/get-note-summary'; import getNoteSummary from '../../misc/get-note-summary';
import { ensure } from '../../prelude/ensure'; import { ensure } from '../../prelude/ensure';
@ -188,7 +188,7 @@ router.get('/@:user.json', async ctx => {
} }
}); });
//#region for crawlers //#region SSR (for crawlers)
// User // User
router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => { router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => {
const { username, host } = parseAcct(ctx.params.user); const { username, host } = parseAcct(ctx.params.user);
@ -297,6 +297,28 @@ router.get('/@:user/pages/:page', async ctx => {
ctx.status = 404; 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 //#endregion
router.get('/info', async ctx => { 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'] select: ['muteeId']
}); });
// Copy
const _note: Note = { const _note: Note = {
...note ...note
}; };

View file

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

View file

@ -17,7 +17,7 @@ import extractMentions from '../../misc/extract-mentions';
import extractEmojis from '../../misc/extract-emojis'; import extractEmojis from '../../misc/extract-emojis';
import extractHashtags from '../../misc/extract-hashtags'; import extractHashtags from '../../misc/extract-hashtags';
import { Note, IMentionedRemoteUsers } from '../../models/entities/note'; 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 { DriveFile } from '../../models/entities/drive-file';
import { App } from '../../models/entities/app'; import { App } from '../../models/entities/app';
import { Not, getConnection, In } from 'typeorm'; 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 { addNoteToAntenna } from '../add-note-to-antenna';
import { countSameRenotes } from '../../misc/count-same-renotes'; import { countSameRenotes } from '../../misc/count-same-renotes';
import { deliverToRelays } from '../relay'; import { deliverToRelays } from '../relay';
import { Channel } from '../../models/entities/channel';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -102,6 +103,7 @@ type Option = {
cw?: string | null; cw?: string | null;
visibility?: string; visibility?: string;
visibleUsers?: User[] | null; visibleUsers?: User[] | null;
channel?: Channel | null;
apMentions?: User[] | null; apMentions?: User[] | null;
apHashtags?: string[] | null; apHashtags?: string[] | null;
apEmojis?: 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) => { 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.createdAt == null) data.createdAt = new Date();
if (data.visibility == null) data.visibility = 'public'; if (data.visibility == null) data.visibility = 'public';
if (data.viaMobile == null) data.viaMobile = false; if (data.viaMobile == null) data.viaMobile = false;
if (data.localOnly == null) data.localOnly = 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'; data.visibility = 'home';
} }
@ -142,12 +162,12 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
} }
// ローカルのみをRenoteしたらローカルのみにする // ローカルのみをRenoteしたらローカルのみにする
if (data.renote && data.renote.localOnly) { if (data.renote && data.renote.localOnly && data.channel == null) {
data.localOnly = true; data.localOnly = true;
} }
// ローカルのみにリプライしたらローカルのみにする // ローカルのみにリプライしたらローカルのみにする
if (data.reply && data.reply.localOnly) { if (data.reply && data.reply.localOnly && data.channel == null) {
data.localOnly = true; 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) { if (data.reply) {
saveReply(data.reply, note); 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'); if (data.visibleUsers == null) throw new Error('invalid param');
for (const u of data.visibleUsers) { for (const u of data.visibleUsers) {
insertNoteUnread(u, note, true); // ローカルユーザーのみ
if (!Users.isLocalUser(u)) continue;
insertNoteUnread(u.id, note, {
isSpecified: true,
isMentioned: false,
});
} }
} else { } else {
for (const u of mentionedUsers) { 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 //#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 // Register to search database
index(note); 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) : [], fileIds: data.files ? data.files.map(file => file.id) : [],
replyId: data.reply ? data.reply.id : null, replyId: data.reply ? data.reply.id : null,
renoteId: data.renote ? data.renote.id : null, renoteId: data.renote ? data.renote.id : null,
channelId: data.channel ? data.channel.id : null,
name: data.name, name: data.name,
text: data.text, text: data.text,
hasPoll: data.poll != null, hasPoll: data.poll != null,

View file

@ -2,71 +2,104 @@ import { publishMainStream } from '../stream';
import { Note } from '../../models/entities/note'; import { Note } from '../../models/entities/note';
import { User } from '../../models/entities/user'; import { User } from '../../models/entities/user';
import { NoteUnreads, Antennas, AntennaNotes, Users } from '../../models'; import { NoteUnreads, Antennas, AntennaNotes, Users } from '../../models';
import { Not, IsNull } from 'typeorm';
// TODO: 状態が変化していない場合は各種イベントを送信しない
/** /**
* Mark a note as read * Mark a note as read
*/ */
export default ( export default async function(
userId: User['id'], userId: User['id'],
noteId: Note['id'] noteId: Note['id']
) => new Promise<any>(async (resolve, reject) => { ) {
// Remove document async function careNoteUnreads() {
/*const res = */await NoteUnreads.delete({ const exist = await NoteUnreads.findOne({
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({
userId: userId, userId: userId,
isSpecified: false noteId: noteId,
}),
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
}); });
const count = await AntennaNotes.count({ if (!exist) return;
antennaId: antenna.id,
read: false // Remove the record
await NoteUnreads.delete({
userId: userId,
noteId: noteId,
}); });
if (count === 0) { if (exist.isMentioned) {
publishMainStream(userId, 'readAntenna', antenna); NoteUnreads.count({
userId: userId,
isMentioned: true
}).then(mentionsCount => {
if (mentionsCount === 0) {
// 全て既読になったイベントを発行
publishMainStream(userId, 'readAllUnreadMentions');
}
});
} }
}));
Users.getHasUnreadAntenna(userId).then(unread => { if (exist.isSpecified) {
if (!unread) { NoteUnreads.count({
publishMainStream(userId, 'readAllAntennas'); 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 { Note } from '../../models/entities/note';
import { publishMainStream } from '../stream'; import { publishMainStream } from '../stream';
import { User } from '../../models/entities/user'; import { User } from '../../models/entities/user';
import { Mutings, NoteUnreads, Users } from '../../models'; import { Mutings, NoteUnreads } from '../../models';
import { genId } from '../../misc/gen-id'; import { genId } from '../../misc/gen-id';
export default async function(user: User, note: Note, isSpecified = false) { export default async function(userId: User['id'], note: Note, params: {
// ローカルユーザーのみ // NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
if (!Users.isLocalUser(user)) return; isSpecified: boolean;
isMentioned: boolean;
}) {
//#region ミュートしているなら無視 //#region ミュートしているなら無視
// TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする
const mute = await Mutings.find({ const mute = await Mutings.find({
muterId: user.id muterId: userId
}); });
if (mute.map(m => m.muteeId).includes(note.userId)) return; if (mute.map(m => m.muteeId).includes(note.userId)) return;
//#endregion //#endregion
@ -18,20 +20,27 @@ export default async function(user: User, note: Note, isSpecified = false) {
const unread = await NoteUnreads.save({ const unread = await NoteUnreads.save({
id: genId(), id: genId(),
noteId: note.id, noteId: note.id,
userId: user.id, userId: userId,
isSpecified, isSpecified: params.isSpecified,
noteUserId: note.userId isMentioned: params.isMentioned,
noteChannelId: note.channelId,
noteUserId: note.userId,
}); });
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
setTimeout(async () => { setTimeout(async () => {
const exist = await NoteUnreads.findOne(unread.id); const exist = await NoteUnreads.findOne(unread.id);
if (exist == null) return; if (exist == null) return;
publishMainStream(user.id, 'unreadMention', note.id); if (params.isMentioned) {
publishMainStream(userId, 'unreadMention', note.id);
if (isSpecified) { }
publishMainStream(user.id, 'unreadSpecifiedNote', note.id); if (params.isSpecified) {
publishMainStream(userId, 'unreadSpecifiedNote', note.id);
}
if (note.channelId) {
publishMainStream(userId, 'unreadChannel', note.id);
} }
}, 2000); }, 2000);
} }