ユーザーグループ

Resolve #3218
This commit is contained in:
syuilo 2019-05-18 20:36:33 +09:00
parent 3d1239a3b3
commit 20c34ead23
65 changed files with 1797 additions and 638 deletions

View file

@ -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: "前の投稿"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -50,6 +50,11 @@ export default Vue.extend({
},
created() {
this.fetch();
this.$emit('init', {
title: this.$t('@.pages'),
icon: faStickyNote
});
},
methods: {
async fetch() {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -80,5 +80,5 @@ export default define(meta, async (ps, me) => {
}
// Push the user
pushUserToUserList(user, userList);
await pushUserToUserList(user, userList);
});

View file

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

View file

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

View file

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

View file

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