diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index dc0692e4b9..437fd39971 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -265,6 +265,7 @@ common: my-token-regenerated: "あなたのトークンが更新されたのでサインアウトします。" hide-password: "パスワードを隠す" show-password: "パスワードを表示する" + enter-username: "ユーザー名を入力してください" do-not-use-in-production: "これは開発ビルドです。本番環境で使用しないでください。" user-suspended: "このユーザーは凍結されています。" @@ -480,20 +481,24 @@ common/views/components/messaging.vue: search-user: "ユーザーを探す" you: "あなた" no-history: "履歴はありません" + user: "ユーザー" + group: "グループ" + start-with-user: "ユーザーとトークを開始" + start-with-group: "グループとトークを開始" common/views/components/messaging-room.vue: - empty: "このユーザーと話したことはありません" + not-talked-user: "このユーザーとの会話はありません" + not-talked-group: "このグループでの会話はありません" no-history: "これより過去の履歴はありません" - resize-form: "ドラッグしてフォームの広さを調整" new-message: "新しいメッセージがあります" - only-one-file-attached: "メッセージに添付できるのはひとつのファイルのみです" + only-one-file-attached: "メッセージに添付できるファイルはひとつです" common/views/components/messaging-room.form.vue: input-message-here: "ここにメッセージを入力" send: "送信" attach-from-local: "PCからファイルを添付する" attach-from-drive: "ドライブからファイルを添付する" - only-one-file-attached: "メッセージに添付できるのはひとつのファイルのみです" + only-one-file-attached: "メッセージに添付できるファイルはひとつです" common/views/components/messaging-room.message.vue: is-read: "既読" @@ -750,11 +755,27 @@ common/views/components/user-list-editor.vue: remove-user: "このリストから削除" delete-are-you-sure: "リスト「$1」を削除しますか?" deleted: "削除しました" + add-user: "ユーザーを追加" + +common/views/components/user-group-editor.vue: + users: "メンバー" + rename: "グループ名を変更" + delete: "グループを削除" + remove-user: "このグループから削除" + delete-are-you-sure: "グループ「$1」を削除しますか?" + deleted: "削除しました" + add-user: "メンバーを追加" common/views/components/user-lists.vue: + user-lists: "リスト" create-list: "リストを作成" list-name: "リスト名" +common/views/components/user-groups.vue: + user-groups: "グループ" + create-group: "グループを作成" + group-name: "グループ名" + common/views/widgets/broadcast.vue: fetching: "確認中" no-broadcasts: "お知らせはありません" @@ -827,6 +848,11 @@ common/views/pages/follow.vue: follow-processing: "フォロー処理中" follow-request: "フォロー申請" +common/views/pages/follow-requests.vue: + received-follow-requests: "フォロー申請" + accept: "承認" + reject: "拒否" + desktop: banner-crop-title: "バナーとして表示する部分を選択" banner: "バナー" @@ -1139,6 +1165,7 @@ desktop/views/components/ui.header.vue: desktop/views/components/ui.header.account.vue: profile: "プロフィール" lists: "リスト" + groups: "グループ" follow-requests: "フォロー申請" admin: "管理" @@ -1154,14 +1181,6 @@ desktop/views/components/ui.header.post.vue: desktop/views/components/ui.header.search.vue: placeholder: "検索" -desktop/views/components/received-follow-requests-window.vue: - title: "フォロー申請" - accept: "承認" - reject: "拒否" - -desktop/views/components/user-lists-window.vue: - title: "リスト" - desktop/views/components/user-preview.vue: notes: "投稿" following: "フォロー" @@ -1749,11 +1768,6 @@ mobile/views/pages/widgets/activity.vue: mobile/views/pages/share.vue: share-with: "{name}で共有" -mobile/views/pages/received-follow-requests.vue: - title: "フォロー申請" - accept: "承認" - reject: "拒否" - mobile/views/pages/note.vue: title: "投稿" prev: "前の投稿" diff --git a/migration/1558103093633-UserGroup.ts b/migration/1558103093633-UserGroup.ts new file mode 100644 index 0000000000..04783b8dfa --- /dev/null +++ b/migration/1558103093633-UserGroup.ts @@ -0,0 +1,41 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class UserGroup1558103093633 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise<any> { + await queryRunner.query(`CREATE TABLE "user_group" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(256) NOT NULL, "userId" character varying(32) NOT NULL, "isPrivate" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_3c29fba6fe013ec8724378ce7c9" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_20e30aa35180e317e133d75316" ON "user_group" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_3d6b372788ab01be58853003c9" ON "user_group" ("userId") `); + await queryRunner.query(`CREATE TABLE "user_group_joining" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "userGroupId" character varying(32) NOT NULL, CONSTRAINT "PK_15f2425885253c5507e1599cfe7" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_f3a1b4bd0c7cabba958a0c0b23" ON "user_group_joining" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_67dc758bc0566985d1b3d39986" ON "user_group_joining" ("userGroupId") `); + await queryRunner.query(`ALTER TABLE "messaging_message" ADD "groupId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "messaging_message" ADD "reads" character varying(32) array NOT NULL DEFAULT '{}'::varchar[]`); + await queryRunner.query(`ALTER TABLE "messaging_message" ALTER COLUMN "recipientId" DROP NOT NULL`); + await queryRunner.query(`COMMENT ON COLUMN "messaging_message"."recipientId" IS 'The recipient user ID.'`); + await queryRunner.query(`CREATE INDEX "IDX_2c4be03b446884f9e9c502135b" ON "messaging_message" ("groupId") `); + await queryRunner.query(`ALTER TABLE "messaging_message" ADD CONSTRAINT "FK_2c4be03b446884f9e9c502135be" FOREIGN KEY ("groupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_group" ADD CONSTRAINT "FK_3d6b372788ab01be58853003c93" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_group_joining" ADD CONSTRAINT "FK_f3a1b4bd0c7cabba958a0c0b231" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_group_joining" ADD CONSTRAINT "FK_67dc758bc0566985d1b3d399865" FOREIGN KEY ("userGroupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise<any> { + await queryRunner.query(`ALTER TABLE "user_group_joining" DROP CONSTRAINT "FK_67dc758bc0566985d1b3d399865"`); + await queryRunner.query(`ALTER TABLE "user_group_joining" DROP CONSTRAINT "FK_f3a1b4bd0c7cabba958a0c0b231"`); + await queryRunner.query(`ALTER TABLE "user_group" DROP CONSTRAINT "FK_3d6b372788ab01be58853003c93"`); + await queryRunner.query(`ALTER TABLE "messaging_message" DROP CONSTRAINT "FK_2c4be03b446884f9e9c502135be"`); + await queryRunner.query(`DROP INDEX "IDX_2c4be03b446884f9e9c502135b"`); + await queryRunner.query(`COMMENT ON COLUMN "messaging_message"."recipientId" IS ''`); + await queryRunner.query(`ALTER TABLE "messaging_message" ALTER COLUMN "recipientId" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "messaging_message" DROP COLUMN "reads"`); + await queryRunner.query(`ALTER TABLE "messaging_message" DROP COLUMN "groupId"`); + await queryRunner.query(`DROP INDEX "IDX_67dc758bc0566985d1b3d39986"`); + await queryRunner.query(`DROP INDEX "IDX_f3a1b4bd0c7cabba958a0c0b23"`); + await queryRunner.query(`DROP TABLE "user_group_joining"`); + await queryRunner.query(`DROP INDEX "IDX_3d6b372788ab01be58853003c9"`); + await queryRunner.query(`DROP INDEX "IDX_20e30aa35180e317e133d75316"`); + await queryRunner.query(`DROP TABLE "user_group"`); + } + +} diff --git a/src/client/app/common/views/components/dialog.vue b/src/client/app/common/views/components/dialog.vue index f22e0174b3..9f38031d62 100644 --- a/src/client/app/common/views/components/dialog.vue +++ b/src/client/app/common/views/components/dialog.vue @@ -18,6 +18,7 @@ <fa icon="spinner" pulse v-if="type === 'waiting'"/> </div> <header v-if="title" v-html="title"></header> + <header v-if="title == null && user">{{ $t('@.enter-username') }}</header> <div class="body" v-if="text" v-html="text"></div> <ui-input v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></ui-input> <ui-input v-if="user" v-model="userInputValue" autofocus @keydown="onInputKeydown"><template #prefix>@</template></ui-input> diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts index f4d40f9b1a..174fa36c00 100644 --- a/src/client/app/common/views/components/index.ts +++ b/src/client/app/common/views/components/index.ts @@ -44,6 +44,8 @@ import uiSwitch from './ui/switch.vue'; import uiRadio from './ui/radio.vue'; import uiSelect from './ui/select.vue'; import uiInfo from './ui/info.vue'; +import uiMargin from './ui/margin.vue'; +import uiHr from './ui/hr.vue'; import formButton from './ui/form/button.vue'; import formRadio from './ui/form/radio.vue'; @@ -91,5 +93,7 @@ Vue.component('ui-switch', uiSwitch); Vue.component('ui-radio', uiRadio); Vue.component('ui-select', uiSelect); Vue.component('ui-info', uiInfo); +Vue.component('ui-margin', uiMargin); +Vue.component('ui-hr', uiHr); Vue.component('form-button', formButton); Vue.component('form-radio', formRadio); diff --git a/src/client/app/common/views/components/messaging-room.form.vue b/src/client/app/common/views/components/messaging-room.form.vue index ee6c312bce..1dfb0589e4 100644 --- a/src/client/app/common/views/components/messaging-room.form.vue +++ b/src/client/app/common/views/components/messaging-room.form.vue @@ -33,7 +33,16 @@ import * as autosize from 'autosize'; export default Vue.extend({ i18n: i18n('common/views/components/messaging-room.form.vue'), - props: ['user'], + props: { + user: { + type: Object, + requird: false, + }, + group: { + type: Object, + requird: false, + }, + }, data() { return { text: null, @@ -43,7 +52,7 @@ export default Vue.extend({ }, computed: { draftId(): string { - return this.user.id; + return this.user ? 'user:' + this.user.id : 'group:' + this.group.id; }, canSend(): boolean { return (this.text != null && this.text != '') || this.file != null; @@ -159,7 +168,8 @@ export default Vue.extend({ send() { this.sending = true; this.$root.api('messaging/messages/create', { - userId: this.user.id, + userId: this.user ? this.user.id : undefined, + groupId: this.group ? this.group.id : undefined, text: this.text ? this.text : undefined, fileId: this.file ? this.file.id : undefined }).then(message => { diff --git a/src/client/app/common/views/components/messaging-room.message.vue b/src/client/app/common/views/components/messaging-room.message.vue index 908533e0cc..aff89c2573 100644 --- a/src/client/app/common/views/components/messaging-room.message.vue +++ b/src/client/app/common/views/components/messaging-room.message.vue @@ -23,7 +23,12 @@ <div></div> <mk-url-preview v-for="url in urls" :url="url" :key="url"/> <footer> - <span class="read" v-if="isMe && message.isRead">{{ $t('is-read') }}</span> + <template v-if="isGroup"> + <span class="read" v-if="message.reads.length > 0">{{ $t('is-read') }} {{ message.reads.length }}</span> + </template> + <template v-else> + <span class="read" v-if="isMe && message.isRead">{{ $t('is-read') }}</span> + </template> <mk-time :time="message.createdAt"/> <template v-if="message.is_edited"><fa icon="pencil-alt"/></template> </footer> @@ -42,6 +47,9 @@ export default Vue.extend({ props: { message: { required: true + }, + isGroup: { + required: false } }, computed: { diff --git a/src/client/app/common/views/components/messaging-room.vue b/src/client/app/common/views/components/messaging-room.vue index a8980e068f..658dc93f64 100644 --- a/src/client/app/common/views/components/messaging-room.vue +++ b/src/client/app/common/views/components/messaging-room.vue @@ -4,14 +4,14 @@ @drop.prevent.stop="onDrop" > <div class="body"> - <p class="init" v-if="init"><fa icon="spinner .spin"/>{{ $t('@.loading') }}</p> - <p class="empty" v-if="!init && messages.length == 0"><fa icon="info-circle"/>{{ $t('empty') }}</p> + <p class="init" v-if="init"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}</p> + <p class="empty" v-if="!init && messages.length == 0"><fa icon="info-circle"/>{{ user ? $t('not-talked-user') : $t('not-talked-group') }}</p> <p class="no-history" v-if="!init && messages.length > 0 && !existMoreMessages"><fa :icon="faFlag"/>{{ $t('no-history') }}</p> <button class="more" :class="{ fetching: fetchingMoreMessages }" v-if="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages"> <template v-if="fetchingMoreMessages"><fa icon="spinner" pulse fixed-width/></template>{{ fetchingMoreMessages ? $t('@.loading') : $t('@.load-more') }} </button> <template v-for="(message, i) in _messages"> - <x-message :message="message" :key="message.id"/> + <x-message :message="message" :key="message.id" :is-group="group != null"/> <p class="date" v-if="i != messages.length - 1 && message._date != _messages[i + 1]._date"> <span>{{ _messages[i + 1]._datetext }}</span> </p> @@ -23,7 +23,7 @@ <button @click="onIndicatorClick"><i><fa :icon="faArrowCircleDown"/></i>{{ $t('new-message') }}</button> </div> </transition> - <x-form :user="user" ref="form"/> + <x-form :user="user" :group="group" ref="form"/> </footer> </div> </template> @@ -34,17 +34,30 @@ import i18n from '../../../i18n'; import XMessage from './messaging-room.message.vue'; import XForm from './messaging-room.form.vue'; import { url } from '../../../config'; -import { faArrowCircleDown } from '@fortawesome/free-solid-svg-icons'; -import { faFlag } from '@fortawesome/free-regular-svg-icons'; +import { faArrowCircleDown, faFlag } from '@fortawesome/free-solid-svg-icons'; export default Vue.extend({ i18n: i18n('common/views/components/messaging-room.vue'), + components: { XMessage, XForm }, - props: ['user', 'isNaked'], + props: { + user: { + type: Object, + requird: false, + }, + group: { + type: Object, + requird: false, + }, + isNaked: { + type: Boolean, + requird: false, + }, + }, data() { return { @@ -76,7 +89,10 @@ export default Vue.extend({ }, mounted() { - this.connection = this.$root.stream.connectToChannel('messaging', { otherparty: this.user.id }); + this.connection = this.$root.stream.connectToChannel('messaging', { + otherparty: this.user ? this.user.id : undefined, + group: this.group ? this.group.id : undefined, + }); this.connection.on('message', this.onMessage); this.connection.on('read', this.onRead); @@ -147,7 +163,8 @@ export default Vue.extend({ const max = this.existMoreMessages ? 20 : 10; this.$root.api('messaging/messages', { - userId: this.user.id, + userId: this.user ? this.user.id : undefined, + groupId: this.group ? this.group.id : undefined, limit: max + 1, untilId: this.existMoreMessages ? this.messages[0].id : undefined }).then(messages => { @@ -199,12 +216,21 @@ export default Vue.extend({ } }, - onRead(ids) { - if (!Array.isArray(ids)) ids = [ids]; - for (const id of ids) { - if (this.messages.some(x => x.id == id)) { - const exist = this.messages.map(x => x.id).indexOf(id); - this.messages[exist].isRead = true; + onRead(x) { + if (this.user) { + if (!Array.isArray(x)) x = [x]; + for (const id of x) { + if (this.messages.some(x => x.id == id)) { + const exist = this.messages.map(x => x.id).indexOf(id); + this.messages[exist].isRead = true; + } + } + } else if (this.group) { + for (const id of x.ids) { + if (this.messages.some(x => x.id == id)) { + const exist = this.messages.map(x => x.id).indexOf(id); + this.messages[exist].reads.push(x.userId); + } } } }, diff --git a/src/client/app/common/views/components/messaging.vue b/src/client/app/common/views/components/messaging.vue index f884a599d7..01d7a5a798 100644 --- a/src/client/app/common/views/components/messaging.vue +++ b/src/client/app/common/views/components/messaging.vue @@ -21,36 +21,62 @@ </div> </div> <div class="history" v-if="messages.length > 0"> - <template> - <a v-for="message in messages" - class="user" - :href="`/i/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" - :data-is-me="isMe(message)" - :data-is-read="message.isRead" - @click.prevent="navigate(isMe(message) ? message.recipient : message.user)" - :key="message.id" - > - <div> - <mk-avatar class="avatar" :user="isMe(message) ? message.recipient : message.user"/> - <header> - <span class="name"><mk-user-name :user="isMe(message) ? message.recipient : message.user"/></span> - <span class="username">@{{ isMe(message) ? message.recipient : message.user | acct }}</span> - <mk-time :time="message.createdAt"/> - </header> - <div class="body"> - <p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p> - </div> + <div class="title">{{ $t('user') }}</div> + <a v-for="message in messages" + class="user" + :href="`/i/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" + :data-is-me="isMe(message)" + :data-is-read="message.isRead" + @click.prevent="navigate(isMe(message) ? message.recipient : message.user)" + :key="message.id" + > + <div> + <mk-avatar class="avatar" :user="isMe(message) ? message.recipient : message.user"/> + <header> + <span class="name"><mk-user-name :user="isMe(message) ? message.recipient : message.user"/></span> + <span class="username">@{{ isMe(message) ? message.recipient : message.user | acct }}</span> + <mk-time :time="message.createdAt"/> + </header> + <div class="body"> + <p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p> </div> - </a> - </template> + </div> + </a> </div> - <p class="no-history" v-if="!fetching && messages.length == 0">{{ $t('no-history') }}</p> + <div class="history" v-if="groupMessages.length > 0"> + <div class="title">{{ $t('group') }}</div> + <a v-for="message in groupMessages" + class="user" + :href="`/i/messaging/group/${message.groupId}`" + :data-is-me="isMe(message)" + :data-is-read="message.reads.includes($store.state.i.id)" + @click.prevent="navigateGroup(message.group)" + :key="message.id" + > + <div> + <mk-avatar class="avatar" :user="message.user"/> + <header> + <span class="name">{{ message.group.name }}</span> + <mk-time :time="message.createdAt"/> + </header> + <div class="body"> + <p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p> + </div> + </div> + </a> + </div> + <p class="no-history" v-if="!fetching && (messages.length == 0 && groupMessages.length == 0)">{{ $t('no-history') }}</p> <p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> + <ui-margin> + <ui-button @click="startUser()"><fa :icon="faUser"/> {{ $t('start-with-user') }}</ui-button> + <ui-button @click="startGroup()"><fa :icon="faUsers"/> {{ $t('start-with-group') }}</ui-button> + </ui-margin> </div> </template> <script lang="ts"> import Vue from 'vue'; +import { faUser, faUsers } from '@fortawesome/free-solid-svg-icons'; import i18n from '../../../i18n'; import getAcct from '../../../../../misc/acct/render'; @@ -71,9 +97,11 @@ export default Vue.extend({ fetching: true, moreFetching: false, messages: [], + groupMessages: [], q: null, result: [], - connection: null + connection: null, + faUser, faUsers }; }, mounted() { @@ -82,9 +110,12 @@ export default Vue.extend({ this.connection.on('message', this.onMessage); this.connection.on('read', this.onRead); - this.$root.api('messaging/history').then(messages => { - this.messages = messages; - this.fetching = false; + this.$root.api('messaging/history', { group: false }).then(messages => { + this.$root.api('messaging/history', { group: true }).then(groupMessages => { + this.messages = messages; + this.groupMessages = groupMessages; + this.fetching = false; + }); }); }, beforeDestroy() { @@ -96,16 +127,27 @@ export default Vue.extend({ return message.userId == this.$store.state.i.id; }, onMessage(message) { - this.messages = this.messages.filter(m => !( - (m.recipientId == message.recipientId && m.userId == message.userId) || - (m.recipientId == message.userId && m.userId == message.recipientId))); + if (message.recipientId) { + this.messages = this.messages.filter(m => !( + (m.recipientId == message.recipientId && m.userId == message.userId) || + (m.recipientId == message.userId && m.userId == message.recipientId))); - this.messages.unshift(message); + this.messages.unshift(message); + } else if (message.groupId) { + this.groupMessages = this.groupMessages.filter(m => m.groupId !== message.groupId); + this.groupMessages.unshift(message); + } }, onRead(ids) { for (const id of ids) { const found = this.messages.find(m => m.id == id); - if (found) found.isRead = true; + if (found) { + if (found.recipientId) { + found.isRead = true; + } else if (found.groupId) { + found.reads.push(this.$store.state.i.id); + } + } } }, search() { @@ -125,6 +167,9 @@ export default Vue.extend({ navigate(user) { this.$emit('navigate', user); }, + navigateGroup(group) { + this.$emit('navigateGroup', group); + }, onSearchKeydown(e) { switch (e.which) { case 9: // [TAB] @@ -161,6 +206,30 @@ export default Vue.extend({ (list.childNodes[i].nextElementSibling || list.childNodes[0]).focus(); break; } + }, + async startUser() { + const { result: user } = await this.$root.dialog({ + user: { + local: true + } + }); + if (user == null) return; + this.navigate(user); + }, + async startGroup() { + const groups = await this.$root.api('users/groups/joined'); + const { canceled, result: group } = await this.$root.dialog({ + type: null, + title: this.$t('select-group'), + select: { + items: groups.map(group => ({ + value: group, text: group.name + })) + }, + showCancelButton: true + }); + if (canceled) return; + this.navigateGroup(group); } } }); @@ -173,6 +242,9 @@ export default Vue.extend({ font-size 0.8em > .history + > .title + padding 8px + > a &:last-child border-bottom none @@ -311,6 +383,13 @@ export default Vue.extend({ color rgba(#000, 0.3) > .history + > .title + padding 6px 16px + margin 0 auto + max-width 500px + background rgba(0, 0, 0, 0.05) + color var(--text) + font-size 85% > a display block diff --git a/src/client/app/common/views/components/ui/hr.vue b/src/client/app/common/views/components/ui/hr.vue new file mode 100644 index 0000000000..38572cfcc3 --- /dev/null +++ b/src/client/app/common/views/components/ui/hr.vue @@ -0,0 +1,15 @@ +<template> +<div class="evrzpitu"></div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({}); +</script> + +<style lang="stylus" scoped> +.evrzpitu + margin 16px 0 + border-bottom solid var(--lineWidth) var(--faceDivider) + +</style> diff --git a/src/client/app/common/views/components/ui/margin.vue b/src/client/app/common/views/components/ui/margin.vue new file mode 100644 index 0000000000..508116f070 --- /dev/null +++ b/src/client/app/common/views/components/ui/margin.vue @@ -0,0 +1,16 @@ +<template> +<div class="zdcrxcne"> + <slot></slot> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({}); +</script> + +<style lang="stylus" scoped> +.zdcrxcne + margin 16px + +</style> diff --git a/src/client/app/common/views/components/user-lists.vue b/src/client/app/common/views/components/user-lists.vue deleted file mode 100644 index 699251b313..0000000000 --- a/src/client/app/common/views/components/user-lists.vue +++ /dev/null @@ -1,95 +0,0 @@ -<template> -<div class="xkxvokkjlptzyewouewmceqcxhpgzprp"> - <button class="ui" @click="add">{{ $t('create-list') }}</button> - <a v-for="list in lists" :key="list.id" @click="choice(list)">{{ list.name }}</a> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('common/views/components/user-lists.vue'), - data() { - return { - fetching: true, - lists: [] - }; - }, - mounted() { - this.$root.api('users/lists/list').then(lists => { - this.fetching = false; - this.lists = lists; - }); - }, - methods: { - add() { - this.$root.dialog({ - title: this.$t('list-name'), - input: true - }).then(async ({ canceled, result: name }) => { - if (canceled) return; - const list = await this.$root.api('users/lists/create', { - name - }); - - this.lists.push(list) - this.$emit('choosen', list); - }); - }, - choice(list) { - this.$emit('choosen', list); - } - } -}); -</script> - -<style lang="stylus" scoped> -.xkxvokkjlptzyewouewmceqcxhpgzprp - padding 16px - background: var(--bg) - - > button - display block - margin-bottom 16px - color var(--primaryForeground) - background var(--primary) - width 100% - border-radius 38px - user-select none - cursor pointer - padding 0 16px - min-width 100px - line-height 38px - font-size 14px - font-weight 700 - - &:hover - background var(--primaryLighten10) - - &:active - background var(--primaryDarken10) - - a - display block - margin 8px 0 - padding 8px - color var(--text) - background var(--face) - box-shadow 0 2px 16px var(--reversiListItemShadow) - border-radius 6px - cursor pointer - line-height 32px - - * - pointer-events none - user-select none - - &:hover - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) - - &:active - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) - -</style> diff --git a/src/client/app/common/views/components/user-menu.vue b/src/client/app/common/views/components/user-menu.vue index 7cbffa9f9a..532dcf35c2 100644 --- a/src/client/app/common/views/components/user-menu.vue +++ b/src/client/app/common/views/components/user-menu.vue @@ -27,7 +27,7 @@ export default Vue.extend({ text: this.$t('push-to-list'), action: this.pushList }] as any; - + if (this.$store.getters.isSignedIn && this.$store.state.i.id != this.user.id) { menu = menu.concat([null, { icon: this.user.isMuted ? ['fas', 'eye'] : ['far', 'eye-slash'], diff --git a/src/client/app/common/views/deck/deck.explore-column.vue b/src/client/app/common/views/deck/deck.column-template.vue similarity index 50% rename from src/client/app/common/views/deck/deck.explore-column.vue rename to src/client/app/common/views/deck/deck.column-template.vue index 53db677b37..09583de4b2 100644 --- a/src/client/app/common/views/deck/deck.explore-column.vue +++ b/src/client/app/common/views/deck/deck.column-template.vue @@ -1,34 +1,45 @@ <template> <x-column> <template #header> - <fa :icon="faHashtag"/>{{ $t('@.explore') }} + <fa :icon="icon"/>{{ title }} </template> <div> - <x-explore v-bind="$attrs"/> + <component :is="component" @init="init" v-bind="$attrs"/> </div> </x-column> </template> <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../i18n'; import XColumn from './deck.column.vue'; -import XExplore from '../../../common/views/pages/explore.vue'; -import { faHashtag } from '@fortawesome/free-solid-svg-icons'; export default Vue.extend({ - i18n: i18n(), - components: { XColumn, - XExplore, + }, + + props: { + component: { + required: true + } }, data() { return { - faHashtag + title: null, + icon: null, }; + }, + + mounted() { + }, + + methods: { + init(v) { + this.title = v.title; + this.icon = v.icon; + } } }); </script> diff --git a/src/client/app/common/views/pages/explore.vue b/src/client/app/common/views/pages/explore.vue index d0e98035f8..bf0d7ab574 100644 --- a/src/client/app/common/views/pages/explore.vue +++ b/src/client/app/common/views/pages/explore.vue @@ -116,6 +116,10 @@ export default Vue.extend({ }, created() { + this.$emit('init', { + title: this.$t('@.explore'), + icon: faHashtag + }); this.$root.api('hashtags/list', { sort: '+attachedLocalUsers', attachedToLocalUserOnly: true, diff --git a/src/client/app/mobile/views/pages/received-follow-requests.vue b/src/client/app/common/views/pages/follow-requests.vue similarity index 57% rename from src/client/app/mobile/views/pages/received-follow-requests.vue rename to src/client/app/common/views/pages/follow-requests.vue index abf0c33830..860efefd93 100644 --- a/src/client/app/mobile/views/pages/received-follow-requests.vue +++ b/src/client/app/common/views/pages/follow-requests.vue @@ -1,27 +1,30 @@ <template> -<mk-ui> - <template #header><fa :icon="['far', 'envelope']"/>{{ $t('title') }}</template> - - <main> - <div v-for="req in requests"> - <router-link :key="req.id" :to="req.follower | userPage"> - <mk-user-name :user="req.follower"/> - </router-link> - <span> - <a @click="accept(req.follower)">{{ $t('accept') }}</a>|<a @click="reject(req.follower)">{{ $t('reject') }}</a> - </span> +<div> + <ui-container :body-togglable="true"> + <template #header>{{ $t('received-follow-requests') }}</template> + <div v-if="!fetching"> + <sequential-entrance animation="entranceFromTop" delay="25" tag="div" class="mcbzkkaw"> + <div v-for="req in requests"> + <router-link :key="req.id" :to="req.follower | userPage"> + <mk-user-name :user="req.follower"/> + </router-link> + <span> + <a @click="accept(req.follower)">{{ $t('accept') }}</a>|<a @click="reject(req.follower)">{{ $t('reject') }}</a> + </span> + </div> + </sequential-entrance> </div> - </main> -</mk-ui> + </ui-container> +</div> </template> <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import Progress from '../../../common/scripts/loading'; +import Progress from '../../scripts/loading'; export default Vue.extend({ - i18n: i18n('mobile/views/pages/received-follow-requests.vue'), + i18n: i18n('common/views/pages/follow-requests.vue'), data() { return { fetching: true, @@ -29,14 +32,10 @@ export default Vue.extend({ }; }, mounted() { - document.title = this.$t('title'); - Progress.start(); - this.$root.api('following/requests/list').then(requests => { this.fetching = false; this.requests = requests; - Progress.done(); }); }, @@ -56,7 +55,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -main +.mcbzkkaw > div display flex padding 16px diff --git a/src/client/app/common/views/pages/pages.vue b/src/client/app/common/views/pages/pages.vue index 751ea72374..d658728a19 100644 --- a/src/client/app/common/views/pages/pages.vue +++ b/src/client/app/common/views/pages/pages.vue @@ -50,6 +50,11 @@ export default Vue.extend({ }, created() { this.fetch(); + + this.$emit('init', { + title: this.$t('@.pages'), + icon: faStickyNote + }); }, methods: { async fetch() { diff --git a/src/client/app/common/views/pages/user-group-editor.vue b/src/client/app/common/views/pages/user-group-editor.vue new file mode 100644 index 0000000000..c658d0c6ff --- /dev/null +++ b/src/client/app/common/views/pages/user-group-editor.vue @@ -0,0 +1,180 @@ +<template> +<div class="ivrbakop"> + <ui-container v-if="group"> + <template #header><fa :icon="faUsers"/> {{ group.name }}</template> + + <section> + <ui-margin> + <ui-button @click="rename"><fa :icon="faICursor"/> {{ $t('rename') }}</ui-button> + <ui-button @click="del"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button> + </ui-margin> + </section> + </ui-container> + + <ui-container> + <template #header><fa :icon="faUsers"/> {{ $t('users') }}</template> + + <section> + <ui-margin> + <ui-button @click="add"><fa :icon="faPlus"/> {{ $t('add-user') }}</ui-button> + </ui-margin> + <sequential-entrance animation="entranceFromTop" delay="25"> + <div class="kjlrfbes" v-for="user in users"> + <div> + <a :href="user | userPage"> + <mk-avatar class="avatar" :user="user" :disable-link="true"/> + </a> + </div> + <div> + <header> + <b><mk-user-name :user="user"/></b> + <span class="username">@{{ user | acct }}</span> + </header> + <div> + <a @click="remove(user)">{{ $t('remove-user') }}</a> + </div> + </div> + </div> + </sequential-entrance> + </section> + </ui-container> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import { faICursor, faUsers, faPlus } from '@fortawesome/free-solid-svg-icons'; +import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; + +export default Vue.extend({ + i18n: i18n('common/views/components/user-group-editor.vue'), + + props: { + groupId: { + required: true + } + }, + + data() { + return { + group: null, + users: [], + faICursor, faTrashAlt, faUsers, faPlus + }; + }, + + created() { + this.$root.api('users/groups/show', { + groupId: this.groupId + }).then(group => { + this.group = group; + this.fetchUsers(); + this.$emit('init', { + title: this.group.name, + icon: faUsers + }); + }); + }, + + methods: { + fetchUsers() { + this.$root.api('users/show', { + userIds: this.group.userIds + }).then(users => { + this.users = users; + }); + }, + + rename() { + this.$root.dialog({ + title: this.$t('rename'), + input: { + default: this.group.name + } + }).then(({ canceled, result: name }) => { + if (canceled) return; + this.$root.api('users/groups/update', { + groupId: this.group.id, + name: name + }); + }); + }, + + del() { + this.$root.dialog({ + type: 'warning', + text: this.$t('delete-are-you-sure').replace('$1', this.group.name), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + this.$root.api('users/groups/delete', { + groupId: this.group.id + }).then(() => { + this.$root.dialog({ + type: 'success', + text: this.$t('deleted') + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }); + }, + + remove(user: any) { + this.$root.api('users/groups/pull', { + groupId: this.group.id, + userId: user.id + }).then(() => { + this.fetchUsers(); + }); + }, + + async add() { + const { result: user } = await this.$root.dialog({ + user: { + local: true + } + }); + if (user == null) return; + this.$root.api('users/groups/push', { + groupId: this.group.id, + userId: user.id + }).then(() => { + this.fetchUsers(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.ivrbakop + .kjlrfbes + display flex + padding 16px + border-top solid 1px var(--faceDivider) + + > div:first-child + > a + > .avatar + width 64px + height 64px + + > div:last-child + flex 1 + padding-left 16px + + @media (max-width 500px) + font-size 14px + + > header + > .username + margin-left 8px + opacity 0.7 + +</style> diff --git a/src/client/app/common/views/pages/user-groups.vue b/src/client/app/common/views/pages/user-groups.vue new file mode 100644 index 0000000000..336772799b --- /dev/null +++ b/src/client/app/common/views/pages/user-groups.vue @@ -0,0 +1,63 @@ +<template> +<ui-container> + <template #header><fa :icon="faUsers"/> {{ $t('user-groups') }}</template> + <ui-margin> + <ui-button @click="add"><fa :icon="faPlus"/> {{ $t('create-group') }}</ui-button> + </ui-margin> + <div class="hwgkdrbl" v-for="group in groups" :key="group.id"> + <ui-hr/> + <ui-margin> + <router-link :to="`/i/groups/${group.id}`">{{ group.name }}</router-link> + </ui-margin> + </div> +</ui-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import { faUsers, faPlus } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + i18n: i18n('common/views/components/user-groups.vue'), + data() { + return { + fetching: true, + groups: [], + faUsers, faPlus + }; + }, + mounted() { + this.$root.api('users/groups/owned').then(groups => { + this.fetching = false; + this.groups = groups; + }); + + this.$emit('init', { + title: this.$t('user-groups'), + icon: faUsers + }); + }, + methods: { + add() { + this.$root.dialog({ + title: this.$t('group-name'), + input: true + }).then(async ({ canceled, result: name }) => { + if (canceled) return; + const list = await this.$root.api('users/groups/create', { + name + }); + + this.groups.push(list) + }); + }, + } +}); +</script> + +<style lang="stylus" scoped> +.hwgkdrbl + display block + +</style> diff --git a/src/client/app/common/views/components/user-list-editor.vue b/src/client/app/common/views/pages/user-list-editor.vue similarity index 66% rename from src/client/app/common/views/components/user-list-editor.vue rename to src/client/app/common/views/pages/user-list-editor.vue index 86024c4da3..6b2fd75f85 100644 --- a/src/client/app/common/views/components/user-list-editor.vue +++ b/src/client/app/common/views/pages/user-list-editor.vue @@ -1,18 +1,23 @@ <template> <div class="cudqjmnl"> - <ui-card> - <template #title><fa :icon="faList"/> {{ list.name }}</template> + <ui-container v-if="list"> + <template #header><fa :icon="faListUl"/> {{ list.name }}</template> - <section> - <ui-button @click="rename"><fa :icon="faICursor"/> {{ $t('rename') }}</ui-button> - <ui-button @click="del"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button> + <section class="fwvevrks"> + <ui-margin> + <ui-button @click="rename"><fa :icon="faICursor"/> {{ $t('rename') }}</ui-button> + <ui-button @click="del"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button> + </ui-margin> </section> - </ui-card> + </ui-container> - <ui-card> - <template #title><fa :icon="faUsers"/> {{ $t('users') }}</template> + <ui-container> + <template #header><fa :icon="faUsers"/> {{ $t('users') }}</template> <section> + <ui-margin> + <ui-button @click="add"><fa :icon="faPlus"/> {{ $t('add-user') }}</ui-button> + </ui-margin> <sequential-entrance animation="entranceFromTop" delay="25"> <div class="phcqulfl" v-for="user in users"> <div> @@ -32,34 +37,44 @@ </div> </sequential-entrance> </section> - </ui-card> + </ui-container> </div> </template> <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import { faList, faICursor, faUsers } from '@fortawesome/free-solid-svg-icons'; +import { faListUl, faICursor, faUsers, faPlus } from '@fortawesome/free-solid-svg-icons'; import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; export default Vue.extend({ i18n: i18n('common/views/components/user-list-editor.vue'), props: { - list: { + listId: { required: true } }, data() { return { + list: null, users: [], - faList, faICursor, faTrashAlt, faUsers + faListUl, faICursor, faTrashAlt, faUsers, faPlus }; }, - mounted() { - this.fetchUsers(); + created() { + this.$root.api('users/lists/show', { + listId: this.listId + }).then(list => { + this.list = list; + this.fetchUsers(); + this.$emit('init', { + title: this.list.name, + icon: faListUl + }); + }); }, methods: { @@ -117,6 +132,21 @@ export default Vue.extend({ }).then(() => { this.fetchUsers(); }); + }, + + async add() { + const { result: user } = await this.$root.dialog({ + user: { + local: true + } + }); + if (user == null) return; + this.$root.api('users/lists/push', { + listId: this.list.id, + userId: user.id + }).then(() => { + this.fetchUsers(); + }); } } }); @@ -126,7 +156,7 @@ export default Vue.extend({ .cudqjmnl .phcqulfl display flex - padding 16px 0 + padding 16px border-top solid 1px var(--faceDivider) > div:first-child diff --git a/src/client/app/common/views/pages/user-lists.vue b/src/client/app/common/views/pages/user-lists.vue new file mode 100644 index 0000000000..4c09eca6ce --- /dev/null +++ b/src/client/app/common/views/pages/user-lists.vue @@ -0,0 +1,63 @@ +<template> +<ui-container> + <template #header><fa :icon="faListUl"/> {{ $t('user-lists') }}</template> + <ui-margin> + <ui-button @click="add"><fa :icon="faPlus"/> {{ $t('create-list') }}</ui-button> + </ui-margin> + <div class="cpqqyrst" v-for="list in lists" :key="list.id"> + <ui-hr/> + <ui-margin> + <router-link :to="`/i/lists/${list.id}`">{{ list.name }}</router-link> + </ui-margin> + </div> +</ui-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import { faListUl, faPlus } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + i18n: i18n('common/views/components/user-lists.vue'), + data() { + return { + fetching: true, + lists: [], + faListUl, faPlus + }; + }, + mounted() { + this.$root.api('users/lists/list').then(lists => { + this.fetching = false; + this.lists = lists; + }); + + this.$emit('init', { + title: this.$t('user-lists'), + icon: faListUl + }); + }, + methods: { + add() { + this.$root.dialog({ + title: this.$t('list-name'), + input: true + }).then(async ({ canceled, result: name }) => { + if (canceled) return; + const list = await this.$root.api('users/lists/create', { + name + }); + + this.lists.push(list) + }); + }, + } +}); +</script> + +<style lang="stylus" scoped> +.cpqqyrst + display block + +</style> diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts index 464f7d3ce9..c6479f477c 100644 --- a/src/client/app/desktop/script.ts +++ b/src/client/app/desktop/script.ts @@ -22,6 +22,7 @@ import MkShare from '../common/views/pages/share.vue'; import MkFollow from '../common/views/pages/follow.vue'; import MkNotFound from '../common/views/pages/not-found.vue'; import MkSettings from './views/pages/settings.vue'; +import DeckColumn from '../common/views/deck/deck.column-template.vue'; import Ctx from './views/components/context-menu.vue'; import PostFormWindow from './views/components/post-form-window.vue'; @@ -138,9 +139,14 @@ init(async (launch, os) => { { path: '/search', component: () => import('../common/views/deck/deck.search-column.vue').then(m => m.default) }, { path: '/tags/:tag', name: 'tag', component: () => import('../common/views/deck/deck.hashtag-column.vue').then(m => m.default) }, { path: '/featured', name: 'featured', component: () => import('../common/views/deck/deck.featured-column.vue').then(m => m.default) }, - { path: '/explore', name: 'explore', component: () => import('../common/views/deck/deck.explore-column.vue').then(m => m.default) }, - { path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('../common/views/deck/deck.explore-column.vue').then(m => m.default) }, - { path: '/i/favorites', component: () => import('../common/views/deck/deck.favorites-column.vue').then(m => m.default) } + { path: '/explore', name: 'explore', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default) }) }, + { path: '/explore/tags/:tag', name: 'explore-tag', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default), tag: route.params.tag }) }, + { path: '/i/favorites', component: () => import('../common/views/deck/deck.favorites-column.vue').then(m => m.default) }, + { path: '/i/pages', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/pages.vue').then(m => m.default) }) }, + { path: '/i/lists', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) }) }, + { path: '/i/lists/:listId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default), listId: route.params.listId }) }, + { path: '/i/groups', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-groups.vue').then(m => m.default) }) }, + { path: '/i/groups/:groupId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-group-editor.vue').then(m => m.default), groupId: route.params.groupId }) }, ]} : { path: '/', component: MkHome, children: [ { path: '', name: 'index', component: MkHomeTimeline }, @@ -157,11 +163,17 @@ init(async (launch, os) => { { path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('../common/views/pages/explore.vue').then(m => m.default) }, { path: '/i/favorites', component: () => import('./views/home/favorites.vue').then(m => m.default) }, { path: '/i/pages', component: () => import('../common/views/pages/pages.vue').then(m => m.default) }, + { path: '/i/lists', component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) }, + { path: '/i/lists/:listId', props: true, component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default) }, + { path: '/i/groups', component: () => import('../common/views/pages/user-groups.vue').then(m => m.default) }, + { path: '/i/groups/:groupId', props: true, component: () => import('../common/views/pages/user-group-editor.vue').then(m => m.default) }, + { path: '/i/follow-requests', component: () => import('../common/views/pages/follow-requests.vue').then(m => m.default) }, ]}, { path: '/@:user/pages/:page', props: true, component: () => import('./views/pages/page.vue').then(m => m.default) }, { path: '/@:user/pages/:pageName/view-source', props: true, component: () => import('./views/pages/page-editor.vue').then(m => m.default) }, { path: '/i/pages/new', component: () => import('./views/pages/page-editor.vue').then(m => m.default) }, { path: '/i/pages/edit/:pageId', props: true, component: () => import('./views/pages/page-editor.vue').then(m => m.default) }, + { path: '/i/messaging/group/:group', component: MkMessagingRoom }, { path: '/i/messaging/:user', component: MkMessagingRoom }, { path: '/i/drive', component: MkDrive }, { path: '/i/drive/folder/:folder', component: MkDrive }, diff --git a/src/client/app/desktop/views/components/messaging-room-window.vue b/src/client/app/desktop/views/components/messaging-room-window.vue index 00cd423cd2..6c1708b59f 100644 --- a/src/client/app/desktop/views/components/messaging-room-window.vue +++ b/src/client/app/desktop/views/components/messaging-room-window.vue @@ -1,7 +1,7 @@ <template> <mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="destroyDom"> - <template #header><fa icon="comments"/> {{ $t('@.messaging') }}: <mk-user-name :user="user"/></template> - <x-messaging-room :user="user" :class="$style.content"/> + <template #header><fa icon="comments"/> {{ $t('@.messaging') }}: <mk-user-name v-if="user" :user="user"/><span v-else>{{ group.name }}</span></template> + <x-messaging-room :user="user" :group="group" :class="$style.content"/> </mk-window> </template> @@ -16,10 +16,14 @@ export default Vue.extend({ components: { XMessagingRoom: () => import('../../../common/views/components/messaging-room.vue').then(m => m.default) }, - props: ['user'], + props: ['user', 'group'], computed: { popout(): string { - return `${url}/i/messaging/${getAcct(this.user)}`; + if (this.user) { + return `${url}/i/messaging/${getAcct(this.user)}`; + } else if (this.group) { + return `${url}/i/messaging/group/${this.group.id}`; + } } } }); diff --git a/src/client/app/desktop/views/components/messaging-window.vue b/src/client/app/desktop/views/components/messaging-window.vue index 1572c40669..7cec9484d6 100644 --- a/src/client/app/desktop/views/components/messaging-window.vue +++ b/src/client/app/desktop/views/components/messaging-window.vue @@ -1,7 +1,7 @@ <template> <mk-window ref="window" width="500px" height="560px" @closed="destroyDom"> <template #header :class="$style.header"><fa icon="comments"/>{{ $t('@.messaging') }}</template> - <x-messaging :class="$style.content" @navigate="navigate"/> + <x-messaging :class="$style.content" @navigate="navigate" @navigateGroup="navigateGroup"/> </mk-window> </template> @@ -20,6 +20,11 @@ export default Vue.extend({ this.$root.new(MkMessagingRoomWindow, { user: user }); + }, + navigateGroup(group) { + this.$root.new(MkMessagingRoomWindow, { + group: group + }); } } }); diff --git a/src/client/app/desktop/views/components/received-follow-requests-window.vue b/src/client/app/desktop/views/components/received-follow-requests-window.vue deleted file mode 100644 index f86b6b0d59..0000000000 --- a/src/client/app/desktop/views/components/received-follow-requests-window.vue +++ /dev/null @@ -1,70 +0,0 @@ -<template> -<mk-window ref="window" is-modal width="450px" height="500px" @closed="destroyDom"> - <template #header><fa :icon="['far', 'envelope']"/> {{ $t('title') }}</template> - - <div class="slpqaxdoxhvglersgjukmvizkqbmbokc"> - <div v-for="req in requests"> - <router-link :key="req.id" :to="req.follower | userPage"> - <mk-user-name :user="req.follower"/> - </router-link> - <span> - <a @click="accept(req.follower)">{{ $t('accept') }}</a>|<a @click="reject(req.follower)">{{ $t('reject') }}</a> - </span> - </div> - </div> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/received-follow-requests-window.vue'), - data() { - return { - fetching: true, - requests: [] - }; - }, - mounted() { - this.$root.api('following/requests/list').then(requests => { - this.fetching = false; - this.requests = requests; - }); - }, - methods: { - accept(user) { - this.$root.api('following/requests/accept', { userId: user.id }).then(() => { - this.requests = this.requests.filter(r => r.follower.id != user.id); - }); - }, - reject(user) { - this.$root.api('following/requests/reject', { userId: user.id }).then(() => { - this.requests = this.requests.filter(r => r.follower.id != user.id); - }); - }, - close() { - (this as any).$refs.window.close(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.slpqaxdoxhvglersgjukmvizkqbmbokc - padding 16px - - > button - margin-bottom 16px - - > div - display flex - padding 16px - border solid 1px var(--faceDivider) - border-radius 4px - - > span - margin 0 0 0 auto - -</style> diff --git a/src/client/app/desktop/views/components/ui.header.account.vue b/src/client/app/desktop/views/components/ui.header.account.vue index 9b87e0c29f..c00c6b9c64 100644 --- a/src/client/app/desktop/views/components/ui.header.account.vue +++ b/src/client/app/desktop/views/components/ui.header.account.vue @@ -28,12 +28,19 @@ <i><fa icon="angle-right"/></i> </router-link> </li> - <li @click="list"> - <p> + <li> + <router-link to="/i/lists"> <i><fa icon="list" fixed-width/></i> <span>{{ $t('lists') }}</span> <i><fa icon="angle-right"/></i> - </p> + </router-link> + </li> + <li> + <router-link to="/i/groups"> + <i><fa :icon="faUsers" fixed-width/></i> + <span>{{ $t('groups') }}</span> + <i><fa icon="angle-right"/></i> + </router-link> </li> <li> <router-link to="/i/pages"> @@ -42,12 +49,12 @@ <i><fa icon="angle-right"/></i> </router-link> </li> - <li @click="followRequests" v-if="($store.state.i.isLocked || $store.state.i.carefulBot)"> - <p> + <li v-if="($store.state.i.isLocked || $store.state.i.carefulBot)"> + <router-link to="/i/follow-requests"> <i><fa :icon="['far', 'envelope']" fixed-width/></i> <span>{{ $t('follow-requests') }}<i v-if="$store.state.i.pendingReceivedFollowRequestsCount">{{ $store.state.i.pendingReceivedFollowRequestsCount }}</i></span> <i><fa icon="angle-right"/></i> - </p> + </router-link> </li> </ul> <ul> @@ -96,12 +103,10 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import MkUserListsWindow from './user-lists-window.vue'; -import MkFollowRequestsWindow from './received-follow-requests-window.vue'; // import MkSettingsWindow from './settings-window.vue'; import MkDriveWindow from './drive-window.vue'; import contains from '../../../common/scripts/contains'; -import { faHome, faColumns } from '@fortawesome/free-solid-svg-icons'; +import { faHome, faColumns, faUsers } from '@fortawesome/free-solid-svg-icons'; import { faMoon, faSun, faStickyNote } from '@fortawesome/free-regular-svg-icons'; export default Vue.extend({ @@ -109,7 +114,7 @@ export default Vue.extend({ data() { return { isOpen: false, - faHome, faColumns, faMoon, faSun, faStickyNote + faHome, faColumns, faMoon, faSun, faStickyNote, faUsers }; }, computed: { @@ -147,14 +152,6 @@ export default Vue.extend({ this.close(); this.$root.new(MkDriveWindow); }, - list() { - this.close(); - this.$root.new(MkUserListsWindow); - }, - followRequests() { - this.close(); - this.$root.new(MkFollowRequestsWindow); - }, signout() { this.$root.signout(); }, diff --git a/src/client/app/desktop/views/components/ui.sidebar.vue b/src/client/app/desktop/views/components/ui.sidebar.vue index 1c01f127b9..d1ceec5198 100644 --- a/src/client/app/desktop/views/components/ui.sidebar.vue +++ b/src/client/app/desktop/views/components/ui.sidebar.vue @@ -72,8 +72,6 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import MkUserListsWindow from './user-lists-window.vue'; -import MkFollowRequestsWindow from './received-follow-requests-window.vue'; import MkSettingsWindow from './settings-window.vue'; import MkDriveWindow from './drive-window.vue'; import MkMessagingWindow from './messaging-window.vue'; diff --git a/src/client/app/desktop/views/components/user-list-window.vue b/src/client/app/desktop/views/components/user-list-window.vue deleted file mode 100644 index 6764579b20..0000000000 --- a/src/client/app/desktop/views/components/user-list-window.vue +++ /dev/null @@ -1,24 +0,0 @@ -<template> -<mk-window ref="window" width="450px" height="500px" @closed="destroyDom"> - <template #header><fa icon="list"/> {{ list.name }}</template> - - <x-editor :list="list"/> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import XEditor from '../../../common/views/components/user-list-editor.vue'; - -export default Vue.extend({ - components: { - XEditor - }, - - props: { - list: { - required: true - } - } -}); -</script> diff --git a/src/client/app/desktop/views/components/user-lists-window.vue b/src/client/app/desktop/views/components/user-lists-window.vue deleted file mode 100644 index afea01d4a1..0000000000 --- a/src/client/app/desktop/views/components/user-lists-window.vue +++ /dev/null @@ -1,36 +0,0 @@ -<template> -<mk-window ref="window" width="450px" height="500px" @closed="destroyDom"> - <template #header><fa icon="list"/> {{ $t('title') }}</template> - <x-lists :class="$style.content" @choosen="choosen"/> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import MkUserListWindow from './user-list-window.vue'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/user-lists-window.vue'), - components: { - XLists: () => import('../../../common/views/components/user-lists.vue').then(m => m.default) - }, - methods: { - close() { - (this as any).$refs.window.close(); - }, - choosen(list) { - this.$root.new(MkUserListWindow, { - list - }); - } - } -}); -</script> - -<style lang="stylus" module> -.content - height 100% - overflow auto - -</style> diff --git a/src/client/app/desktop/views/pages/messaging-room.vue b/src/client/app/desktop/views/pages/messaging-room.vue index 376b402d30..c725074b7d 100644 --- a/src/client/app/desktop/views/pages/messaging-room.vue +++ b/src/client/app/desktop/views/pages/messaging-room.vue @@ -1,6 +1,6 @@ <template> <div class="mk-messaging-room-page"> - <x-messaging-room v-if="user" :user="user" :is-naked="true"/> + <x-messaging-room v-if="user || group" :user="user" :group="group" :is-naked="true"/> </div> </template> @@ -19,7 +19,8 @@ export default Vue.extend({ data() { return { fetching: true, - user: null + user: null, + group: null }; }, watch: { @@ -47,14 +48,25 @@ export default Vue.extend({ Progress.start(); this.fetching = true; - this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { - this.user = user; - this.fetching = false; + if (this.$route.params.user) { + this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { + this.user = user; + this.fetching = false; - document.title = this.$t('@.messaging') + ': ' + getUserName(this.user); + document.title = this.$t('@.messaging') + ': ' + getUserName(this.user); - Progress.done(); - }); + Progress.done(); + }); + } else { + this.$root.api('users/groups/show', { groupId: this.$route.params.group }).then(group => { + this.group = group; + this.fetching = false; + + document.title = this.$t('@.messaging') + ': ' + this.group.name; + + Progress.done(); + }); + } } } }); diff --git a/src/client/app/desktop/views/widgets/messaging.vue b/src/client/app/desktop/views/widgets/messaging.vue index 1e82ae3d3a..e94e745c19 100644 --- a/src/client/app/desktop/views/widgets/messaging.vue +++ b/src/client/app/desktop/views/widgets/messaging.vue @@ -4,7 +4,7 @@ <template #header><fa icon="comments"/>{{ $t('@.messaging') }}</template> <template #func><button @click="add"><fa icon="plus"/></button></template> - <x-messaging ref="index" compact @navigate="navigate"/> + <x-messaging ref="index" compact @navigate="navigate" @navigateGroup="navigateGroup"/> </ui-container> </div> </template> @@ -31,6 +31,11 @@ export default define({ user: user }); }, + navigateGroup(group) { + this.$root.new(MkMessagingRoomWindow, { + group: group + }); + }, add() { this.$root.new(MkMessagingWindow); }, diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts index 4a79d88773..360da01496 100644 --- a/src/client/app/mobile/script.ts +++ b/src/client/app/mobile/script.ts @@ -18,17 +18,16 @@ import MkDrive from './views/pages/drive.vue'; import MkWidgets from './views/pages/widgets.vue'; import MkMessaging from './views/pages/messaging.vue'; import MkMessagingRoom from './views/pages/messaging-room.vue'; -import MkReceivedFollowRequests from './views/pages/received-follow-requests.vue'; import MkNote from './views/pages/note.vue'; import MkSearch from './views/pages/search.vue'; import MkFavorites from './views/pages/favorites.vue'; -import MkUserLists from './views/pages/user-lists.vue'; -import MkUserList from './views/pages/user-list.vue'; +import UI from './views/pages/ui.vue'; import MkReversi from './views/pages/games/reversi.vue'; import MkTag from './views/pages/tag.vue'; import MkShare from '../common/views/pages/share.vue'; import MkFollow from '../common/views/pages/follow.vue'; import MkNotFound from '../common/views/pages/not-found.vue'; +import DeckColumn from '../common/views/deck/deck.column-template.vue'; import PostForm from './views/components/post-form-dialog.vue'; import FileChooser from './views/components/drive-file-chooser.vue'; @@ -125,9 +124,14 @@ init((launch, os) => { { path: '/search', component: () => import('../common/views/deck/deck.search-column.vue').then(m => m.default) }, { path: '/tags/:tag', name: 'tag', component: () => import('../common/views/deck/deck.hashtag-column.vue').then(m => m.default) }, { path: '/featured', name: 'featured', component: () => import('../common/views/deck/deck.featured-column.vue').then(m => m.default) }, - { path: '/explore', name: 'explore', component: () => import('../common/views/deck/deck.explore-column.vue').then(m => m.default) }, - { path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('../common/views/deck/deck.explore-column.vue').then(m => m.default) }, - { path: '/i/favorites', component: () => import('../common/views/deck/deck.favorites-column.vue').then(m => m.default) } + { path: '/explore', name: 'explore', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default) }) }, + { path: '/explore/tags/:tag', name: 'explore-tag', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default), tag: route.params.tag }) }, + { path: '/i/favorites', component: () => import('../common/views/deck/deck.favorites-column.vue').then(m => m.default) }, + { path: '/i/pages', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/pages.vue').then(m => m.default) }) }, + { path: '/i/lists', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) }) }, + { path: '/i/lists/:listId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default), listId: route.params.listId }) }, + { path: '/i/groups', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-groups.vue').then(m => m.default) }) }, + { path: '/i/groups/:groupId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-group-editor.vue').then(m => m.default), groupId: route.params.groupId }) }, ]}] : [ { path: '/', name: 'index', component: MkIndex }, @@ -135,12 +139,15 @@ init((launch, os) => { { path: '/signup', name: 'signup', component: MkSignup }, { path: '/i/settings', name: 'settings', component: () => import('./views/pages/settings.vue').then(m => m.default) }, { path: '/i/favorites', name: 'favorites', component: MkFavorites }, - { path: '/i/pages', name: 'pages', component: () => import('./views/pages/pages.vue').then(m => m.default) }, - { path: '/i/lists', name: 'user-lists', component: MkUserLists }, - { path: '/i/lists/:list', name: 'user-list', component: MkUserList }, - { path: '/i/received-follow-requests', name: 'received-follow-requests', component: MkReceivedFollowRequests }, + { path: '/i/pages', name: 'pages', component: UI, props: route => ({ component: () => import('../common/views/pages/pages.vue').then(m => m.default) }) }, + { path: '/i/lists', name: 'user-lists', component: UI, props: route => ({ component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) }) }, + { path: '/i/lists/:list', component: UI, props: route => ({ component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default), listId: route.params.list }) }, + { path: '/i/groups', name: 'user-groups', component: UI, props: route => ({ component: () => import('../common/views/pages/user-groups.vue').then(m => m.default) }) }, + { path: '/i/groups/:group', component: UI, props: route => ({ component: () => import('../common/views/pages/user-group-editor.vue').then(m => m.default), groupId: route.params.group }) }, + { path: '/i/follow-requests', name: 'follow-requests', component: UI, props: route => ({ component: () => import('../common/views/pages/follow-requests.vue').then(m => m.default) }) }, { path: '/i/widgets', name: 'widgets', component: MkWidgets }, { path: '/i/messaging', name: 'messaging', component: MkMessaging }, + { path: '/i/messaging/group/:group', component: MkMessagingRoom }, { path: '/i/messaging/:user', component: MkMessagingRoom }, { path: '/i/drive', name: 'drive', component: MkDrive }, { path: '/i/drive/folder/:folder', component: MkDrive }, @@ -151,8 +158,8 @@ init((launch, os) => { { path: '/search', component: MkSearch }, { path: '/tags/:tag', component: MkTag }, { path: '/featured', name: 'featured', component: () => import('./views/pages/featured.vue').then(m => m.default) }, - { path: '/explore', name: 'explore', component: () => import('./views/pages/explore.vue').then(m => m.default) }, - { path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('./views/pages/explore.vue').then(m => m.default) }, + { path: '/explore', name: 'explore', component: UI, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default) }) }, + { path: '/explore/tags/:tag', name: 'explore-tag', component: UI, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default), tag: route.params.tag }) }, { path: '/share', component: MkShare }, { path: '/games/reversi/:game?', name: 'reversi', component: MkReversi }, { path: '/@:user', name: 'user', component: () => import('./views/pages/user/index.vue').then(m => m.default), children: [ diff --git a/src/client/app/mobile/views/components/ui.nav.vue b/src/client/app/mobile/views/components/ui.nav.vue index da9bb518ef..29c744d898 100644 --- a/src/client/app/mobile/views/components/ui.nav.vue +++ b/src/client/app/mobile/views/components/ui.nav.vue @@ -19,7 +19,7 @@ <li><router-link to="/" :data-active="$route.name == 'index'"><i><fa icon="home" fixed-width/></i>{{ $t('timeline') }}<i><fa icon="angle-right"/></i></router-link></li> <li><p @click="showNotifications = true"><i><fa :icon="['far', 'bell']" fixed-width/></i>{{ $t('notifications') }}<i v-if="hasUnreadNotification" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></p></li> <li><router-link to="/i/messaging" :data-active="$route.name == 'messaging'"><i><fa :icon="['far', 'comments']" fixed-width/></i>{{ $t('@.messaging') }}<i v-if="hasUnreadMessagingMessage" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> - <li v-if="$store.getters.isSignedIn && ($store.state.i.isLocked || $store.state.i.carefulBot)"><router-link to="/i/received-follow-requests" :data-active="$route.name == 'received-follow-requests'"><i><fa :icon="['far', 'envelope']" fixed-width/></i>{{ $t('follow-requests') }}<i v-if="$store.getters.isSignedIn && $store.state.i.pendingReceivedFollowRequestsCount" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> + <li v-if="$store.getters.isSignedIn && ($store.state.i.isLocked || $store.state.i.carefulBot)"><router-link to="/i/follow-requests" :data-active="$route.name == 'follow-requests'"><i><fa :icon="['far', 'envelope']" fixed-width/></i>{{ $t('follow-requests') }}<i v-if="$store.getters.isSignedIn && $store.state.i.pendingReceivedFollowRequestsCount" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> <li><router-link to="/featured" :data-active="$route.name == 'featured'"><i><fa :icon="faNewspaper" fixed-width/></i>{{ $t('@.featured-notes') }}<i><fa icon="angle-right"/></i></router-link></li> <li><router-link to="/explore" :data-active="$route.name == 'explore' || $route.name == 'explore-tag'"><i><fa :icon="faHashtag" fixed-width/></i>{{ $t('@.explore') }}<i><fa icon="angle-right"/></i></router-link></li> <li><router-link to="/games/reversi" :data-active="$route.name == 'reversi'"><i><fa icon="gamepad" fixed-width/></i>{{ $t('game') }}<i v-if="hasGameInvitation" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> diff --git a/src/client/app/mobile/views/pages/explore.vue b/src/client/app/mobile/views/pages/explore.vue deleted file mode 100644 index 111721bc8a..0000000000 --- a/src/client/app/mobile/views/pages/explore.vue +++ /dev/null @@ -1,28 +0,0 @@ -<template> -<mk-ui> - <template #header><span style="margin-right:4px;"><fa :icon="faHashtag"/></span>{{ $t('@.explore') }}</template> - - <main> - <x-explore v-bind="$attrs"/> - </main> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { faHashtag } from '@fortawesome/free-solid-svg-icons'; -import XExplore from '../../../common/views/pages/explore.vue'; - -export default Vue.extend({ - i18n: i18n(''), - components: { - XExplore - }, - data() { - return { - faHashtag - }; - }, -}); -</script> diff --git a/src/client/app/mobile/views/pages/messaging-room.vue b/src/client/app/mobile/views/pages/messaging-room.vue index aa00d48699..7872847127 100644 --- a/src/client/app/mobile/views/pages/messaging-room.vue +++ b/src/client/app/mobile/views/pages/messaging-room.vue @@ -2,9 +2,10 @@ <mk-ui> <template #header> <template v-if="user"><span style="margin-right:4px;"><fa :icon="['far', 'comments']"/></span><mk-user-name :user="user"/></template> + <template v-else-if="group"><span style="margin-right:4px;"><fa :icon="['far', 'comments']"/></span>{{ group.name }}</template> <template v-else><mk-ellipsis/></template> </template> - <x-messaging-room v-if="!fetching" :user="user" :is-naked="true"/> + <x-messaging-room v-if="!fetching" :user="user" :group="group" :is-naked="true"/> </mk-ui> </template> @@ -22,6 +23,7 @@ export default Vue.extend({ return { fetching: true, user: null, + group: null, unwatchDarkmode: null }; }, @@ -48,12 +50,21 @@ export default Vue.extend({ methods: { fetch() { this.fetching = true; - this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { - this.user = user; - this.fetching = false; + if (this.$route.params.user) { + this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { + this.user = user; + this.fetching = false; - document.title = `${this.$t('@.messaging')}: ${Vue.filter('userName')(this.user)} | ${this.$root.instanceName}`; - }); + document.title = `${this.$t('@.messaging')}: ${Vue.filter('userName')(this.user)} | ${this.$root.instanceName}`; + }); + } else { + this.$root.api('users/groups/show', { groupId: this.$route.params.group }).then(group => { + this.group = group; + this.fetching = false; + + document.title = this.$t('@.messaging') + ': ' + this.group.name; + }); + } } } }); diff --git a/src/client/app/mobile/views/pages/messaging.vue b/src/client/app/mobile/views/pages/messaging.vue index 5ce2f14bbd..ff66ae06e6 100644 --- a/src/client/app/mobile/views/pages/messaging.vue +++ b/src/client/app/mobile/views/pages/messaging.vue @@ -1,7 +1,7 @@ <template> <mk-ui> <template #header><span style="margin-right:4px;"><fa :icon="['far', 'comments']"/></span>{{ $t('@.messaging') }}</template> - <x-messaging @navigate="navigate" :header-top="48"/> + <x-messaging @navigate="navigate" @navigateGroup="navigateGroup" :header-top="48"/> </mk-ui> </template> @@ -21,6 +21,9 @@ export default Vue.extend({ methods: { navigate(user) { (this as any).$router.push(`/i/messaging/${getAcct(user)}`); + }, + navigateGroup(group) { + (this as any).$router.push(`/i/messaging/group/${group.id}`); } } }); diff --git a/src/client/app/mobile/views/pages/pages.vue b/src/client/app/mobile/views/pages/pages.vue deleted file mode 100644 index 2fd134fcd2..0000000000 --- a/src/client/app/mobile/views/pages/pages.vue +++ /dev/null @@ -1,29 +0,0 @@ -<template> -<mk-ui> - <template #header><span style="margin-right:4px;"><fa :icon="faStickyNote"/></span>{{ $t('@.pages') }}</template> - - <main> - <x-pages v-bind="$attrs"/> - </main> -</mk-ui> -</template> - - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { faHashtag } from '@fortawesome/free-solid-svg-icons'; -import XPages from '../../../common/views/pages/pages.vue'; - -export default Vue.extend({ - i18n: i18n(''), - components: { - XPages - }, - data() { - return { - faHashtag - }; - }, -}); -</script> diff --git a/src/client/app/mobile/views/pages/ui.vue b/src/client/app/mobile/views/pages/ui.vue new file mode 100644 index 0000000000..397ba5df07 --- /dev/null +++ b/src/client/app/mobile/views/pages/ui.vue @@ -0,0 +1,38 @@ +<template> +<mk-ui> + <template #header><span style="margin-right:4px;" v-if="icon"><fa :icon="icon"/></span>{{ title }}</template> + + <main> + <component :is="component" @init="init" v-bind="$attrs"/> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + component: { + required: true + } + }, + + data() { + return { + title: null, + icon: null, + }; + }, + + mounted() { + }, + + methods: { + init(v) { + this.title = v.title; + this.icon = v.icon; + } + } +}); +</script> diff --git a/src/client/app/mobile/views/pages/user-list.vue b/src/client/app/mobile/views/pages/user-list.vue deleted file mode 100644 index 68fd0358c4..0000000000 --- a/src/client/app/mobile/views/pages/user-list.vue +++ /dev/null @@ -1,48 +0,0 @@ -<template> -<mk-ui> - <template #header v-if="!fetching"><fa icon="list"/>{{ list.name }}</template> - - <main v-if="!fetching"> - <x-editor :list="list"/> - </main> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import Progress from '../../../common/scripts/loading'; -import XEditor from '../../../common/views/components/user-list-editor.vue'; - -export default Vue.extend({ - components: { - XEditor - }, - data() { - return { - fetching: true, - list: null - }; - }, - watch: { - $route: 'fetch' - }, - created() { - this.fetch(); - }, - methods: { - fetch() { - Progress.start(); - this.fetching = true; - - this.$root.api('users/lists/show', { - listId: this.$route.params.list - }).then(list => { - this.list = list; - this.fetching = false; - - Progress.done(); - }); - } - } -}); -</script> diff --git a/src/client/app/mobile/views/pages/user-lists.vue b/src/client/app/mobile/views/pages/user-lists.vue deleted file mode 100644 index a3e9bd78ba..0000000000 --- a/src/client/app/mobile/views/pages/user-lists.vue +++ /dev/null @@ -1,35 +0,0 @@ -<template> -<mk-ui> - <template #header><fa icon="list"/>{{ $t('title') }}</template> - <template #func><button @click="$refs.lists.add()"><fa icon="plus"/></button></template> - - <x-lists ref="lists" @choosen="choosen"/> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/user-lists.vue'), - data() { - return { - fetching: true, - lists: [] - }; - }, - components: { - XLists: () => import('../../../common/views/components/user-lists.vue').then(m => m.default) - }, - mounted() { - document.title = this.$t('title'); - }, - methods: { - choosen(list) { - if (!list) return; - this.$router.push(`/i/lists/${list.id}`); - } - } -}); -</script> diff --git a/src/db/postgre.ts b/src/db/postgre.ts index f488af03ca..40b9ce151b 100644 --- a/src/db/postgre.ts +++ b/src/db/postgre.ts @@ -24,6 +24,8 @@ import { SwSubscription } from '../models/entities/sw-subscription'; import { Blocking } from '../models/entities/blocking'; import { UserList } from '../models/entities/user-list'; import { UserListJoining } from '../models/entities/user-list-joining'; +import { UserGroup } from '../models/entities/user-group'; +import { UserGroupJoining } from '../models/entities/user-group-joining'; import { Hashtag } from '../models/entities/hashtag'; import { NoteFavorite } from '../models/entities/note-favorite'; import { AbuseUserReport } from '../models/entities/abuse-user-report'; @@ -106,6 +108,8 @@ export function initDb(justBorrow = false, sync = false, log = false) { UserPublickey, UserList, UserListJoining, + UserGroup, + UserGroupJoining, UserNotePining, Following, FollowRequest, diff --git a/src/models/entities/messaging-message.ts b/src/models/entities/messaging-message.ts index d3c3eab3a2..c18897a37d 100644 --- a/src/models/entities/messaging-message.ts +++ b/src/models/entities/messaging-message.ts @@ -2,6 +2,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typ import { User } from './user'; import { DriveFile } from './drive-file'; import { id } from '../id'; +import { UserGroup } from './user-group'; @Entity() export class MessagingMessage { @@ -29,10 +30,10 @@ export class MessagingMessage { @Index() @Column({ - ...id(), + ...id(), nullable: true, comment: 'The recipient user ID.' }) - public recipientId: User['id']; + public recipientId: User['id'] | null; @ManyToOne(type => User, { onDelete: 'CASCADE' @@ -40,6 +41,19 @@ export class MessagingMessage { @JoinColumn() public recipient: User | null; + @Index() + @Column({ + ...id(), nullable: true, + comment: 'The recipient group ID.' + }) + public groupId: UserGroup['id'] | null; + + @ManyToOne(type => UserGroup, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public group: UserGroup | null; + @Column('varchar', { length: 4096, nullable: true }) @@ -50,6 +64,12 @@ export class MessagingMessage { }) public isRead: boolean; + @Column({ + ...id(), + array: true, default: '{}' + }) + public reads: User['id'][]; + @Column({ ...id(), nullable: true, diff --git a/src/models/entities/user-group-joining.ts b/src/models/entities/user-group-joining.ts new file mode 100644 index 0000000000..17b534f42f --- /dev/null +++ b/src/models/entities/user-group-joining.ts @@ -0,0 +1,41 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { UserGroup } from './user-group'; +import { id } from '../id'; + +@Entity() +export class UserGroupJoining { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the UserGroupJoining.' + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The user ID.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The group ID.' + }) + public userGroupId: UserGroup['id']; + + @ManyToOne(type => UserGroup, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public userGroup: UserGroup | null; +} diff --git a/src/models/entities/user-group.ts b/src/models/entities/user-group.ts new file mode 100644 index 0000000000..f4bac03223 --- /dev/null +++ b/src/models/entities/user-group.ts @@ -0,0 +1,46 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +export class UserGroup { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the UserGroup.' + }) + public createdAt: Date; + + @Column('varchar', { + length: 256, + }) + public name: string; + + @Index() + @Column({ + ...id(), + comment: 'The ID of owner.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column('boolean', { + default: false, + }) + public isPrivate: boolean; + + constructor(data: Partial<UserGroup>) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/src/models/index.ts b/src/models/index.ts index a63bb2c2b5..c05d7febe5 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -6,7 +6,6 @@ import { PollVote } from './entities/poll-vote'; import { Meta } from './entities/meta'; import { SwSubscription } from './entities/sw-subscription'; import { NoteWatching } from './entities/note-watching'; -import { UserListJoining } from './entities/user-list-joining'; import { NoteUnread } from './entities/note-unread'; import { RegistrationTicket } from './entities/registration-tickets'; import { UserRepository } from './repositories/user'; @@ -20,6 +19,9 @@ import { SigninRepository } from './repositories/signin'; import { MessagingMessageRepository } from './repositories/messaging-message'; import { ReversiGameRepository } from './repositories/games/reversi/game'; import { UserListRepository } from './repositories/user-list'; +import { UserListJoining } from './entities/user-list-joining'; +import { UserGroupRepository } from './repositories/user-group'; +import { UserGroupJoining } from './entities/user-group-joining'; import { FollowRequestRepository } from './repositories/follow-request'; import { MutingRepository } from './repositories/muting'; import { BlockingRepository } from './repositories/blocking'; @@ -52,6 +54,8 @@ export const UserKeypairs = getRepository(UserKeypair); export const UserPublickeys = getRepository(UserPublickey); export const UserLists = getCustomRepository(UserListRepository); export const UserListJoinings = getRepository(UserListJoining); +export const UserGroups = getCustomRepository(UserGroupRepository); +export const UserGroupJoinings = getRepository(UserGroupJoining); export const UserNotePinings = getRepository(UserNotePining); export const Followings = getCustomRepository(FollowingRepository); export const FollowRequests = getCustomRepository(FollowRequestRepository); diff --git a/src/models/repositories/messaging-message.ts b/src/models/repositories/messaging-message.ts index 33f95bbd5f..a64ed07328 100644 --- a/src/models/repositories/messaging-message.ts +++ b/src/models/repositories/messaging-message.ts @@ -1,6 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { MessagingMessage } from '../entities/messaging-message'; -import { Users, DriveFiles } from '..'; +import { Users, DriveFiles, UserGroups } from '..'; import { ensure } from '../../prelude/ensure'; import { types, bool, SchemaType } from '../../misc/schema'; @@ -16,11 +16,13 @@ export class MessagingMessageRepository extends Repository<MessagingMessage> { src: MessagingMessage['id'] | MessagingMessage, me?: any, options?: { - populateRecipient: boolean + populateRecipient?: boolean, + populateGroup?: boolean, } ): Promise<PackedMessagingMessage> { const opts = options || { - populateRecipient: true + populateRecipient: true, + populateGroup: true, }; const message = typeof src === 'object' ? src : await this.findOne(src).then(ensure); @@ -32,10 +34,13 @@ export class MessagingMessageRepository extends Repository<MessagingMessage> { userId: message.userId, user: await Users.pack(message.user || message.userId, me), recipientId: message.recipientId, - recipient: opts.populateRecipient ? await Users.pack(message.recipient || message.recipientId, me) : undefined, + recipient: message.recipientId && opts.populateRecipient ? await Users.pack(message.recipient || message.recipientId, me) : undefined, + groupId: message.recipientId, + group: message.groupId && opts.populateGroup ? await UserGroups.pack(message.group || message.groupId) : undefined, fileId: message.fileId, file: message.fileId ? await DriveFiles.pack(message.fileId) : null, - isRead: message.isRead + isRead: message.isRead, + reads: message.reads, }; } } @@ -83,17 +88,36 @@ export const packedMessagingMessageSchema = { }, recipientId: { type: types.string, - optional: bool.false, nullable: bool.false, + optional: bool.false, nullable: bool.true, format: 'id', }, recipient: { type: types.object, - optional: bool.true, nullable: bool.false, + optional: bool.true, nullable: bool.true, ref: 'User' }, + groupId: { + type: types.string, + optional: bool.false, nullable: bool.true, + format: 'id', + }, + group: { + type: types.object, + optional: bool.true, nullable: bool.true, + ref: 'UserGroup' + }, isRead: { type: types.boolean, optional: bool.true, nullable: bool.false, }, + reads: { + type: types.array, + optional: bool.true, nullable: bool.false, + items: { + type: types.string, + optional: bool.false, nullable: bool.false, + format: 'id' + } + }, }, }; diff --git a/src/models/repositories/user-group.ts b/src/models/repositories/user-group.ts new file mode 100644 index 0000000000..8bb1ae8330 --- /dev/null +++ b/src/models/repositories/user-group.ts @@ -0,0 +1,61 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { UserGroup } from '../entities/user-group'; +import { ensure } from '../../prelude/ensure'; +import { UserGroupJoinings } from '..'; +import { bool, types, SchemaType } from '../../misc/schema'; + +export type PackedUserGroup = SchemaType<typeof packedUserGroupSchema>; + +@EntityRepository(UserGroup) +export class UserGroupRepository extends Repository<UserGroup> { + public async pack( + src: UserGroup['id'] | UserGroup, + ): Promise<PackedUserGroup> { + const userGroup = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + + const users = await UserGroupJoinings.find({ + userGroupId: userGroup.id + }); + + return { + id: userGroup.id, + createdAt: userGroup.createdAt.toISOString(), + name: userGroup.name, + userIds: users.map(x => x.userId) + }; + } +} + +export const packedUserGroupSchema = { + type: types.object, + optional: bool.false, nullable: bool.false, + properties: { + id: { + type: types.string, + optional: bool.false, nullable: bool.false, + format: 'id', + description: 'The unique identifier for this UserGroup.', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: types.string, + optional: bool.false, nullable: bool.false, + format: 'date-time', + description: 'The date that the UserGroup was created.' + }, + name: { + type: types.string, + optional: bool.false, nullable: bool.false, + description: 'The name of the UserGroup.' + }, + userIds: { + type: types.array, + nullable: bool.false, optional: bool.true, + items: { + type: types.string, + nullable: bool.false, optional: bool.false, + format: 'id', + } + }, + }, +}; diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts index 330220fb72..f81fa6bc77 100644 --- a/src/models/repositories/user.ts +++ b/src/models/repositories/user.ts @@ -1,6 +1,6 @@ import { EntityRepository, Repository, In } from 'typeorm'; import { User, ILocalUser, IRemoteUser } from '../entities/user'; -import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles } from '..'; +import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserGroupJoinings } from '..'; import { ensure } from '../../prelude/ensure'; import config from '../../config'; import { SchemaType, bool, types } from '../../misc/schema'; @@ -54,6 +54,31 @@ export class UserRepository extends Repository<User> { }; } + public async getHasUnreadMessagingMessage(userId: User['id']): Promise<boolean> { + const joinings = await UserGroupJoinings.find({ userId: userId }); + + const groupQs = Promise.all(joinings.map(j => MessagingMessages.createQueryBuilder('message') + .where(`message.groupId = :groupId`, { groupId: j.userGroupId }) + .andWhere('message.userId != :userId', { userId: userId }) + .andWhere('NOT (:userId = ANY(message.reads))', { userId: userId }) + .andWhere('message.createdAt > :joinedAt', { joinedAt: j.createdAt }) // 自分が加入する前の会話については、未読扱いしない + .getOne().then(x => x != null))); + + const [withUser, withGroups] = await Promise.all([ + // TODO: ミュートを考慮 + MessagingMessages.count({ + where: { + recipientId: userId, + isRead: false + }, + take: 1 + }).then(count => count > 0), + groupQs + ]); + + return withUser || withGroups.some(x => x); + } + public async pack( src: User['id'] | User, me?: User['id'] | User | null | undefined, @@ -151,13 +176,7 @@ export class UserRepository extends Repository<User> { autoWatch: profile!.autoWatch, alwaysMarkNsfw: profile!.alwaysMarkNsfw, carefulBot: profile!.carefulBot, - hasUnreadMessagingMessage: MessagingMessages.count({ - where: { - recipientId: user.id, - isRead: false - }, - take: 1 - }).then(count => count > 0), + hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id), hasUnreadNotification: Notifications.count({ where: { notifieeId: user.id, diff --git a/src/server/api/common/read-messaging-message.ts b/src/server/api/common/read-messaging-message.ts index 2cb5a1f87f..544d890197 100644 --- a/src/server/api/common/read-messaging-message.ts +++ b/src/server/api/common/read-messaging-message.ts @@ -1,21 +1,33 @@ -import { publishMainStream } from '../../../services/stream'; +import { publishMainStream, publishGroupMessagingStream } from '../../../services/stream'; import { publishMessagingStream } from '../../../services/stream'; import { publishMessagingIndexStream } from '../../../services/stream'; import { User } from '../../../models/entities/user'; import { MessagingMessage } from '../../../models/entities/messaging-message'; -import { MessagingMessages } from '../../../models'; +import { MessagingMessages, UserGroupJoinings, Users } from '../../../models'; import { In } from 'typeorm'; +import { IdentifiableError } from '../../../misc/identifiable-error'; +import { UserGroup } from '../../../models/entities/user-group'; /** * Mark messages as read */ -export default async ( +export async function readUserMessagingMessage( userId: User['id'], otherpartyId: User['id'], messageIds: MessagingMessage['id'][] -) => { +) { if (messageIds.length === 0) return; + const messages = await MessagingMessages.find({ + id: In(messageIds) + }); + + for (const message of messages) { + if (message.recipientId !== userId) { + throw new IdentifiableError('e140a4bf-49ce-4fb6-b67c-b78dadf6b52f', 'Access denied (user).'); + } + } + // Update documents await MessagingMessages.update({ id: In(messageIds), @@ -30,14 +42,62 @@ export default async ( publishMessagingStream(otherpartyId, userId, 'read', messageIds); publishMessagingIndexStream(userId, 'read', messageIds); - // Calc count of my unread messages - const count = await MessagingMessages.count({ - recipientId: userId, - isRead: false - }); - - if (count == 0) { + if (!Users.getHasUnreadMessagingMessage(userId)) { // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 publishMainStream(userId, 'readAllMessagingMessages'); } -}; +} + +/** + * Mark messages as read + */ +export async function readGroupMessagingMessage( + userId: User['id'], + groupId: UserGroup['id'], + messageIds: MessagingMessage['id'][] +) { + if (messageIds.length === 0) return; + + // check joined + const joining = await UserGroupJoinings.findOne({ + userId: userId, + userGroupId: groupId + }); + + if (joining == null) { + throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).'); + } + + const messages = await MessagingMessages.find({ + id: In(messageIds) + }); + + const reads = []; + + for (const message of messages) { + if (message.userId === userId) continue; + if (message.reads.includes(userId)) continue; + + // Update document + await MessagingMessages.createQueryBuilder().update() + .set({ + reads: (() => `array_append("reads", '${joining.userId}')`) as any + }) + .where('id = :id', { id: message.id }) + .execute(); + + reads.push(message.id); + } + + // Publish event + publishGroupMessagingStream(groupId, 'read', { + ids: reads, + userId: userId + }); + publishMessagingIndexStream(userId, 'read', reads); + + if (!Users.getHasUnreadMessagingMessage(userId)) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + publishMainStream(userId, 'readAllMessagingMessages'); + } +} diff --git a/src/server/api/endpoints/messaging/history.ts b/src/server/api/endpoints/messaging/history.ts index 27e38bbdec..833ec37e4c 100644 --- a/src/server/api/endpoints/messaging/history.ts +++ b/src/server/api/endpoints/messaging/history.ts @@ -1,13 +1,13 @@ import $ from 'cafy'; import define from '../../define'; import { MessagingMessage } from '../../../../models/entities/messaging-message'; -import { MessagingMessages, Mutings } from '../../../../models'; +import { MessagingMessages, Mutings, UserGroupJoinings } from '../../../../models'; import { Brackets } from 'typeorm'; import { types, bool } from '../../../../misc/schema'; export const meta = { desc: { - 'ja-JP': 'Messagingの履歴を取得します。', + 'ja-JP': 'トークの履歴を取得します。', 'en-US': 'Show messaging history.' }, @@ -21,6 +21,11 @@ export const meta = { limit: { validator: $.optional.num.range(1, 100), default: 10 + }, + + group: { + validator: $.optional.bool, + default: false } }, @@ -40,26 +45,46 @@ export default define(meta, async (ps, user) => { muterId: user.id, }); + const groups = ps.group ? await UserGroupJoinings.find({ + userId: user.id, + }).then(xs => xs.map(x => x.userGroupId)) : []; + + if (ps.group && groups.length === 0) { + return []; + } + const history: MessagingMessage[] = []; for (let i = 0; i < ps.limit!; i++) { - const found = history.map(m => (m.userId === user.id) ? m.recipientId : m.userId); + const found = ps.group + ? history.map(m => m.groupId!) + : history.map(m => (m.userId === user.id) ? m.recipientId! : m.userId!); const query = MessagingMessages.createQueryBuilder('message') - .where(new Brackets(qb => { qb - .where(`message.userId = :userId`, { userId: user.id }) - .orWhere(`message.recipientId = :userId`, { userId: user.id }); - })) .orderBy('message.createdAt', 'DESC'); - if (found.length > 0) { - query.andWhere(`message.userId NOT IN (:...found)`, { found: found }); - query.andWhere(`message.recipientId NOT IN (:...found)`, { found: found }); - } + if (ps.group) { + query.where(`message.groupId IN (:...groups)`, { groups: groups }); - if (mute.length > 0) { - query.andWhere(`message.userId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) }); - query.andWhere(`message.recipientId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) }); + if (found.length > 0) { + query.andWhere(`message.groupId NOT IN (:...found)`, { found: found }); + } + } else { + query.where(new Brackets(qb => { qb + .where(`message.userId = :userId`, { userId: user.id }) + .orWhere(`message.recipientId = :userId`, { userId: user.id }); + })); + query.andWhere(`message.groupId IS NULL`); + + if (found.length > 0) { + query.andWhere(`message.userId NOT IN (:...found)`, { found: found }); + query.andWhere(`message.recipientId NOT IN (:...found)`, { found: found }); + } + + if (mute.length > 0) { + query.andWhere(`message.userId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) }); + query.andWhere(`message.recipientId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) }); + } } const message = await query.getOne(); diff --git a/src/server/api/endpoints/messaging/messages.ts b/src/server/api/endpoints/messaging/messages.ts index 0d5295bff3..c1e79cd130 100644 --- a/src/server/api/endpoints/messaging/messages.ts +++ b/src/server/api/endpoints/messaging/messages.ts @@ -1,16 +1,17 @@ import $ from 'cafy'; import { ID } from '../../../../misc/cafy-id'; -import read from '../../common/read-messaging-message'; import define from '../../define'; import { ApiError } from '../../error'; import { getUser } from '../../common/getters'; -import { MessagingMessages } from '../../../../models'; +import { MessagingMessages, UserGroups, UserGroupJoinings } from '../../../../models'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { types, bool } from '../../../../misc/schema'; +import { Brackets } from 'typeorm'; +import { readUserMessagingMessage, readGroupMessagingMessage } from '../../common/read-messaging-message'; export const meta = { desc: { - 'ja-JP': '指定したユーザーとのMessagingのメッセージ一覧を取得します。', + 'ja-JP': 'トークメッセージ一覧を取得します。', 'en-US': 'Get messages of messaging.' }, @@ -22,13 +23,21 @@ export const meta = { params: { userId: { - validator: $.type(ID), + validator: $.optional.type(ID), desc: { 'ja-JP': '対象のユーザーのID', 'en-US': 'Target user ID' } }, + groupId: { + validator: $.optional.type(ID), + desc: { + 'ja-JP': '対象のグループのID', + 'en-US': 'Target group ID' + } + }, + limit: { validator: $.optional.num.range(1, 100), default: 10 @@ -64,27 +73,85 @@ export const meta = { code: 'NO_SUCH_USER', id: '11795c64-40ea-4198-b06e-3c873ed9039d' }, + + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f' + }, + + groupAccessDenied: { + message: 'You can not read messages of groups that you have not joined.', + code: 'GROUP_ACCESS_DENIED', + id: 'a053a8dd-a491-4718-8f87-50775aad9284' + }, } }; export default define(meta, async (ps, user) => { - // Fetch recipient - const recipient = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); + if (ps.userId != null) { + // Fetch recipient (user) + const recipient = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); - const query = makePaginationQuery(MessagingMessages.createQueryBuilder('message'), ps.sinceId, ps.untilId) - .andWhere(`(message.userId = :meId AND message.recipientId = :recipientId) OR (message.userId = :recipientId AND message.recipientId = :meId)`, { meId: user.id, recipientId: recipient.id }); + const query = makePaginationQuery(MessagingMessages.createQueryBuilder('message'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { qb + .where(new Brackets(qb => { qb + .where('message.userId = :meId') + .andWhere('message.recipientId = :recipientId'); + })) + .orWhere(new Brackets(qb => { qb + .where('message.userId = :recipientId') + .andWhere('message.recipientId = :meId'); + })); + })) + .setParameter('meId', user.id) + .setParameter('recipientId', recipient.id); - const messages = await query.getMany(); + const messages = await query.take(ps.limit!).getMany(); - // Mark all as read - if (ps.markAsRead) { - read(user.id, recipient.id, messages.map(x => x.id)); + // Mark all as read + if (ps.markAsRead) { + readUserMessagingMessage(user.id, recipient.id, messages.map(x => x.id)); + } + + return await Promise.all(messages.map(message => MessagingMessages.pack(message, user, { + populateRecipient: false + }))); + } else if (ps.groupId != null) { + // Fetch recipient (group) + const recipientGroup = await UserGroups.findOne(ps.groupId); + + if (recipientGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // check joined + const joining = await UserGroupJoinings.findOne({ + userId: user.id, + userGroupId: recipientGroup.id + }); + + if (joining == null) { + throw new ApiError(meta.errors.groupAccessDenied); + } + + const query = makePaginationQuery(MessagingMessages.createQueryBuilder('message'), ps.sinceId, ps.untilId) + .andWhere(`message.groupId = :groupId`, { groupId: recipientGroup.id }); + + const messages = await query.take(ps.limit!).getMany(); + + // Mark all as read + if (ps.markAsRead) { + readGroupMessagingMessage(user.id, recipientGroup.id, messages.map(x => x.id)); + } + + return await Promise.all(messages.map(message => MessagingMessages.pack(message, user, { + populateGroup: false + }))); + } else { + throw new Error(); } - - return await Promise.all(messages.map(message => MessagingMessages.pack(message, user, { - populateRecipient: false - }))); }); diff --git a/src/server/api/endpoints/messaging/messages/create.ts b/src/server/api/endpoints/messaging/messages/create.ts index 388852b9cd..f5d7cf2b38 100644 --- a/src/server/api/endpoints/messaging/messages/create.ts +++ b/src/server/api/endpoints/messaging/messages/create.ts @@ -1,19 +1,22 @@ import $ from 'cafy'; import { ID } from '../../../../../misc/cafy-id'; -import { publishMainStream } from '../../../../../services/stream'; +import { publishMainStream, publishGroupMessagingStream } from '../../../../../services/stream'; import { publishMessagingStream, publishMessagingIndexStream } from '../../../../../services/stream'; import pushSw from '../../../../../services/push-notification'; import define from '../../../define'; import { ApiError } from '../../../error'; import { getUser } from '../../../common/getters'; -import { MessagingMessages, DriveFiles, Mutings } from '../../../../../models'; +import { MessagingMessages, DriveFiles, Mutings, UserGroups, UserGroupJoinings } from '../../../../../models'; import { MessagingMessage } from '../../../../../models/entities/messaging-message'; import { genId } from '../../../../../misc/gen-id'; import { types, bool } from '../../../../../misc/schema'; +import { User } from '../../../../../models/entities/user'; +import { UserGroup } from '../../../../../models/entities/user-group'; +import { Not } from 'typeorm'; export const meta = { desc: { - 'ja-JP': '指定したユーザーへMessagingのメッセージを送信します。', + 'ja-JP': 'トークメッセージを送信します。', 'en-US': 'Create a message of messaging.' }, @@ -25,13 +28,21 @@ export const meta = { params: { userId: { - validator: $.type(ID), + validator: $.optional.type(ID), desc: { 'ja-JP': '対象のユーザーのID', 'en-US': 'Target user ID' } }, + groupId: { + validator: $.optional.type(ID), + desc: { + 'ja-JP': '対象のグループのID', + 'en-US': 'Target group ID' + } + }, + text: { validator: $.optional.str.pipe(MessagingMessages.isValidText) }, @@ -60,6 +71,18 @@ export const meta = { id: '11795c64-40ea-4198-b06e-3c873ed9039d' }, + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'c94e2a5d-06aa-4914-8fa6-6a42e73d6537' + }, + + groupAccessDenied: { + message: 'You can not send messages to groups that you have not joined.', + code: 'GROUP_ACCESS_DENIED', + id: 'd96b3cca-5ad1-438b-ad8b-02f931308fbd' + }, + noSuchFile: { message: 'No such file.', code: 'NO_SUCH_FILE', @@ -75,16 +98,38 @@ export const meta = { }; export default define(meta, async (ps, user) => { - // Myself - if (ps.userId === user.id) { - throw new ApiError(meta.errors.recipientIsYourself); - } + let recipientUser: User | undefined; + let recipientGroup: UserGroup | undefined; - // Fetch recipient - const recipient = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw e; - }); + if (ps.userId != null) { + // Myself + if (ps.userId === user.id) { + throw new ApiError(meta.errors.recipientIsYourself); + } + + // Fetch recipient (user) + recipientUser = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + } else if (ps.groupId != null) { + // Fetch recipient (group) + recipientGroup = await UserGroups.findOne(ps.groupId); + + if (recipientGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // check joined + const joining = await UserGroupJoinings.findOne({ + userId: user.id, + userGroupId: recipientGroup.id + }); + + if (joining == null) { + throw new ApiError(meta.errors.groupAccessDenied); + } + } let file = null; if (ps.fileId != null) { @@ -107,32 +152,49 @@ export default define(meta, async (ps, user) => { id: genId(), createdAt: new Date(), fileId: file ? file.id : null, - recipientId: recipient.id, + recipientId: recipientUser ? recipientUser.id : null, + groupId: recipientGroup ? recipientGroup.id : null, text: ps.text ? ps.text.trim() : null, userId: user.id, - isRead: false + isRead: false, + reads: [] as any[] } as MessagingMessage); const messageObj = await MessagingMessages.pack(message); - // 自分のストリーム - publishMessagingStream(message.userId, message.recipientId, 'message', messageObj); - publishMessagingIndexStream(message.userId, 'message', messageObj); - publishMainStream(message.userId, 'messagingMessage', messageObj); + if (recipientUser) { + // 自分のストリーム + publishMessagingStream(message.userId, recipientUser.id, 'message', messageObj); + publishMessagingIndexStream(message.userId, 'message', messageObj); + publishMainStream(message.userId, 'messagingMessage', messageObj); - // 相手のストリーム - publishMessagingStream(message.recipientId, message.userId, 'message', messageObj); - publishMessagingIndexStream(message.recipientId, 'message', messageObj); - publishMainStream(message.recipientId, 'messagingMessage', messageObj); + // 相手のストリーム + publishMessagingStream(recipientUser.id, message.userId, 'message', messageObj); + publishMessagingIndexStream(recipientUser.id, 'message', messageObj); + publishMainStream(recipientUser.id, 'messagingMessage', messageObj); + } else if (recipientGroup) { + // グループのストリーム + publishGroupMessagingStream(recipientGroup.id, 'message', messageObj); + + // メンバーのストリーム + const joinings = await UserGroupJoinings.find({ userGroupId: recipientGroup.id }); + for (const joining of joinings) { + publishMessagingIndexStream(joining.userId, 'message', messageObj); + publishMainStream(joining.userId, 'messagingMessage', messageObj); + } + } // 2秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する setTimeout(async () => { - const freshMessage = await MessagingMessages.findOne({ id: message.id }); + const freshMessage = await MessagingMessages.findOne(message.id); if (freshMessage == null) return; // メッセージが削除されている場合もある - if (!freshMessage.isRead) { + + if (recipientUser) { + if (freshMessage.isRead) return; // 既読 + //#region ただしミュートされているなら発行しない const mute = await Mutings.find({ - muterId: recipient.id, + muterId: recipientUser.id, }); const mutedUserIds = mute.map(m => m.muteeId.toString()); if (mutedUserIds.indexOf(user.id) != -1) { @@ -140,8 +202,15 @@ export default define(meta, async (ps, user) => { } //#endregion - publishMainStream(message.recipientId, 'unreadMessagingMessage', messageObj); - pushSw(message.recipientId, 'unreadMessagingMessage', messageObj); + publishMainStream(recipientUser.id, 'unreadMessagingMessage', messageObj); + pushSw(recipientUser.id, 'unreadMessagingMessage', messageObj); + } else if (recipientGroup) { + const joinings = await UserGroupJoinings.find({ userGroupId: recipientGroup.id, userId: Not(user.id) }); + for (const joining of joinings) { + if (freshMessage.reads.includes(joining.userId)) return; // 既読 + publishMainStream(joining.userId, 'unreadMessagingMessage', messageObj); + pushSw(joining.userId, 'unreadMessagingMessage', messageObj); + } } }, 2000); diff --git a/src/server/api/endpoints/messaging/messages/delete.ts b/src/server/api/endpoints/messaging/messages/delete.ts index 6a896cd8d1..fb1bb42a56 100644 --- a/src/server/api/endpoints/messaging/messages/delete.ts +++ b/src/server/api/endpoints/messaging/messages/delete.ts @@ -1,7 +1,7 @@ import $ from 'cafy'; import { ID } from '../../../../../misc/cafy-id'; import define from '../../../define'; -import { publishMessagingStream } from '../../../../../services/stream'; +import { publishMessagingStream, publishGroupMessagingStream } from '../../../../../services/stream'; import * as ms from 'ms'; import { ApiError } from '../../../error'; import { MessagingMessages } from '../../../../../models'; @@ -10,7 +10,7 @@ export const meta = { stability: 'stable', desc: { - 'ja-JP': '指定したメッセージを削除します。', + 'ja-JP': '指定したトークメッセージを削除します。', 'en-US': 'Delete a message.' }, @@ -57,6 +57,10 @@ export default define(meta, async (ps, user) => { await MessagingMessages.delete(message.id); - publishMessagingStream(message.userId, message.recipientId, 'deleted', message.id); - publishMessagingStream(message.recipientId, message.userId, 'deleted', message.id); + if (message.recipientId) { + publishMessagingStream(message.userId, message.recipientId, 'deleted', message.id); + publishMessagingStream(message.recipientId, message.userId, 'deleted', message.id); + } else if (message.groupId) { + publishGroupMessagingStream(message.groupId, 'deleted', message.id); + } }); diff --git a/src/server/api/endpoints/messaging/messages/read.ts b/src/server/api/endpoints/messaging/messages/read.ts index 50b7f39870..dd3449af15 100644 --- a/src/server/api/endpoints/messaging/messages/read.ts +++ b/src/server/api/endpoints/messaging/messages/read.ts @@ -1,13 +1,13 @@ import $ from 'cafy'; import { ID } from '../../../../../misc/cafy-id'; -import read from '../../../common/read-messaging-message'; import define from '../../../define'; import { ApiError } from '../../../error'; import { MessagingMessages } from '../../../../../models'; +import { readUserMessagingMessage, readGroupMessagingMessage } from '../../../common/read-messaging-message'; export const meta = { desc: { - 'ja-JP': '指定した自分宛てのメッセージを既読にします。', + 'ja-JP': '指定した自分宛てのトークメッセージを既読にします。', 'en-US': 'Mark as read a message of messaging.' }, @@ -39,12 +39,21 @@ export const meta = { export default define(meta, async (ps, user) => { const message = await MessagingMessages.findOne({ id: ps.messageId, - recipientId: user.id }); if (message == null) { throw new ApiError(meta.errors.noSuchMessage); } - read(user.id, message.userId, [message.id]); + if (message.recipientId) { + await readUserMessagingMessage(user.id, message.recipientId, [message.id]).catch(e => { + if (e.id === 'e140a4bf-49ce-4fb6-b67c-b78dadf6b52f') throw new ApiError(meta.errors.noSuchMessage); + throw e; + }); + } else if (message.groupId) { + await readGroupMessagingMessage(user.id, message.groupId, [message.id]).catch(e => { + if (e.id === '930a270c-714a-46b2-b776-ad27276dc569') throw new ApiError(meta.errors.noSuchMessage); + throw e; + }); + } }); diff --git a/src/server/api/endpoints/users/groups/create.ts b/src/server/api/endpoints/users/groups/create.ts new file mode 100644 index 0000000000..ee6cade8d0 --- /dev/null +++ b/src/server/api/endpoints/users/groups/create.ts @@ -0,0 +1,51 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { UserGroups, UserGroupJoinings } from '../../../../../models'; +import { genId } from '../../../../../misc/gen-id'; +import { UserGroup } from '../../../../../models/entities/user-group'; +import { types, bool } from '../../../../../misc/schema'; +import { UserGroupJoining } from '../../../../../models/entities/user-group-joining'; + +export const meta = { + desc: { + 'ja-JP': 'ユーザーグループを作成します。', + 'en-US': 'Create a user group.' + }, + + tags: ['groups'], + + requireCredential: true, + + kind: 'write:user-groups', + + params: { + name: { + validator: $.str.range(1, 100) + } + }, + + res: { + type: types.object, + optional: bool.false, nullable: bool.false, + ref: 'UserGroup', + }, +}; + +export default define(meta, async (ps, user) => { + const userGroup = await UserGroups.save({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + } as UserGroup); + + // Push the owner + await UserGroupJoinings.save({ + id: genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: userGroup.id + } as UserGroupJoining); + + return await UserGroups.pack(userGroup); +}); diff --git a/src/server/api/endpoints/users/groups/delete.ts b/src/server/api/endpoints/users/groups/delete.ts new file mode 100644 index 0000000000..4f89c324a1 --- /dev/null +++ b/src/server/api/endpoints/users/groups/delete.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '../../../../../misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserGroups } from '../../../../../models'; + +export const meta = { + desc: { + 'ja-JP': '指定したユーザーグループを削除します。', + 'en-US': 'Delete a user group' + }, + + tags: ['groups'], + + requireCredential: true, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + desc: { + 'ja-JP': '対象となるユーザーグループのID', + 'en-US': 'ID of target user group' + } + } + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '63dbd64c-cd77-413f-8e08-61781e210b38' + } + } +}; + +export default define(meta, async (ps, user) => { + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: user.id + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + await UserGroups.delete(userGroup.id); +}); diff --git a/src/server/api/endpoints/users/groups/joined.ts b/src/server/api/endpoints/users/groups/joined.ts new file mode 100644 index 0000000000..14561fce05 --- /dev/null +++ b/src/server/api/endpoints/users/groups/joined.ts @@ -0,0 +1,33 @@ +import define from '../../../define'; +import { UserGroups, UserGroupJoinings } from '../../../../../models'; +import { types, bool } from '../../../../../misc/schema'; + +export const meta = { + desc: { + 'ja-JP': '自分の所属するユーザーグループ一覧を取得します。' + }, + + tags: ['groups', 'account'], + + requireCredential: true, + + kind: 'read:user-groups', + + res: { + type: types.array, + optional: bool.false, nullable: bool.false, + items: { + type: types.object, + optional: bool.false, nullable: bool.false, + ref: 'UserGroup', + } + }, +}; + +export default define(meta, async (ps, me) => { + const joinings = await UserGroupJoinings.find({ + userId: me.id, + }); + + return await Promise.all(joinings.map(x => UserGroups.pack(x.userGroupId))); +}); diff --git a/src/server/api/endpoints/users/groups/owned.ts b/src/server/api/endpoints/users/groups/owned.ts new file mode 100644 index 0000000000..6cf39a142b --- /dev/null +++ b/src/server/api/endpoints/users/groups/owned.ts @@ -0,0 +1,33 @@ +import define from '../../../define'; +import { UserGroups } from '../../../../../models'; +import { types, bool } from '../../../../../misc/schema'; + +export const meta = { + desc: { + 'ja-JP': '自分の作成したユーザーグループ一覧を取得します。' + }, + + tags: ['groups', 'account'], + + requireCredential: true, + + kind: 'read:user-groups', + + res: { + type: types.array, + optional: bool.false, nullable: bool.false, + items: { + type: types.object, + optional: bool.false, nullable: bool.false, + ref: 'UserGroup', + } + }, +}; + +export default define(meta, async (ps, me) => { + const userGroups = await UserGroups.find({ + userId: me.id, + }); + + return await Promise.all(userGroups.map(x => UserGroups.pack(x))); +}); diff --git a/src/server/api/endpoints/users/groups/pull.ts b/src/server/api/endpoints/users/groups/pull.ts new file mode 100644 index 0000000000..5fc0c2fa5e --- /dev/null +++ b/src/server/api/endpoints/users/groups/pull.ts @@ -0,0 +1,68 @@ +import $ from 'cafy'; +import { ID } from '../../../../../misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { UserGroups, UserGroupJoinings } from '../../../../../models'; + +export const meta = { + desc: { + 'ja-JP': '指定したユーザーグループから指定したユーザーを削除します。', + 'en-US': 'Remove a user to a user group.' + }, + + tags: ['groups', 'users'], + + requireCredential: true, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + + userId: { + validator: $.type(ID), + desc: { + 'ja-JP': '対象のユーザーのID', + 'en-US': 'Target user ID' + } + }, + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '4662487c-05b1-4b78-86e5-fd46998aba74' + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '0b5cc374-3681-41da-861e-8bc1146f7a55' + } + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Pull the user + await UserGroupJoinings.delete({ userId: user.id }); +}); diff --git a/src/server/api/endpoints/users/groups/push.ts b/src/server/api/endpoints/users/groups/push.ts new file mode 100644 index 0000000000..5371580db0 --- /dev/null +++ b/src/server/api/endpoints/users/groups/push.ts @@ -0,0 +1,90 @@ +import $ from 'cafy'; +import { ID } from '../../../../../misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { UserGroups, UserGroupJoinings } from '../../../../../models'; +import { genId } from '../../../../../misc/gen-id'; +import { UserGroupJoining } from '../../../../../models/entities/user-group-joining'; + +export const meta = { + desc: { + 'ja-JP': '指定したユーザーグループに指定したユーザーを追加します。', + 'en-US': 'Add a user to a user group.' + }, + + tags: ['groups', 'users'], + + requireCredential: true, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + + userId: { + validator: $.type(ID), + desc: { + 'ja-JP': '対象のユーザーのID', + 'en-US': 'Target user ID' + } + }, + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '583f8bc0-8eee-4b78-9299-1e14fc91e409' + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'da52de61-002c-475b-90e1-ba64f9cf13a8' + }, + + alreadyAdded: { + message: 'That user has already been added to that group.', + code: 'ALREADY_ADDED', + id: '7e35c6a0-39b2-4488-aea6-6ee20bd5da2c' + } + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const exist = await UserGroupJoinings.findOne({ + userGroupId: userGroup.id, + userId: user.id + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyAdded); + } + + // Push the user + await UserGroupJoinings.save({ + id: genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: userGroup.id + } as UserGroupJoining); +}); diff --git a/src/server/api/endpoints/users/groups/show.ts b/src/server/api/endpoints/users/groups/show.ts new file mode 100644 index 0000000000..5f2c839881 --- /dev/null +++ b/src/server/api/endpoints/users/groups/show.ts @@ -0,0 +1,53 @@ +import $ from 'cafy'; +import { ID } from '../../../../../misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserGroups } from '../../../../../models'; +import { types, bool } from '../../../../../misc/schema'; + +export const meta = { + desc: { + 'ja-JP': '指定したユーザーグループの情報を取得します。', + 'en-US': 'Show a user group.' + }, + + tags: ['groups', 'account'], + + requireCredential: true, + + kind: 'read:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + }, + + res: { + type: types.object, + optional: bool.false, nullable: bool.false, + ref: 'UserGroup', + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'ea04751e-9b7e-487b-a509-330fb6bd6b9b' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + return await UserGroups.pack(userGroup); +}); diff --git a/src/server/api/endpoints/users/lists/push.ts b/src/server/api/endpoints/users/lists/push.ts index 2763b3a19c..bdc8403083 100644 --- a/src/server/api/endpoints/users/lists/push.ts +++ b/src/server/api/endpoints/users/lists/push.ts @@ -80,5 +80,5 @@ export default define(meta, async (ps, me) => { } // Push the user - pushUserToUserList(user, userList); + await pushUserToUserList(user, userList); }); diff --git a/src/server/api/kinds.ts b/src/server/api/kinds.ts index 76d5a8a61a..be3c30f7d9 100644 --- a/src/server/api/kinds.ts +++ b/src/server/api/kinds.ts @@ -23,4 +23,6 @@ export const kinds = [ 'write:pages', 'write:page-likes', 'read:page-likes', + 'read:user-groups', + 'write:user-groups', ]; diff --git a/src/server/api/openapi/schemas.ts b/src/server/api/openapi/schemas.ts index 628bba511f..32f69bdef3 100644 --- a/src/server/api/openapi/schemas.ts +++ b/src/server/api/openapi/schemas.ts @@ -13,6 +13,7 @@ import { packedBlockingSchema } from '../../../models/repositories/blocking'; import { packedNoteReactionSchema } from '../../../models/repositories/note-reaction'; import { packedHashtagSchema } from '../../../models/repositories/hashtag'; import { packedPageSchema } from '../../../models/repositories/page'; +import { packedUserGroupSchema } from '../../../models/repositories/user-group'; export function convertSchemaToOpenApiSchema(schema: Schema) { const res: any = schema; @@ -66,6 +67,7 @@ export const schemas = { User: convertSchemaToOpenApiSchema(packedUserSchema), UserList: convertSchemaToOpenApiSchema(packedUserListSchema), + UserGroup: convertSchemaToOpenApiSchema(packedUserGroupSchema), App: convertSchemaToOpenApiSchema(packedAppSchema), MessagingMessage: convertSchemaToOpenApiSchema(packedMessagingMessageSchema), Note: convertSchemaToOpenApiSchema(packedNoteSchema), diff --git a/src/server/api/stream/channels/messaging.ts b/src/server/api/stream/channels/messaging.ts index ce766e28e9..1e5e94c1c8 100644 --- a/src/server/api/stream/channels/messaging.ts +++ b/src/server/api/stream/channels/messaging.ts @@ -1,20 +1,39 @@ import autobind from 'autobind-decorator'; -import read from '../../common/read-messaging-message'; +import { readUserMessagingMessage, readGroupMessagingMessage } from '../../common/read-messaging-message'; import Channel from '../channel'; +import { UserGroupJoinings } from '../../../../models'; export default class extends Channel { public readonly chName = 'messaging'; public static shouldShare = false; public static requireCredential = true; - private otherpartyId: string; + private otherpartyId: string | null; + private groupId: string | null; @autobind public async init(params: any) { this.otherpartyId = params.otherparty as string; + this.groupId = params.group as string; + + // Check joining + if (this.groupId) { + const joining = await UserGroupJoinings.findOne({ + userId: this.user!.id, + userGroupId: this.groupId + }); + + if (joining == null) { + return; + } + } + + const subCh = this.otherpartyId + ? `messagingStream:${this.user!.id}-${this.otherpartyId}` + : `messagingStream:${this.groupId}`; // Subscribe messaging stream - this.subscriber.on(`messagingStream:${this.user!.id}-${this.otherpartyId}`, data => { + this.subscriber.on(subCh, data => { this.send(data); }); } @@ -23,7 +42,11 @@ export default class extends Channel { public onMessage(type: string, body: any) { switch (type) { case 'read': - read(this.user!.id, this.otherpartyId, [body.id]); + if (this.otherpartyId) { + readUserMessagingMessage(this.user!.id, this.otherpartyId, [body.id]); + } else if (this.groupId) { + readGroupMessagingMessage(this.user!.id, this.groupId, [body.id]); + } break; } } diff --git a/src/services/stream.ts b/src/services/stream.ts index 28cb2057e2..a47798eefd 100644 --- a/src/services/stream.ts +++ b/src/services/stream.ts @@ -3,6 +3,7 @@ import { User } from '../models/entities/user'; import { Note } from '../models/entities/note'; import { UserList } from '../models/entities/user-list'; import { ReversiGame } from '../models/entities/games/reversi/game'; +import { UserGroup } from '../models/entities/user-group'; class Publisher { private publish = (channel: string, type: string | null, value?: any): void => { @@ -39,6 +40,10 @@ class Publisher { this.publish(`messagingStream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); } + public publishGroupMessagingStream = (groupId: UserGroup['id'], type: string, value?: any): void => { + this.publish(`messagingStream:${groupId}`, type, typeof value === 'undefined' ? null : value); + } + public publishMessagingIndexStream = (userId: User['id'], type: string, value?: any): void => { this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value); } @@ -74,6 +79,7 @@ export const publishNoteStream = publisher.publishNoteStream; export const publishNotesStream = publisher.publishNotesStream; export const publishUserListStream = publisher.publishUserListStream; export const publishMessagingStream = publisher.publishMessagingStream; +export const publishGroupMessagingStream = publisher.publishGroupMessagingStream; export const publishMessagingIndexStream = publisher.publishMessagingIndexStream; export const publishReversiStream = publisher.publishReversiStream; export const publishReversiGameStream = publisher.publishReversiGameStream;