mirror of
https://iceshrimp.dev/limepotato/jormungandr-bite.git
synced 2024-11-25 11:27:31 -07:00
Channel (#6621)
* 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:
parent
696f8c9638
commit
481aa17886
70 changed files with 2203 additions and 184 deletions
|
@ -564,6 +564,19 @@ overview: "概要"
|
|||
logs: "ログ"
|
||||
delayed: "遅延"
|
||||
database: "データベース"
|
||||
channel: "チャンネル"
|
||||
create: "作成"
|
||||
|
||||
_channel:
|
||||
create: "チャンネルを作成"
|
||||
edit: "チャンネルを編集"
|
||||
setBanner: "バナーを設定"
|
||||
removeBanner: "バナーを削除"
|
||||
featured: "トレンド"
|
||||
owned: "管理中"
|
||||
following: "フォロー中"
|
||||
usersCount: "{n}人が参加中"
|
||||
notesCount: "{n}投稿があります"
|
||||
|
||||
_sidebar:
|
||||
full: "フル"
|
||||
|
@ -660,6 +673,7 @@ _sfx:
|
|||
chat: "チャット"
|
||||
chatBg: "チャット(バックグラウンド)"
|
||||
antenna: "アンテナ受信"
|
||||
channel: "チャンネル通知"
|
||||
|
||||
_ago:
|
||||
unknown: "謎"
|
||||
|
@ -740,6 +754,8 @@ _permissions:
|
|||
"write:page-likes": "ページのいいねを操作する"
|
||||
"read:user-groups": "ユーザーグループを見る"
|
||||
"write:user-groups": "ユーザーグループを操作する"
|
||||
"read:channels": "チャンネルを見る"
|
||||
"write:channels": "チャンネルを操作する"
|
||||
|
||||
_auth:
|
||||
shareAccess: "「{name}」がアカウントにアクセスすることを許可しますか?"
|
||||
|
@ -822,6 +838,7 @@ _visibility:
|
|||
_postForm:
|
||||
replyPlaceholder: "このノートに返信..."
|
||||
quotePlaceholder: "このノートを引用..."
|
||||
channelPlaceholder: "チャンネルに投稿..."
|
||||
_placeholders:
|
||||
a: "いまどうしてる?"
|
||||
b: "何かありましたか?"
|
||||
|
|
58
migration/1596548170836-channel.ts
Normal file
58
migration/1596548170836-channel.ts
Normal 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"`);
|
||||
}
|
||||
|
||||
}
|
14
migration/1596786425167-channel2.ts
Normal file
14
migration/1596786425167-channel2.ts
Normal 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"`);
|
||||
}
|
||||
|
||||
}
|
27
migration/1597459042300-channel-unread.ts
Normal file
27
migration/1597459042300-channel-unread.ts
Normal 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`);
|
||||
}
|
||||
|
||||
}
|
BIN
src/client/assets/sounds/noizenecio/kick_gaba2.mp3
(Stored with Git LFS)
Normal file
BIN
src/client/assets/sounds/noizenecio/kick_gaba2.mp3
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src/client/assets/sounds/syuilo/reverved.mp3
(Stored with Git LFS)
Normal file
BIN
src/client/assets/sounds/syuilo/reverved.mp3
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src/client/assets/sounds/syuilo/ryukyu.mp3
(Stored with Git LFS)
Normal file
BIN
src/client/assets/sounds/syuilo/ryukyu.mp3
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src/client/assets/sounds/syuilo/square-pico.mp3
(Stored with Git LFS)
Normal file
BIN
src/client/assets/sounds/syuilo/square-pico.mp3
(Stored with Git LFS)
Normal file
Binary file not shown.
141
src/client/components/channel-follow-button.vue
Normal file
141
src/client/components/channel-follow-button.vue
Normal 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>
|
144
src/client/components/channel-preview.vue
Normal file
144
src/client/components/channel-preview.vue
Normal 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>
|
|
@ -57,6 +57,7 @@
|
|||
<mk-url-preview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="detail" class="url-preview"/>
|
||||
<div class="renote" v-if="appearNote.renote"><x-note-preview :note="appearNote.renote"/></div>
|
||||
</div>
|
||||
<router-link v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><fa :icon="faSatelliteDish"/> {{ appearNote.channel.name }}</router-link>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<x-reactions-viewer :note="appearNote" ref="reactionsViewer"/>
|
||||
|
@ -96,7 +97,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
|
||||
import { parse } from '../../mfm/parse';
|
||||
import { sum, unique } from '../../prelude/array';
|
||||
|
@ -133,6 +134,12 @@ export default Vue.extend({
|
|||
MkUrlPreview,
|
||||
},
|
||||
|
||||
inject: {
|
||||
inChannel: {
|
||||
default: null
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
|
@ -159,7 +166,7 @@ export default Vue.extend({
|
|||
isDeleted: false,
|
||||
muted: false,
|
||||
noteBody: this.$refs.noteBody,
|
||||
faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug
|
||||
faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug, faSatelliteDish
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -954,6 +961,11 @@ export default Vue.extend({
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .channel {
|
||||
opacity: 0.7;
|
||||
font-size: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
> .footer {
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
<div>
|
||||
<span class="local-only" v-if="localOnly" v-text="$t('_visibility.localOnly')" />
|
||||
<span class="text-count" :class="{ over: trimmedLength(text) > max }">{{ max - trimmedLength(text) }}</span>
|
||||
<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$t('visibility')">
|
||||
<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$t('visibility')" :disabled="channel != null">
|
||||
<span v-if="visibility === 'public'"><fa :icon="faGlobe"/></span>
|
||||
<span v-if="visibility === 'home'"><fa :icon="faHome"/></span>
|
||||
<span v-if="visibility === 'followers'"><fa :icon="faUnlock"/></span>
|
||||
|
@ -88,6 +88,10 @@ export default Vue.extend({
|
|||
type: Object,
|
||||
required: false
|
||||
},
|
||||
channel: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
mention: {
|
||||
type: Object,
|
||||
required: false
|
||||
|
@ -140,30 +144,38 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
computed: {
|
||||
draftId(): string {
|
||||
return this.renote
|
||||
? `renote:${this.renote.id}`
|
||||
: this.reply
|
||||
? `reply:${this.reply.id}`
|
||||
: 'note';
|
||||
draftKey(): string {
|
||||
let key = this.channel ? `channel:${this.channel.id}` : '';
|
||||
|
||||
if (this.renote) {
|
||||
key += `renote:${this.renote.id}`;
|
||||
} else if (this.reply) {
|
||||
key += `reply:${this.reply.id}`;
|
||||
} else {
|
||||
key += 'note';
|
||||
}
|
||||
|
||||
return key;
|
||||
},
|
||||
|
||||
placeholder(): string {
|
||||
const xs = [
|
||||
this.$t('_postForm._placeholders.a'),
|
||||
this.$t('_postForm._placeholders.b'),
|
||||
this.$t('_postForm._placeholders.c'),
|
||||
this.$t('_postForm._placeholders.d'),
|
||||
this.$t('_postForm._placeholders.e'),
|
||||
this.$t('_postForm._placeholders.f')
|
||||
];
|
||||
const x = xs[Math.floor(Math.random() * xs.length)];
|
||||
|
||||
return this.renote
|
||||
? this.$t('_postForm.quotePlaceholder')
|
||||
: this.reply
|
||||
? this.$t('_postForm.replyPlaceholder')
|
||||
: x;
|
||||
if (this.renote) {
|
||||
return this.$t('_postForm.quotePlaceholder');
|
||||
} else if (this.reply) {
|
||||
return this.$t('_postForm.replyPlaceholder');
|
||||
} else if (this.channel) {
|
||||
return this.$t('_postForm.channelPlaceholder');
|
||||
} else {
|
||||
const xs = [
|
||||
this.$t('_postForm._placeholders.a'),
|
||||
this.$t('_postForm._placeholders.b'),
|
||||
this.$t('_postForm._placeholders.c'),
|
||||
this.$t('_postForm._placeholders.d'),
|
||||
this.$t('_postForm._placeholders.e'),
|
||||
this.$t('_postForm._placeholders.f')
|
||||
];
|
||||
return xs[Math.floor(Math.random() * xs.length)];
|
||||
}
|
||||
},
|
||||
|
||||
submitText(): string {
|
||||
|
@ -224,9 +236,11 @@ export default Vue.extend({
|
|||
}
|
||||
|
||||
// デフォルト公開範囲
|
||||
this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.visibility : this.$store.state.settings.defaultNoteVisibility);
|
||||
if (this.channel == null) {
|
||||
this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.visibility : this.$store.state.settings.defaultNoteVisibility);
|
||||
|
||||
this.localOnly = this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.localOnly : this.$store.state.settings.defaultNoteLocalOnly;
|
||||
this.localOnly = this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.localOnly : this.$store.state.settings.defaultNoteLocalOnly;
|
||||
}
|
||||
|
||||
// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
|
||||
if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
|
||||
|
@ -266,7 +280,7 @@ export default Vue.extend({
|
|||
this.$nextTick(() => {
|
||||
// 書きかけの投稿を復元
|
||||
if (!this.instant && !this.mention) {
|
||||
const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId];
|
||||
const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey];
|
||||
if (draft) {
|
||||
this.text = draft.data.text;
|
||||
this.useCw = draft.data.useCw;
|
||||
|
@ -398,6 +412,10 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
setVisibility() {
|
||||
if (this.channel) {
|
||||
// TODO: information dialog
|
||||
return;
|
||||
}
|
||||
const w = this.$root.new(MkVisibilityChooser, {
|
||||
source: this.$refs.visibilityButton,
|
||||
currentVisibility: this.visibility,
|
||||
|
@ -510,7 +528,7 @@ export default Vue.extend({
|
|||
|
||||
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
|
||||
|
||||
data[this.draftId] = {
|
||||
data[this.draftKey] = {
|
||||
updatedAt: new Date(),
|
||||
data: {
|
||||
text: this.text,
|
||||
|
@ -529,7 +547,7 @@ export default Vue.extend({
|
|||
deleteDraft() {
|
||||
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
|
||||
|
||||
delete data[this.draftId];
|
||||
delete data[this.draftKey];
|
||||
|
||||
localStorage.setItem('drafts', JSON.stringify(data));
|
||||
},
|
||||
|
@ -540,6 +558,7 @@ export default Vue.extend({
|
|||
fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
|
||||
replyId: this.reply ? this.reply.id : undefined,
|
||||
renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined,
|
||||
channelId: this.channel ? this.channel.id : undefined,
|
||||
poll: this.poll ? (this.$refs.poll as any).get() : undefined,
|
||||
cw: this.useCw ? this.cw || '' : undefined,
|
||||
localOnly: this.localOnly,
|
||||
|
|
|
@ -24,6 +24,10 @@ export default Vue.extend({
|
|||
type: String,
|
||||
required: false
|
||||
},
|
||||
channel: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
sound: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
|
@ -31,6 +35,12 @@ export default Vue.extend({
|
|||
}
|
||||
},
|
||||
|
||||
provide() {
|
||||
return {
|
||||
inChannel: this.src === 'channel'
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
connection: null,
|
||||
|
@ -117,6 +127,15 @@ export default Vue.extend({
|
|||
this.connection.on('note', prepend);
|
||||
this.connection.on('userAdded', onUserAdded);
|
||||
this.connection.on('userRemoved', onUserRemoved);
|
||||
} else if (this.src == 'channel') {
|
||||
endpoint = 'channels/timeline';
|
||||
this.query = {
|
||||
channelId: this.channel
|
||||
};
|
||||
this.connection = this.$root.stream.connectToChannel('channel', {
|
||||
channelId: this.channel
|
||||
});
|
||||
this.connection.on('note', prepend);
|
||||
}
|
||||
|
||||
this.pagination = {
|
||||
|
|
|
@ -350,6 +350,20 @@ os.init(async () => {
|
|||
app.sound('antenna');
|
||||
});
|
||||
|
||||
main.on('readAllChannels', () => {
|
||||
store.dispatch('mergeMe', {
|
||||
hasUnreadChannel: false
|
||||
});
|
||||
});
|
||||
|
||||
main.on('unreadChannel', () => {
|
||||
store.dispatch('mergeMe', {
|
||||
hasUnreadChannel: true
|
||||
});
|
||||
|
||||
app.sound('channel');
|
||||
});
|
||||
|
||||
main.on('readAllAnnouncements', () => {
|
||||
store.dispatch('mergeMe', {
|
||||
hasUnreadAnnouncement: false
|
||||
|
|
128
src/client/pages/channel-editor.vue
Normal file
128
src/client/pages/channel-editor.vue
Normal 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>
|
190
src/client/pages/channel.vue
Normal file
190
src/client/pages/channel.vue
Normal 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>
|
86
src/client/pages/channels.vue
Normal file
86
src/client/pages/channels.vue
Normal 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>
|
|
@ -2,14 +2,15 @@
|
|||
<div class="mk-home" v-hotkey.global="keymap">
|
||||
<portal to="header" v-if="showTitle">
|
||||
<button @click="choose" class="_button _kjvfvyph_">
|
||||
<i><fa v-if="$store.state.i.hasUnreadAntenna" :icon="faCircle"/></i>
|
||||
<i><fa v-if="$store.state.i.hasUnreadAntenna || $store.state.i.hasUnreadChannel" :icon="faCircle"/></i>
|
||||
<fa v-if="src === 'home'" :icon="faHome"/>
|
||||
<fa v-if="src === 'local'" :icon="faComments"/>
|
||||
<fa v-if="src === 'social'" :icon="faShareAlt"/>
|
||||
<fa v-if="src === 'global'" :icon="faGlobe"/>
|
||||
<fa v-if="src === 'list'" :icon="faListUl"/>
|
||||
<fa v-if="src === 'antenna'" :icon="faSatellite"/>
|
||||
<span style="margin-left: 8px;">{{ src === 'list' ? list.name : src === 'antenna' ? antenna.name : $t('_timelines.' + src) }}</span>
|
||||
<fa v-if="src === 'channel'" :icon="faSatelliteDish"/>
|
||||
<span style="margin-left: 8px;">{{ src === 'list' ? list.name : src === 'antenna' ? antenna.name : src === 'channel' ? channel.name : $t('_timelines.' + src) }}</span>
|
||||
<fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/>
|
||||
</button>
|
||||
</portal>
|
||||
|
@ -19,13 +20,13 @@
|
|||
<x-tutorial class="tutorial" v-if="$store.state.settings.tutorial != -1"/>
|
||||
|
||||
<x-post-form class="post-form _panel" fixed v-if="$store.state.device.showFixedPostForm"/>
|
||||
<x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src" :src="src" :list="list ? list.id : null" :antenna="antenna ? antenna.id : null" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/>
|
||||
<x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src === 'channel' ? `channel:${channel.id}` : src" :src="src" :list="list ? list.id : null" :antenna="antenna ? antenna.id : null" :channel="channel ? channel.id : null" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite, faCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite, faSatelliteDish, faCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faComments } from '@fortawesome/free-regular-svg-icons';
|
||||
import Progress from '../scripts/loading';
|
||||
import XTimeline from '../components/timeline.vue';
|
||||
|
@ -57,10 +58,11 @@ export default Vue.extend({
|
|||
src: 'home',
|
||||
list: null,
|
||||
antenna: null,
|
||||
channel: null,
|
||||
menuOpened: false,
|
||||
queue: 0,
|
||||
width: 0,
|
||||
faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite, faCircle
|
||||
faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite, faSatelliteDish, faCircle
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -79,16 +81,20 @@ export default Vue.extend({
|
|||
watch: {
|
||||
src() {
|
||||
this.showNav = false;
|
||||
this.saveSrc();
|
||||
},
|
||||
list(x) {
|
||||
this.showNav = false;
|
||||
this.saveSrc();
|
||||
if (x != null) this.antenna = null;
|
||||
if (x != null) this.channel = null;
|
||||
},
|
||||
antenna(x) {
|
||||
this.showNav = false;
|
||||
this.saveSrc();
|
||||
if (x != null) this.list = null;
|
||||
if (x != null) this.channel = null;
|
||||
},
|
||||
channel(x) {
|
||||
this.showNav = false;
|
||||
if (x != null) this.antenna = null;
|
||||
if (x != null) this.list = null;
|
||||
},
|
||||
},
|
||||
|
@ -99,6 +105,8 @@ export default Vue.extend({
|
|||
this.list = this.$store.state.deviceUser.tl.arg;
|
||||
} else if (this.src === 'antenna') {
|
||||
this.antenna = this.$store.state.deviceUser.tl.arg;
|
||||
} else if (this.src === 'channel') {
|
||||
this.channel = this.$store.state.deviceUser.tl.arg;
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -127,9 +135,10 @@ export default Vue.extend({
|
|||
async choose(ev) {
|
||||
if (this.meta == null) return;
|
||||
this.menuOpened = true;
|
||||
const [antennas, lists] = await Promise.all([
|
||||
const [antennas, lists, channels] = await Promise.all([
|
||||
this.$root.api('antennas/list'),
|
||||
this.$root.api('users/lists/list')
|
||||
this.$root.api('users/lists/list'),
|
||||
this.$root.api('channels/followed'),
|
||||
]);
|
||||
const antennaItems = antennas.map(antenna => ({
|
||||
text: antenna.name,
|
||||
|
@ -137,7 +146,8 @@ export default Vue.extend({
|
|||
indicate: antenna.hasUnreadNote,
|
||||
action: () => {
|
||||
this.antenna = antenna;
|
||||
this.setSrc('antenna');
|
||||
this.src = 'antenna';
|
||||
this.saveSrc();
|
||||
}
|
||||
}));
|
||||
const listItems = lists.map(list => ({
|
||||
|
@ -145,27 +155,40 @@ export default Vue.extend({
|
|||
icon: faListUl,
|
||||
action: () => {
|
||||
this.list = list;
|
||||
this.setSrc('list');
|
||||
this.src = 'list';
|
||||
this.saveSrc();
|
||||
}
|
||||
}));
|
||||
const channelItems = channels.map(channel => ({
|
||||
text: channel.name,
|
||||
icon: faSatelliteDish,
|
||||
indicate: channel.hasUnreadNote,
|
||||
action: () => {
|
||||
// NOTE: チャンネルタイムラインをこのコンポーネントで表示するようにすると投稿フォームはどうするかなどの問題が生じるのでとりあえずページ遷移で
|
||||
//this.channel = channel;
|
||||
//this.src = 'channel';
|
||||
//this.saveSrc();
|
||||
this.$router.push(`/channels/${channel.id}`);
|
||||
}
|
||||
}));
|
||||
this.$root.menu({
|
||||
items: [{
|
||||
text: this.$t('_timelines.home'),
|
||||
icon: faHome,
|
||||
action: () => { this.setSrc('home') }
|
||||
action: () => { this.src = 'home'; this.saveSrc(); }
|
||||
}, this.meta.disableLocalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : {
|
||||
text: this.$t('_timelines.local'),
|
||||
icon: faComments,
|
||||
action: () => { this.setSrc('local') }
|
||||
action: () => { this.src = 'local'; this.saveSrc(); }
|
||||
}, this.meta.disableLocalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : {
|
||||
text: this.$t('_timelines.social'),
|
||||
icon: faShareAlt,
|
||||
action: () => { this.setSrc('social') }
|
||||
action: () => { this.src = 'social'; this.saveSrc(); }
|
||||
}, this.meta.disableGlobalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : {
|
||||
text: this.$t('_timelines.global'),
|
||||
icon: faGlobe,
|
||||
action: () => { this.setSrc('global') }
|
||||
}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems],
|
||||
action: () => { this.src = 'global'; this.saveSrc(); }
|
||||
}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems, channelItems.length > 0 ? null : undefined, ...channelItems],
|
||||
fixed: true,
|
||||
noCenter: true,
|
||||
source: ev.currentTarget || ev.target
|
||||
|
@ -174,14 +197,13 @@ export default Vue.extend({
|
|||
});
|
||||
},
|
||||
|
||||
setSrc(src) {
|
||||
this.src = src;
|
||||
},
|
||||
|
||||
saveSrc() {
|
||||
this.$store.commit('deviceUser/setTl', {
|
||||
src: this.src,
|
||||
arg: this.src == 'list' ? this.list : this.antenna
|
||||
arg:
|
||||
this.src === 'list' ? this.list :
|
||||
this.src === 'antenna' ? this.antenna :
|
||||
this.channel
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -53,7 +53,7 @@ export default Vue.extend({
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
draftId(): string {
|
||||
draftKey(): string {
|
||||
return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
|
||||
},
|
||||
canSend(): boolean {
|
||||
|
@ -79,7 +79,7 @@ export default Vue.extend({
|
|||
autosize(this.$refs.text);
|
||||
|
||||
// 書きかけの投稿を復元
|
||||
const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftId];
|
||||
const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey];
|
||||
if (draft) {
|
||||
this.text = draft.data.text;
|
||||
this.file = draft.data.file;
|
||||
|
@ -199,7 +199,7 @@ export default Vue.extend({
|
|||
saveDraft() {
|
||||
const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
data[this.draftId] = {
|
||||
data[this.draftKey] = {
|
||||
updatedAt: new Date(),
|
||||
data: {
|
||||
text: this.text,
|
||||
|
@ -213,7 +213,7 @@ export default Vue.extend({
|
|||
deleteDraft() {
|
||||
const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
delete data[this.draftId];
|
||||
delete data[this.draftKey];
|
||||
|
||||
localStorage.setItem('message_drafts', JSON.stringify(data));
|
||||
},
|
||||
|
|
|
@ -3,11 +3,11 @@
|
|||
<portal to="icon"><fa :icon="faCog"/></portal>
|
||||
<portal to="title">{{ $t('accountSettings') }}</portal>
|
||||
|
||||
<x-profile-setting/>
|
||||
<x-privacy-setting/>
|
||||
<x-reaction-setting/>
|
||||
<x-profile-setting class="_vMargin"/>
|
||||
<x-privacy-setting class="_vMargin"/>
|
||||
<x-reaction-setting class="_vMargin"/>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch">
|
||||
|
@ -24,14 +24,14 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<x-import-export/>
|
||||
<x-drive/>
|
||||
<x-mute-block/>
|
||||
<x-word-mute/>
|
||||
<x-security/>
|
||||
<x-2fa/>
|
||||
<x-integration/>
|
||||
<x-api/>
|
||||
<x-import-export class="_vMargin"/>
|
||||
<x-drive class="_vMargin"/>
|
||||
<x-mute-block class="_vMargin"/>
|
||||
<x-word-mute class="_vMargin"/>
|
||||
<x-security class="_vMargin"/>
|
||||
<x-2fa class="_vMargin"/>
|
||||
<x-integration class="_vMargin"/>
|
||||
<x-api class="_vMargin"/>
|
||||
|
||||
<router-link class="_panel _buttonPrimary" to="/my/apps" style="margin: var(--margin) auto;">{{ $t('installedApps') }}</router-link>
|
||||
|
||||
|
|
|
@ -5,11 +5,11 @@
|
|||
|
||||
<router-link v-if="$store.getters.isSignedIn" class="_panel _buttonPrimary" to="/my/settings" style="margin-bottom: var(--margin);">{{ $t('accountSettings') }}</router-link>
|
||||
|
||||
<x-theme/>
|
||||
<x-theme class="_vMargin"/>
|
||||
|
||||
<x-sidebar/>
|
||||
<x-sidebar class="_vMargin"/>
|
||||
|
||||
<x-plugins/>
|
||||
<x-plugins class="_vMargin"/>
|
||||
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faMusic"/> {{ $t('sounds') }}</div>
|
||||
|
@ -50,6 +50,11 @@
|
|||
<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
|
||||
<template #text><button class="_textButton" @click="listen(sfxAntenna)" v-if="sfxAntenna"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
|
||||
</mk-select>
|
||||
<mk-select v-model="sfxChannel">
|
||||
<template #label>{{ $t('_sfx.channel') }}</template>
|
||||
<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
|
||||
<template #text><button class="_textButton" @click="listen(sfxChannel)" v-if="sfxChannel"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
|
||||
</mk-select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
@ -142,10 +147,14 @@ const sounds = [
|
|||
'syuilo/pirori',
|
||||
'syuilo/pirori-wet',
|
||||
'syuilo/pirori-square-wet',
|
||||
'syuilo/square-pico',
|
||||
'syuilo/reverved',
|
||||
'syuilo/ryukyu',
|
||||
'aisha/1',
|
||||
'aisha/2',
|
||||
'aisha/3',
|
||||
'noizenecio/kick_gaba',
|
||||
'noizenecio/kick_gaba2',
|
||||
];
|
||||
|
||||
export default Vue.extend({
|
||||
|
@ -272,6 +281,11 @@ export default Vue.extend({
|
|||
set(value) { this.$store.commit('device/set', { key: 'sfxAntenna', value }); }
|
||||
},
|
||||
|
||||
sfxChannel: {
|
||||
get() { return this.$store.state.device.sfxChannel; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'sfxChannel', value }); }
|
||||
},
|
||||
|
||||
volumeIcon: {
|
||||
get() {
|
||||
return this.sfxVolume === 0 ? faVolumeMute : faVolumeUp;
|
||||
|
|
|
@ -29,6 +29,10 @@ export const router = new VueRouter({
|
|||
{ path: '/explore', component: page('explore') },
|
||||
{ path: '/explore/tags/:tag', props: true, component: page('explore') },
|
||||
{ path: '/search', component: page('search') },
|
||||
{ path: '/channels', component: page('channels') },
|
||||
{ path: '/channels/new', component: page('channel-editor') },
|
||||
{ path: '/channels/:channelId/edit', component: page('channel-editor'), props: true },
|
||||
{ path: '/channels/:channelId', component: page('channel'), props: true },
|
||||
{ path: '/my/notifications', component: page('notifications') },
|
||||
{ path: '/my/favorites', component: page('favorites') },
|
||||
{ path: '/my/messages', component: page('messages') },
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import Vuex from 'vuex';
|
||||
import createPersistedState from 'vuex-persistedstate';
|
||||
import * as nestedProperty from 'nested-property';
|
||||
import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faSatelliteDish, faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faBell, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons';
|
||||
import { AiScript, utils, values } from '@syuilo/aiscript';
|
||||
import { apiUrl, deckmode } from './config';
|
||||
|
@ -90,6 +90,7 @@ export const defaultDeviceSettings = {
|
|||
sfxChat: 'syuilo/pope1',
|
||||
sfxChatBg: 'syuilo/waon',
|
||||
sfxAntenna: 'syuilo/triple',
|
||||
sfxChannel: 'syuilo/square-pico',
|
||||
userData: {},
|
||||
};
|
||||
|
||||
|
@ -213,6 +214,11 @@ export default () => new Vuex.Store({
|
|||
get show() { return getters.isSignedIn; },
|
||||
to: '/my/pages',
|
||||
},
|
||||
channels: {
|
||||
title: 'channel',
|
||||
icon: faSatelliteDish,
|
||||
to: '/channels',
|
||||
},
|
||||
games: {
|
||||
title: 'games',
|
||||
icon: faGamepad,
|
||||
|
|
|
@ -72,5 +72,6 @@
|
|||
X12: 'rgba(255, 255, 255, 0.1)',
|
||||
X13: 'rgba(255, 255, 255, 0.15)',
|
||||
X14: ':alpha<0.5<@navBg',
|
||||
X15: ':alpha<0<@panel',
|
||||
},
|
||||
}
|
||||
|
|
|
@ -72,5 +72,6 @@
|
|||
X12: 'rgba(0, 0, 0, 0.1)',
|
||||
X13: 'rgba(0, 0, 0, 0.15)',
|
||||
X14: ':alpha<0.5<@navBg',
|
||||
X15: ':alpha<0<@panel',
|
||||
},
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ import { FollowRequest } from '../models/entities/follow-request';
|
|||
import { Emoji } from '../models/entities/emoji';
|
||||
import { ReversiGame } from '../models/entities/games/reversi/game';
|
||||
import { ReversiMatching } from '../models/entities/games/reversi/matching';
|
||||
import { UserNotePining } from '../models/entities/user-note-pinings';
|
||||
import { UserNotePining } from '../models/entities/user-note-pining';
|
||||
import { Poll } from '../models/entities/poll';
|
||||
import { UserKeypair } from '../models/entities/user-keypair';
|
||||
import { UserPublickey } from '../models/entities/user-publickey';
|
||||
|
@ -60,6 +60,9 @@ import { PromoRead } from '../models/entities/promo-read';
|
|||
import { program } from '../argv';
|
||||
import { Relay } from '../models/entities/relay';
|
||||
import { MutedNote } from '../models/entities/muted-note';
|
||||
import { Channel } from '../models/entities/channel';
|
||||
import { ChannelFollowing } from '../models/entities/channel-following';
|
||||
import { ChannelNotePining } from '../models/entities/channel-note-pining';
|
||||
|
||||
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
|
||||
|
||||
|
@ -153,6 +156,9 @@ export const entities = [
|
|||
ReversiMatching,
|
||||
Relay,
|
||||
MutedNote,
|
||||
Channel,
|
||||
ChannelFollowing,
|
||||
ChannelNotePining,
|
||||
...charts as any
|
||||
];
|
||||
|
||||
|
|
|
@ -25,4 +25,6 @@ export const kinds = [
|
|||
'read:page-likes',
|
||||
'read:user-groups',
|
||||
'write:user-groups',
|
||||
'read:channels',
|
||||
'write:channels',
|
||||
];
|
||||
|
|
43
src/models/entities/channel-following.ts
Normal file
43
src/models/entities/channel-following.ts
Normal 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;
|
||||
}
|
35
src/models/entities/channel-note-pining.ts
Normal file
35
src/models/entities/channel-note-pining.ts
Normal 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;
|
||||
}
|
74
src/models/entities/channel.ts
Normal file
74
src/models/entities/channel.ts
Normal 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;
|
||||
}
|
|
@ -2,6 +2,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typ
|
|||
import { User } from './user';
|
||||
import { Note } from './note';
|
||||
import { id } from '../id';
|
||||
import { Channel } from './channel';
|
||||
|
||||
@Entity()
|
||||
@Index(['userId', 'noteId'], { unique: true })
|
||||
|
@ -29,15 +30,34 @@ export class NoteUnread {
|
|||
@JoinColumn()
|
||||
public note: Note | null;
|
||||
|
||||
/**
|
||||
* メンションか否か
|
||||
*/
|
||||
@Index()
|
||||
@Column('boolean')
|
||||
public isMentioned: boolean;
|
||||
|
||||
/**
|
||||
* ダイレクト投稿か否か
|
||||
*/
|
||||
@Index()
|
||||
@Column('boolean')
|
||||
public isSpecified: boolean;
|
||||
|
||||
//#region Denormalized fields
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public noteUserId: User['id'];
|
||||
|
||||
/**
|
||||
* ダイレクト投稿か
|
||||
*/
|
||||
@Column('boolean')
|
||||
public isSpecified: boolean;
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public noteChannelId: Channel['id'] | null;
|
||||
//#endregion
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { User } from './user';
|
|||
import { DriveFile } from './drive-file';
|
||||
import { id } from '../id';
|
||||
import { noteVisibilities } from '../../types';
|
||||
|
||||
import { Channel } from './channel';
|
||||
|
||||
@Entity()
|
||||
@Index('IDX_NOTE_TAGS', { synchronize: false })
|
||||
|
@ -173,6 +173,20 @@ export class Note {
|
|||
})
|
||||
public hasPoll: boolean;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true, default: null,
|
||||
comment: 'The ID of source channel.'
|
||||
})
|
||||
public channelId: Channel['id'] | null;
|
||||
|
||||
@ManyToOne(type => Channel, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public channel: Channel | null;
|
||||
|
||||
//#region Denormalized fields
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
|
|
|
@ -15,7 +15,7 @@ import { DriveFileRepository } from './repositories/drive-file';
|
|||
import { DriveFolderRepository } from './repositories/drive-folder';
|
||||
import { Log } from './entities/log';
|
||||
import { AccessToken } from './entities/access-token';
|
||||
import { UserNotePining } from './entities/user-note-pinings';
|
||||
import { UserNotePining } from './entities/user-note-pining';
|
||||
import { SigninRepository } from './repositories/signin';
|
||||
import { MessagingMessageRepository } from './repositories/messaging-message';
|
||||
import { ReversiGameRepository } from './repositories/games/reversi/game';
|
||||
|
@ -53,7 +53,10 @@ import { PromoNote } from './entities/promo-note';
|
|||
import { PromoRead } from './entities/promo-read';
|
||||
import { EmojiRepository } from './repositories/emoji';
|
||||
import { RelayRepository } from './repositories/relay';
|
||||
import { ChannelRepository } from './repositories/channel';
|
||||
import { MutedNote } from './entities/muted-note';
|
||||
import { ChannelFollowing } from './entities/channel-following';
|
||||
import { ChannelNotePining } from './entities/channel-note-pining';
|
||||
|
||||
export const Announcements = getRepository(Announcement);
|
||||
export const AnnouncementReads = getRepository(AnnouncementRead);
|
||||
|
@ -110,3 +113,6 @@ export const PromoNotes = getRepository(PromoNote);
|
|||
export const PromoReads = getRepository(PromoRead);
|
||||
export const Relays = getCustomRepository(RelayRepository);
|
||||
export const MutedNotes = getRepository(MutedNote);
|
||||
export const Channels = getCustomRepository(ChannelRepository);
|
||||
export const ChannelFollowings = getRepository(ChannelFollowing);
|
||||
export const ChannelNotePinings = getRepository(ChannelNotePining);
|
||||
|
|
101
src/models/repositories/channel.ts
Normal file
101
src/models/repositories/channel.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
|
@ -1,7 +1,7 @@
|
|||
import { EntityRepository, Repository, In } from 'typeorm';
|
||||
import { Note } from '../entities/note';
|
||||
import { User } from '../entities/user';
|
||||
import { Emojis, Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls } from '..';
|
||||
import { Emojis, Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls, Channels } from '..';
|
||||
import { ensure } from '../../prelude/ensure';
|
||||
import { SchemaType } from '../../misc/schema';
|
||||
import { awaitAll } from '../../prelude/await-all';
|
||||
|
@ -207,6 +207,12 @@ export class NoteRepository extends Repository<Note> {
|
|||
text = `【${note.name}】\n${(note.text || '').trim()}\n\n${note.url || note.uri}`;
|
||||
}
|
||||
|
||||
const channel = note.channelId
|
||||
? note.channel
|
||||
? note.channel
|
||||
: await Channels.findOne(note.channelId)
|
||||
: null;
|
||||
|
||||
const packed = await awaitAll({
|
||||
id: note.id,
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
|
@ -227,6 +233,11 @@ export class NoteRepository extends Repository<Note> {
|
|||
files: DriveFiles.packMany(note.fileIds),
|
||||
replyId: note.replyId,
|
||||
renoteId: note.renoteId,
|
||||
channelId: note.channelId || undefined,
|
||||
channel: channel ? {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
} : undefined,
|
||||
mentions: note.mentions.length > 0 ? note.mentions : undefined,
|
||||
uri: note.uri || undefined,
|
||||
url: note.url || undefined,
|
||||
|
@ -391,6 +402,16 @@ export const packedNoteSchema = {
|
|||
type: 'object' as const,
|
||||
optional: true as const, nullable: true as const,
|
||||
},
|
||||
|
||||
channelId: {
|
||||
type: 'string' as const,
|
||||
optional: true as const, nullable: true as const,
|
||||
format: 'id',
|
||||
example: 'xxxxxxxxxx',
|
||||
},
|
||||
channel: {
|
||||
type: 'object' as const,
|
||||
optional: true as const, nullable: true as const,
|
||||
ref: 'Channel'
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import $ from 'cafy';
|
||||
import { EntityRepository, Repository, In, Not } from 'typeorm';
|
||||
import { User, ILocalUser, IRemoteUser } from '../entities/user';
|
||||
import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes } from '..';
|
||||
import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes, ChannelFollowings } from '..';
|
||||
import { ensure } from '../../prelude/ensure';
|
||||
import config from '../../config';
|
||||
import { SchemaType } from '../../misc/schema';
|
||||
|
@ -107,6 +107,17 @@ export class UserRepository extends Repository<User> {
|
|||
return unread != null;
|
||||
}
|
||||
|
||||
public async getHasUnreadChannel(userId: User['id']): Promise<boolean> {
|
||||
const channels = await ChannelFollowings.find({ followerId: userId });
|
||||
|
||||
const unread = channels.length > 0 ? await NoteUnreads.findOne({
|
||||
userId: userId,
|
||||
noteChannelId: In(channels.map(x => x.id)),
|
||||
}) : null;
|
||||
|
||||
return unread != null;
|
||||
}
|
||||
|
||||
public async getHasUnreadNotification(userId: User['id']): Promise<boolean> {
|
||||
const mute = await Mutings.find({
|
||||
muterId: userId
|
||||
|
@ -139,7 +150,6 @@ export class UserRepository extends Repository<User> {
|
|||
options?: {
|
||||
detail?: boolean,
|
||||
includeSecrets?: boolean,
|
||||
includeHasUnreadNotes?: boolean
|
||||
}
|
||||
): Promise<PackedUser> {
|
||||
const opts = Object.assign({
|
||||
|
@ -181,17 +191,6 @@ export class UserRepository extends Repository<User> {
|
|||
select: ['name', 'host', 'url', 'aliases']
|
||||
}) : [],
|
||||
|
||||
...(opts.includeHasUnreadNotes ? {
|
||||
hasUnreadSpecifiedNotes: NoteUnreads.count({
|
||||
where: { userId: user.id, isSpecified: true },
|
||||
take: 1
|
||||
}).then(count => count > 0),
|
||||
hasUnreadMentions: NoteUnreads.count({
|
||||
where: { userId: user.id },
|
||||
take: 1
|
||||
}).then(count => count > 0),
|
||||
} : {}),
|
||||
|
||||
...(opts.detail ? {
|
||||
url: profile!.url,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
|
@ -233,8 +232,17 @@ export class UserRepository extends Repository<User> {
|
|||
alwaysMarkNsfw: profile!.alwaysMarkNsfw,
|
||||
carefulBot: profile!.carefulBot,
|
||||
autoAcceptFollowed: profile!.autoAcceptFollowed,
|
||||
hasUnreadSpecifiedNotes: NoteUnreads.count({
|
||||
where: { userId: user.id, isSpecified: true },
|
||||
take: 1
|
||||
}).then(count => count > 0),
|
||||
hasUnreadMentions: NoteUnreads.count({
|
||||
where: { userId: user.id, isMentioned: true },
|
||||
take: 1
|
||||
}).then(count => count > 0),
|
||||
hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id),
|
||||
hasUnreadAntenna: this.getHasUnreadAntenna(user.id),
|
||||
hasUnreadChannel: this.getHasUnreadChannel(user.id),
|
||||
hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id),
|
||||
hasUnreadNotification: this.getHasUnreadNotification(user.id),
|
||||
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
|
||||
|
@ -276,7 +284,6 @@ export class UserRepository extends Repository<User> {
|
|||
options?: {
|
||||
detail?: boolean,
|
||||
includeSecrets?: boolean,
|
||||
includeHasUnreadNotes?: boolean
|
||||
}
|
||||
) {
|
||||
return Promise.all(users.map(u => this.pack(u, me, options)));
|
||||
|
|
|
@ -15,7 +15,7 @@ import { updateUsertags } from '../../../services/update-hashtag';
|
|||
import { Users, UserNotePinings, Instances, DriveFiles, Followings, UserProfiles, UserPublickeys } from '../../../models';
|
||||
import { User, IRemoteUser } from '../../../models/entities/user';
|
||||
import { Emoji } from '../../../models/entities/emoji';
|
||||
import { UserNotePining } from '../../../models/entities/user-note-pinings';
|
||||
import { UserNotePining } from '../../../models/entities/user-note-pining';
|
||||
import { genId } from '../../../misc/gen-id';
|
||||
import { instanceChart, usersChart } from '../../../services/chart';
|
||||
import { UserPublickey } from '../../../models/entities/user-publickey';
|
||||
|
|
24
src/server/api/common/generate-channel-query.ts
Normal file
24
src/server/api/common/generate-channel-query.ts
Normal 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());
|
||||
}
|
||||
}
|
68
src/server/api/endpoints/channels/create.ts
Normal file
68
src/server/api/endpoints/channels/create.ts
Normal 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);
|
||||
});
|
28
src/server/api/endpoints/channels/featured.ts
Normal file
28
src/server/api/endpoints/channels/featured.ts
Normal 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)));
|
||||
});
|
45
src/server/api/endpoints/channels/follow.ts
Normal file
45
src/server/api/endpoints/channels/follow.ts
Normal 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,
|
||||
});
|
||||
});
|
28
src/server/api/endpoints/channels/followed.ts
Normal file
28
src/server/api/endpoints/channels/followed.ts
Normal 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)));
|
||||
});
|
28
src/server/api/endpoints/channels/owned.ts
Normal file
28
src/server/api/endpoints/channels/owned.ts
Normal 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)));
|
||||
});
|
0
src/server/api/endpoints/channels/pin-note.ts
Normal file
0
src/server/api/endpoints/channels/pin-note.ts
Normal file
43
src/server/api/endpoints/channels/show.ts
Normal file
43
src/server/api/endpoints/channels/show.ts
Normal 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);
|
||||
});
|
99
src/server/api/endpoints/channels/timeline.ts
Normal file
99
src/server/api/endpoints/channels/timeline.ts
Normal 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);
|
||||
});
|
42
src/server/api/endpoints/channels/unfollow.ts
Normal file
42
src/server/api/endpoints/channels/unfollow.ts
Normal 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,
|
||||
});
|
||||
});
|
93
src/server/api/endpoints/channels/update.ts
Normal file
93
src/server/api/endpoints/channels/update.ts
Normal 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);
|
||||
});
|
|
@ -24,7 +24,6 @@ export default define(meta, async (ps, user, token) => {
|
|||
|
||||
return await Users.pack(user, user, {
|
||||
detail: true,
|
||||
includeHasUnreadNotes: true,
|
||||
includeSecrets: isSecure
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,11 +7,12 @@ import { fetchMeta } from '../../../../misc/fetch-meta';
|
|||
import { ApiError } from '../../error';
|
||||
import { ID } from '../../../../misc/cafy-id';
|
||||
import { User } from '../../../../models/entities/user';
|
||||
import { Users, DriveFiles, Notes } from '../../../../models';
|
||||
import { Users, DriveFiles, Notes, Channels } from '../../../../models';
|
||||
import { DriveFile } from '../../../../models/entities/drive-file';
|
||||
import { Note } from '../../../../models/entities/note';
|
||||
import { DB_MAX_NOTE_TEXT_LENGTH } from '../../../../misc/hard-limits';
|
||||
import { noteVisibilities } from '../../../../types';
|
||||
import { Channel } from '../../../../models/entities/channel';
|
||||
|
||||
let maxNoteTextLength = 500;
|
||||
|
||||
|
@ -128,19 +129,26 @@ export const meta = {
|
|||
},
|
||||
|
||||
replyId: {
|
||||
validator: $.optional.type(ID),
|
||||
validator: $.optional.nullable.type(ID),
|
||||
desc: {
|
||||
'ja-JP': '返信対象'
|
||||
}
|
||||
},
|
||||
|
||||
renoteId: {
|
||||
validator: $.optional.type(ID),
|
||||
validator: $.optional.nullable.type(ID),
|
||||
desc: {
|
||||
'ja-JP': 'Renote対象'
|
||||
}
|
||||
},
|
||||
|
||||
channelId: {
|
||||
validator: $.optional.nullable.type(ID),
|
||||
desc: {
|
||||
'ja-JP': 'チャンネル'
|
||||
}
|
||||
},
|
||||
|
||||
poll: {
|
||||
validator: $.optional.obj({
|
||||
choices: $.arr($.str)
|
||||
|
@ -206,7 +214,13 @@ export const meta = {
|
|||
message: 'Poll is already expired.',
|
||||
code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
|
||||
id: '04da457d-b083-4055-9082-955525eda5a5'
|
||||
}
|
||||
},
|
||||
|
||||
noSuchChannel: {
|
||||
message: 'No such channel.',
|
||||
code: 'NO_SUCH_CHANNEL',
|
||||
id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb'
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -269,6 +283,15 @@ export default define(meta, async (ps, user) => {
|
|||
throw new ApiError(meta.errors.contentRequired);
|
||||
}
|
||||
|
||||
let channel: Channel | undefined;
|
||||
if (ps.channelId != null) {
|
||||
channel = await Channels.findOne(ps.channelId);
|
||||
|
||||
if (channel == null) {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
}
|
||||
|
||||
// 投稿を作成
|
||||
const note = await create(user, {
|
||||
createdAt: new Date(),
|
||||
|
@ -286,6 +309,7 @@ export default define(meta, async (ps, user) => {
|
|||
localOnly: ps.localOnly,
|
||||
visibility: ps.visibility,
|
||||
visibleUsers,
|
||||
channel,
|
||||
apMentions: ps.noExtractMentions ? [] : undefined,
|
||||
apHashtags: ps.noExtractHashtags ? [] : undefined,
|
||||
apEmojis: ps.noExtractEmojis ? [] : undefined,
|
||||
|
|
|
@ -80,6 +80,7 @@ export default define(meta, async (ps, user) => {
|
|||
const query = makePaginationQuery(Notes.createQueryBuilder('note'),
|
||||
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.visibility = \'public\'')
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.leftJoinAndSelect('note.user', 'user');
|
||||
|
||||
generateRepliesQuery(query, user);
|
||||
|
|
|
@ -13,6 +13,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query';
|
|||
import { injectPromo } from '../../common/inject-promo';
|
||||
import { injectFeatured } from '../../common/inject-featured';
|
||||
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
|
||||
import { generateChannelQuery } from '../../common/generate-channel-query';
|
||||
|
||||
export const meta = {
|
||||
desc: {
|
||||
|
@ -131,6 +132,7 @@ export default define(meta, async (ps, user) => {
|
|||
.leftJoinAndSelect('note.user', 'user')
|
||||
.setParameters(followingQuery.getParameters());
|
||||
|
||||
generateChannelQuery(query, user);
|
||||
generateRepliesQuery(query, user);
|
||||
generateVisibilityQuery(query, user);
|
||||
generateMutedUserQuery(query, user);
|
||||
|
|
|
@ -13,6 +13,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query';
|
|||
import { injectPromo } from '../../common/inject-promo';
|
||||
import { injectFeatured } from '../../common/inject-featured';
|
||||
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
|
||||
import { generateChannelQuery } from '../../common/generate-channel-query';
|
||||
|
||||
export const meta = {
|
||||
desc: {
|
||||
|
@ -99,6 +100,7 @@ export default define(meta, async (ps, user) => {
|
|||
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
|
||||
.leftJoinAndSelect('note.user', 'user');
|
||||
|
||||
generateChannelQuery(query, user);
|
||||
generateRepliesQuery(query, user);
|
||||
generateVisibilityQuery(query, user);
|
||||
if (user) generateMutedUserQuery(query, user);
|
||||
|
|
|
@ -11,6 +11,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query';
|
|||
import { injectPromo } from '../../common/inject-promo';
|
||||
import { injectFeatured } from '../../common/inject-featured';
|
||||
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
|
||||
import { generateChannelQuery } from '../../common/generate-channel-query';
|
||||
|
||||
export const meta = {
|
||||
desc: {
|
||||
|
@ -124,6 +125,7 @@ export default define(meta, async (ps, user) => {
|
|||
.leftJoinAndSelect('note.user', 'user')
|
||||
.setParameters(followingQuery.getParameters());
|
||||
|
||||
generateChannelQuery(query, user);
|
||||
generateRepliesQuery(query, user);
|
||||
generateVisibilityQuery(query, user);
|
||||
generateMutedUserQuery(query, user);
|
||||
|
|
|
@ -27,6 +27,10 @@ export default abstract class Channel {
|
|||
return this.connection.muting;
|
||||
}
|
||||
|
||||
protected get followingChannels() {
|
||||
return this.connection.followingChannels;
|
||||
}
|
||||
|
||||
protected get subscriber() {
|
||||
return this.connection.subscriber;
|
||||
}
|
||||
|
|
49
src/server/api/stream/channels/channel.ts
Normal file
49
src/server/api/stream/channels/channel.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -25,6 +25,7 @@ export default class extends Channel {
|
|||
@autobind
|
||||
private async onNote(note: PackedNote) {
|
||||
if (note.visibility !== 'public') return;
|
||||
if (note.channelId != null) return;
|
||||
|
||||
// リプライなら再pack
|
||||
if (note.replyId != null) {
|
||||
|
|
|
@ -18,8 +18,12 @@ export default class extends Channel {
|
|||
|
||||
@autobind
|
||||
private async onNote(note: PackedNote) {
|
||||
// その投稿のユーザーをフォローしていなかったら弾く
|
||||
if (this.user!.id !== note.userId && !this.following.includes(note.userId)) return;
|
||||
if (note.channelId) {
|
||||
if (!this.followingChannels.includes(note.channelId)) return;
|
||||
} else {
|
||||
// その投稿のユーザーをフォローしていなかったら弾く
|
||||
if ((this.user!.id !== note.userId) && !this.following.includes(note.userId)) return;
|
||||
}
|
||||
|
||||
if (['followers', 'specified'].includes(note.visibility)) {
|
||||
note = await Notes.pack(note.id, this.user!, {
|
||||
|
|
|
@ -23,11 +23,15 @@ export default class extends Channel {
|
|||
|
||||
@autobind
|
||||
private async onNote(note: PackedNote) {
|
||||
// 自分自身の投稿 または その投稿のユーザーをフォローしている または 全体公開のローカルの投稿 の場合だけ
|
||||
// チャンネルの投稿ではなく、自分自身の投稿 または
|
||||
// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
|
||||
// チャンネルの投稿ではなく、全体公開のローカルの投稿 または
|
||||
// フォローしているチャンネルの投稿 の場合だけ
|
||||
if (!(
|
||||
this.user!.id === note.userId ||
|
||||
this.following.includes(note.userId) ||
|
||||
((note.user as PackedUser).host == null && note.visibility === 'public')
|
||||
(note.channelId == null && this.user!.id === note.userId) ||
|
||||
(note.channelId == null && this.following.includes(note.userId)) ||
|
||||
(note.channelId == null && ((note.user as PackedUser).host == null && note.visibility === 'public')) ||
|
||||
(note.channelId != null && this.followingChannels.includes(note.channelId))
|
||||
)) return;
|
||||
|
||||
if (['followers', 'specified'].includes(note.visibility)) {
|
||||
|
|
|
@ -11,6 +11,7 @@ import messaging from './messaging';
|
|||
import messagingIndex from './messaging-index';
|
||||
import drive from './drive';
|
||||
import hashtag from './hashtag';
|
||||
import channel from './channel';
|
||||
import admin from './admin';
|
||||
import gamesReversi from './games/reversi';
|
||||
import gamesReversiGame from './games/reversi-game';
|
||||
|
@ -29,6 +30,7 @@ export default {
|
|||
messagingIndex,
|
||||
drive,
|
||||
hashtag,
|
||||
channel,
|
||||
admin,
|
||||
gamesReversi,
|
||||
gamesReversiGame
|
||||
|
|
|
@ -27,6 +27,7 @@ export default class extends Channel {
|
|||
private async onNote(note: PackedNote) {
|
||||
if ((note.user as PackedUser).host !== null) return;
|
||||
if (note.visibility !== 'public') return;
|
||||
if (note.channelId != null && !this.followingChannels.includes(note.channelId)) return;
|
||||
|
||||
// リプライなら再pack
|
||||
if (note.replyId != null) {
|
||||
|
|
|
@ -7,7 +7,8 @@ import Channel from './channel';
|
|||
import channels from './channels';
|
||||
import { EventEmitter } from 'events';
|
||||
import { User } from '../../../models/entities/user';
|
||||
import { Users, Followings, Mutings, UserProfiles } from '../../../models';
|
||||
import { Channel as ChannelModel } from '../../../models/entities/channel';
|
||||
import { Users, Followings, Mutings, UserProfiles, ChannelFollowings } from '../../../models';
|
||||
import { ApiError } from '../error';
|
||||
import { AccessToken } from '../../../models/entities/access-token';
|
||||
import { UserProfile } from '../../../models/entities/user-profile';
|
||||
|
@ -20,6 +21,7 @@ export default class Connection {
|
|||
public userProfile?: UserProfile;
|
||||
public following: User['id'][] = [];
|
||||
public muting: User['id'][] = [];
|
||||
public followingChannels: ChannelModel['id'][] = [];
|
||||
public token?: AccessToken;
|
||||
private wsConnection: websocket.connection;
|
||||
public subscriber: EventEmitter;
|
||||
|
@ -27,6 +29,7 @@ export default class Connection {
|
|||
private subscribingNotes: any = {};
|
||||
private followingClock: NodeJS.Timer;
|
||||
private mutingClock: NodeJS.Timer;
|
||||
private followingChannelsClock: NodeJS.Timer;
|
||||
private userProfileClock: NodeJS.Timer;
|
||||
|
||||
constructor(
|
||||
|
@ -53,6 +56,9 @@ export default class Connection {
|
|||
this.updateMuting();
|
||||
this.mutingClock = setInterval(this.updateMuting, 5000);
|
||||
|
||||
this.updateFollowingChannels();
|
||||
this.followingChannelsClock = setInterval(this.updateFollowingChannels, 5000);
|
||||
|
||||
this.updateUserProfile();
|
||||
this.userProfileClock = setInterval(this.updateUserProfile, 5000);
|
||||
}
|
||||
|
@ -268,6 +274,18 @@ export default class Connection {
|
|||
this.muting = mutings.map(x => x.muteeId);
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async updateFollowingChannels() {
|
||||
const followings = await ChannelFollowings.find({
|
||||
where: {
|
||||
followerId: this.user!.id
|
||||
},
|
||||
select: ['followeeId']
|
||||
});
|
||||
|
||||
this.followingChannels = followings.map(x => x.followeeId);
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async updateUserProfile() {
|
||||
this.userProfile = await UserProfiles.findOne({
|
||||
|
@ -286,6 +304,7 @@ export default class Connection {
|
|||
|
||||
if (this.followingClock) clearInterval(this.followingClock);
|
||||
if (this.mutingClock) clearInterval(this.mutingClock);
|
||||
if (this.followingChannelsClock) clearInterval(this.followingChannelsClock);
|
||||
if (this.userProfileClock) clearInterval(this.userProfileClock);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ import packFeed from './feed';
|
|||
import { fetchMeta } from '../../misc/fetch-meta';
|
||||
import { genOpenapiSpec } from '../api/openapi/gen-spec';
|
||||
import config from '../../config';
|
||||
import { Users, Notes, Emojis, UserProfiles, Pages } from '../../models';
|
||||
import { Users, Notes, Emojis, UserProfiles, Pages, Channels } from '../../models';
|
||||
import parseAcct from '../../misc/acct/parse';
|
||||
import getNoteSummary from '../../misc/get-note-summary';
|
||||
import { ensure } from '../../prelude/ensure';
|
||||
|
@ -188,7 +188,7 @@ router.get('/@:user.json', async ctx => {
|
|||
}
|
||||
});
|
||||
|
||||
//#region for crawlers
|
||||
//#region SSR (for crawlers)
|
||||
// User
|
||||
router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => {
|
||||
const { username, host } = parseAcct(ctx.params.user);
|
||||
|
@ -297,6 +297,28 @@ router.get('/@:user/pages/:page', async ctx => {
|
|||
|
||||
ctx.status = 404;
|
||||
});
|
||||
|
||||
// Channel
|
||||
router.get('/channels/:channel', async ctx => {
|
||||
const channel = await Channels.findOne({
|
||||
id: ctx.params.channel,
|
||||
});
|
||||
|
||||
if (channel) {
|
||||
const _channel = await Channels.pack(channel);
|
||||
const meta = await fetchMeta();
|
||||
await ctx.render('channel', {
|
||||
channel: _channel,
|
||||
instanceName: meta.name || 'Misskey'
|
||||
});
|
||||
|
||||
ctx.set('Cache-Control', 'public, max-age=180');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.status = 404;
|
||||
});
|
||||
//#endregion
|
||||
|
||||
router.get('/info', async ctx => {
|
||||
|
|
21
src/server/web/views/channel.pug
Normal file
21
src/server/web/views/channel.pug
Normal 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')
|
|
@ -28,6 +28,7 @@ export async function addNoteToAntenna(antenna: Antenna, note: Note, noteUser: U
|
|||
select: ['muteeId']
|
||||
});
|
||||
|
||||
// Copy
|
||||
const _note: Note = {
|
||||
...note
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@ import { IdentifiableError } from '../../misc/identifiable-error';
|
|||
import { User } from '../../models/entities/user';
|
||||
import { Note } from '../../models/entities/note';
|
||||
import { Notes, UserNotePinings, Users } from '../../models';
|
||||
import { UserNotePining } from '../../models/entities/user-note-pinings';
|
||||
import { UserNotePining } from '../../models/entities/user-note-pining';
|
||||
import { genId } from '../../misc/gen-id';
|
||||
import { deliverToFollowers } from '../../remote/activitypub/deliver-manager';
|
||||
import { deliverToRelays } from '../relay';
|
||||
|
|
|
@ -17,7 +17,7 @@ import extractMentions from '../../misc/extract-mentions';
|
|||
import extractEmojis from '../../misc/extract-emojis';
|
||||
import extractHashtags from '../../misc/extract-hashtags';
|
||||
import { Note, IMentionedRemoteUsers } from '../../models/entities/note';
|
||||
import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes } from '../../models';
|
||||
import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes, Channels, ChannelFollowings } from '../../models';
|
||||
import { DriveFile } from '../../models/entities/drive-file';
|
||||
import { App } from '../../models/entities/app';
|
||||
import { Not, getConnection, In } from 'typeorm';
|
||||
|
@ -33,6 +33,7 @@ import { checkWordMute } from '../../misc/check-word-mute';
|
|||
import { addNoteToAntenna } from '../add-note-to-antenna';
|
||||
import { countSameRenotes } from '../../misc/count-same-renotes';
|
||||
import { deliverToRelays } from '../relay';
|
||||
import { Channel } from '../../models/entities/channel';
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
|
@ -102,6 +103,7 @@ type Option = {
|
|||
cw?: string | null;
|
||||
visibility?: string;
|
||||
visibleUsers?: User[] | null;
|
||||
channel?: Channel | null;
|
||||
apMentions?: User[] | null;
|
||||
apHashtags?: string[] | null;
|
||||
apEmojis?: string[] | null;
|
||||
|
@ -111,13 +113,31 @@ type Option = {
|
|||
};
|
||||
|
||||
export default async (user: User, data: Option, silent = false) => new Promise<Note>(async (res, rej) => {
|
||||
// チャンネル外にリプライしたら対象のスコープに合わせる
|
||||
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
|
||||
if (data.reply && data.channel && data.reply.channelId !== data.channel.id) {
|
||||
if (data.reply.channelId) {
|
||||
data.channel = await Channels.findOne(data.reply.channelId);
|
||||
} else {
|
||||
data.channel = null;
|
||||
}
|
||||
}
|
||||
|
||||
// チャンネル内にリプライしたら対象のスコープに合わせる
|
||||
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
|
||||
if (data.reply && (data.channel == null) && data.reply.channelId) {
|
||||
data.channel = await Channels.findOne(data.reply.channelId);
|
||||
}
|
||||
|
||||
if (data.createdAt == null) data.createdAt = new Date();
|
||||
if (data.visibility == null) data.visibility = 'public';
|
||||
if (data.viaMobile == null) data.viaMobile = false;
|
||||
if (data.localOnly == null) data.localOnly = false;
|
||||
if (data.channel != null) data.visibility = 'public';
|
||||
if (data.channel != null) data.visibleUsers = [];
|
||||
|
||||
// サイレンス
|
||||
if (user.isSilenced && data.visibility === 'public') {
|
||||
if (user.isSilenced && data.visibility === 'public' && data.channel == null) {
|
||||
data.visibility = 'home';
|
||||
}
|
||||
|
||||
|
@ -142,12 +162,12 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
|
|||
}
|
||||
|
||||
// ローカルのみをRenoteしたらローカルのみにする
|
||||
if (data.renote && data.renote.localOnly) {
|
||||
if (data.renote && data.renote.localOnly && data.channel == null) {
|
||||
data.localOnly = true;
|
||||
}
|
||||
|
||||
// ローカルのみにリプライしたらローカルのみにする
|
||||
if (data.reply && data.reply.localOnly) {
|
||||
if (data.reply && data.reply.localOnly && data.channel == null) {
|
||||
data.localOnly = true;
|
||||
}
|
||||
|
||||
|
@ -255,6 +275,18 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
|
|||
}
|
||||
});
|
||||
|
||||
// Channel
|
||||
if (note.channelId) {
|
||||
ChannelFollowings.find({ followeeId: note.channelId }).then(followings => {
|
||||
for (const following of followings) {
|
||||
insertNoteUnread(following.followerId, note, {
|
||||
isSpecified: false,
|
||||
isMentioned: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (data.reply) {
|
||||
saveReply(data.reply, note);
|
||||
}
|
||||
|
@ -273,11 +305,23 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
|
|||
if (data.visibleUsers == null) throw new Error('invalid param');
|
||||
|
||||
for (const u of data.visibleUsers) {
|
||||
insertNoteUnread(u, note, true);
|
||||
// ローカルユーザーのみ
|
||||
if (!Users.isLocalUser(u)) continue;
|
||||
|
||||
insertNoteUnread(u.id, note, {
|
||||
isSpecified: true,
|
||||
isMentioned: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const u of mentionedUsers) {
|
||||
insertNoteUnread(u, note, false);
|
||||
// ローカルユーザーのみ
|
||||
if (!Users.isLocalUser(u)) continue;
|
||||
|
||||
insertNoteUnread(u.id, note, {
|
||||
isSpecified: false,
|
||||
isMentioned: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -379,6 +423,24 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
|
|||
//#endregion
|
||||
}
|
||||
|
||||
if (data.channel) {
|
||||
Channels.increment({ id: data.channel.id }, 'notesCount', 1);
|
||||
Channels.update(data.channel.id, {
|
||||
lastNotedAt: new Date(),
|
||||
});
|
||||
|
||||
Notes.count({
|
||||
userId: user.id,
|
||||
channelId: data.channel.id,
|
||||
}).then(count => {
|
||||
// この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる
|
||||
// TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい
|
||||
if (count === 1) {
|
||||
Channels.increment({ id: data.channel.id }, 'usersCount', 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Register to search database
|
||||
index(note);
|
||||
});
|
||||
|
@ -405,6 +467,7 @@ async function insertNote(user: User, data: Option, tags: string[], emojis: stri
|
|||
fileIds: data.files ? data.files.map(file => file.id) : [],
|
||||
replyId: data.reply ? data.reply.id : null,
|
||||
renoteId: data.renote ? data.renote.id : null,
|
||||
channelId: data.channel ? data.channel.id : null,
|
||||
name: data.name,
|
||||
text: data.text,
|
||||
hasPoll: data.poll != null,
|
||||
|
|
|
@ -2,71 +2,104 @@ import { publishMainStream } from '../stream';
|
|||
import { Note } from '../../models/entities/note';
|
||||
import { User } from '../../models/entities/user';
|
||||
import { NoteUnreads, Antennas, AntennaNotes, Users } from '../../models';
|
||||
|
||||
// TODO: 状態が変化していない場合は各種イベントを送信しない
|
||||
import { Not, IsNull } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Mark a note as read
|
||||
*/
|
||||
export default (
|
||||
export default async function(
|
||||
userId: User['id'],
|
||||
noteId: Note['id']
|
||||
) => new Promise<any>(async (resolve, reject) => {
|
||||
// Remove document
|
||||
/*const res = */await NoteUnreads.delete({
|
||||
userId: userId,
|
||||
noteId: noteId
|
||||
});
|
||||
|
||||
// v11 TODO: https://github.com/typeorm/typeorm/issues/2415
|
||||
//if (res.affected === 0) {
|
||||
// return;
|
||||
//}
|
||||
|
||||
const [count1, count2] = await Promise.all([
|
||||
NoteUnreads.count({
|
||||
) {
|
||||
async function careNoteUnreads() {
|
||||
const exist = await NoteUnreads.findOne({
|
||||
userId: userId,
|
||||
isSpecified: false
|
||||
}),
|
||||
NoteUnreads.count({
|
||||
userId: userId,
|
||||
isSpecified: true
|
||||
})
|
||||
]);
|
||||
|
||||
if (count1 === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
publishMainStream(userId, 'readAllUnreadMentions');
|
||||
}
|
||||
|
||||
if (count2 === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
|
||||
}
|
||||
|
||||
const antennas = await Antennas.find({ userId });
|
||||
|
||||
await Promise.all(antennas.map(async antenna => {
|
||||
await AntennaNotes.update({
|
||||
antennaId: antenna.id,
|
||||
noteId: noteId
|
||||
}, {
|
||||
read: true
|
||||
noteId: noteId,
|
||||
});
|
||||
|
||||
const count = await AntennaNotes.count({
|
||||
antennaId: antenna.id,
|
||||
read: false
|
||||
if (!exist) return;
|
||||
|
||||
// Remove the record
|
||||
await NoteUnreads.delete({
|
||||
userId: userId,
|
||||
noteId: noteId,
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
publishMainStream(userId, 'readAntenna', antenna);
|
||||
if (exist.isMentioned) {
|
||||
NoteUnreads.count({
|
||||
userId: userId,
|
||||
isMentioned: true
|
||||
}).then(mentionsCount => {
|
||||
if (mentionsCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
publishMainStream(userId, 'readAllUnreadMentions');
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
Users.getHasUnreadAntenna(userId).then(unread => {
|
||||
if (!unread) {
|
||||
publishMainStream(userId, 'readAllAntennas');
|
||||
if (exist.isSpecified) {
|
||||
NoteUnreads.count({
|
||||
userId: userId,
|
||||
isSpecified: true
|
||||
}).then(specifiedCount => {
|
||||
if (specifiedCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (exist.noteChannelId) {
|
||||
NoteUnreads.count({
|
||||
userId: userId,
|
||||
noteChannelId: Not(IsNull())
|
||||
}).then(channelNoteCount => {
|
||||
if (channelNoteCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
publishMainStream(userId, 'readAllChannels');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function careAntenna() {
|
||||
const beforeUnread = await Users.getHasUnreadAntenna(userId);
|
||||
if (!beforeUnread) return;
|
||||
|
||||
const antennas = await Antennas.find({ userId });
|
||||
|
||||
await Promise.all(antennas.map(async antenna => {
|
||||
const countBefore = await AntennaNotes.count({
|
||||
antennaId: antenna.id,
|
||||
read: false
|
||||
});
|
||||
|
||||
if (countBefore === 0) return;
|
||||
|
||||
await AntennaNotes.update({
|
||||
antennaId: antenna.id,
|
||||
noteId: noteId
|
||||
}, {
|
||||
read: true
|
||||
});
|
||||
|
||||
const countAfter = await AntennaNotes.count({
|
||||
antennaId: antenna.id,
|
||||
read: false
|
||||
});
|
||||
|
||||
if (countAfter === 0) {
|
||||
publishMainStream(userId, 'readAntenna', antenna);
|
||||
}
|
||||
}));
|
||||
|
||||
Users.getHasUnreadAntenna(userId).then(unread => {
|
||||
if (!unread) {
|
||||
publishMainStream(userId, 'readAllAntennas');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
careNoteUnreads();
|
||||
careAntenna();
|
||||
}
|
||||
|
|
|
@ -1,16 +1,18 @@
|
|||
import { Note } from '../../models/entities/note';
|
||||
import { publishMainStream } from '../stream';
|
||||
import { User } from '../../models/entities/user';
|
||||
import { Mutings, NoteUnreads, Users } from '../../models';
|
||||
import { Mutings, NoteUnreads } from '../../models';
|
||||
import { genId } from '../../misc/gen-id';
|
||||
|
||||
export default async function(user: User, note: Note, isSpecified = false) {
|
||||
// ローカルユーザーのみ
|
||||
if (!Users.isLocalUser(user)) return;
|
||||
|
||||
export default async function(userId: User['id'], note: Note, params: {
|
||||
// NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
|
||||
isSpecified: boolean;
|
||||
isMentioned: boolean;
|
||||
}) {
|
||||
//#region ミュートしているなら無視
|
||||
// TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする
|
||||
const mute = await Mutings.find({
|
||||
muterId: user.id
|
||||
muterId: userId
|
||||
});
|
||||
if (mute.map(m => m.muteeId).includes(note.userId)) return;
|
||||
//#endregion
|
||||
|
@ -18,20 +20,27 @@ export default async function(user: User, note: Note, isSpecified = false) {
|
|||
const unread = await NoteUnreads.save({
|
||||
id: genId(),
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
isSpecified,
|
||||
noteUserId: note.userId
|
||||
userId: userId,
|
||||
isSpecified: params.isSpecified,
|
||||
isMentioned: params.isMentioned,
|
||||
noteChannelId: note.channelId,
|
||||
noteUserId: note.userId,
|
||||
});
|
||||
|
||||
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
|
||||
setTimeout(async () => {
|
||||
const exist = await NoteUnreads.findOne(unread.id);
|
||||
|
||||
if (exist == null) return;
|
||||
|
||||
publishMainStream(user.id, 'unreadMention', note.id);
|
||||
|
||||
if (isSpecified) {
|
||||
publishMainStream(user.id, 'unreadSpecifiedNote', note.id);
|
||||
if (params.isMentioned) {
|
||||
publishMainStream(userId, 'unreadMention', note.id);
|
||||
}
|
||||
if (params.isSpecified) {
|
||||
publishMainStream(userId, 'unreadSpecifiedNote', note.id);
|
||||
}
|
||||
if (note.channelId) {
|
||||
publishMainStream(userId, 'unreadChannel', note.id);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue