This commit is contained in:
syuilo 2019-05-19 20:41:23 +09:00
parent 56678cbac0
commit e103904a04
No known key found for this signature in database
GPG key ID: BDC4C49D06AB9D69
15 changed files with 379 additions and 34 deletions

View file

@ -485,6 +485,7 @@ common/views/components/messaging.vue:
group: "グループ"
start-with-user: "ユーザーとトークを開始"
start-with-group: "グループとトークを開始"
select-group: "グループを選択してください"
common/views/components/messaging-room.vue:
not-talked-user: "このユーザーとの会話はありません"
@ -764,7 +765,8 @@ common/views/components/user-group-editor.vue:
remove-user: "このグループから削除"
delete-are-you-sure: "グループ「$1」を削除しますか"
deleted: "削除しました"
add-user: "メンバーを追加"
invite: "招待"
invited: "招待を送信しました"
common/views/components/user-lists.vue:
user-lists: "リスト"
@ -775,6 +777,11 @@ common/views/components/user-groups.vue:
user-groups: "グループ"
create-group: "グループを作成"
group-name: "グループ名"
owned-groups: "自分のグループ"
joined-groups: "参加しているグループ"
invites: "招待"
accept-invite: "参加"
reject-invite: "拒否"
common/views/widgets/broadcast.vue:
fetching: "確認中"

View file

@ -0,0 +1,25 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class UserGroupInvite1558257926829 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`CREATE TABLE "user_group_invite" ("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_3893884af0d3a5f4d01e7921a97" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_1039988afa3bf991185b277fe0" ON "user_group_invite" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_e10924607d058004304611a436" ON "user_group_invite" ("userGroupId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_78787741f9010886796f2320a4" ON "user_group_invite" ("userId", "userGroupId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d9ecaed8c6dc43f3592c229282" ON "user_group_joining" ("userId", "userGroupId") `);
await queryRunner.query(`ALTER TABLE "user_group_invite" ADD CONSTRAINT "FK_1039988afa3bf991185b277fe03" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "user_group_invite" ADD CONSTRAINT "FK_e10924607d058004304611a436a" 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_invite" DROP CONSTRAINT "FK_e10924607d058004304611a436a"`);
await queryRunner.query(`ALTER TABLE "user_group_invite" DROP CONSTRAINT "FK_1039988afa3bf991185b277fe03"`);
await queryRunner.query(`DROP INDEX "IDX_d9ecaed8c6dc43f3592c229282"`);
await queryRunner.query(`DROP INDEX "IDX_78787741f9010886796f2320a4"`);
await queryRunner.query(`DROP INDEX "IDX_e10924607d058004304611a436"`);
await queryRunner.query(`DROP INDEX "IDX_1039988afa3bf991185b277fe0"`);
await queryRunner.query(`DROP TABLE "user_group_invite"`);
}
}

View file

@ -217,12 +217,13 @@ export default Vue.extend({
this.navigate(user);
},
async startGroup() {
const groups = await this.$root.api('users/groups/joined');
const groups1 = await this.$root.api('users/groups/owned');
const groups2 = 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 => ({
items: groups1.concat(groups2).map(group => ({
value: group, text: group.name
}))
},

View file

@ -16,7 +16,7 @@
<section>
<ui-margin>
<ui-button @click="add"><fa :icon="faPlus"/> {{ $t('add-user') }}</ui-button>
<ui-button @click="invite()"><fa :icon="faPlus"/> {{ $t('invite') }}</ui-button>
</ui-margin>
<sequential-entrance animation="entranceFromTop" delay="25">
<div class="kjlrfbes" v-for="user in users">
@ -134,18 +134,22 @@ export default Vue.extend({
});
},
async add() {
async invite() {
const t = this.$t('invited');
const { result: user } = await this.$root.dialog({
user: {
local: true
}
});
if (user == null) return;
this.$root.api('users/groups/push', {
this.$root.api('users/groups/invite', {
groupId: this.group.id,
userId: user.id
}).then(() => {
this.fetchUsers();
this.$root.dialog({
type: 'success',
text: t
});
});
}
}

View file

@ -1,36 +1,70 @@
<template>
<div>
<ui-container>
<template #header><fa :icon="faUsers"/> {{ $t('user-groups') }}</template>
<template #header><fa :icon="faUsers"/> {{ $t('owned-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">
<div class="hwgkdrbl" v-for="group in ownedGroups" :key="group.id">
<ui-hr/>
<ui-margin>
<router-link :to="`/i/groups/${group.id}`">{{ group.name }}</router-link>
</ui-margin>
</div>
</ui-container>
<ui-container>
<template #header><fa :icon="faUsers"/> {{ $t('joined-groups') }}</template>
<div class="hwgkdrbl" v-for="(group, i) in joinedGroups" :key="group.id">
<ui-hr v-if="i != 0"/>
<ui-margin>
<router-link :to="`/i/groups/${group.id}`">{{ group.name }}</router-link>
</ui-margin>
</div>
</ui-container>
<ui-container>
<template #header><fa :icon="faEnvelopeOpenText"/> {{ $t('invites') }}</template>
<div class="fvlojuur" v-for="(invite, i) in invites" :key="invite.id">
<ui-hr v-if="i != 0"/>
<ui-margin>
<div class="name">{{ invite.group.name }}</div>
<ui-horizon-group>
<ui-button @click="acceptInvite(invite)"><fa :icon="faCheck"/> {{ $t('accept-invite') }}</ui-button>
<ui-button @click="rejectInvite(invite)"><fa :icon="faBan"/> {{ $t('reject-invite') }}</ui-button>
</ui-horizon-group>
</ui-margin>
</div>
</ui-container>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import { faUsers, faPlus } from '@fortawesome/free-solid-svg-icons';
import { faUsers, faPlus, faCheck, faBan, faEnvelopeOpenText } 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
ownedGroups: [],
joinedGroups: [],
invites: [],
faUsers, faPlus, faCheck, faBan, faEnvelopeOpenText
};
},
mounted() {
this.$root.api('users/groups/owned').then(groups => {
this.fetching = false;
this.groups = groups;
this.ownedGroups = groups;
});
this.$root.api('users/groups/joined').then(groups => {
this.joinedGroups = groups;
});
this.$root.api('i/user-group-invites').then(invites => {
this.invites = invites;
});
this.$emit('init', {
@ -45,13 +79,35 @@ export default Vue.extend({
input: true
}).then(async ({ canceled, result: name }) => {
if (canceled) return;
const list = await this.$root.api('users/groups/create', {
const group = await this.$root.api('users/groups/create', {
name
});
this.groups.push(list)
this.ownedGroups.push(group)
});
},
acceptInvite(invite) {
this.$root.api('users/groups/invitations/accept', {
inviteId: invite.id
}).then(() => {
this.$root.dialog({
type: 'success',
splash: true
});
this.$root.api('i/user-group-invites').then(invites => {
this.invites = invites;
});
});
},
rejectInvite(invite) {
this.$root.api('users/groups/invitations/reject', {
inviteId: invite.id
}).then(() => {
this.$root.api('i/user-group-invites').then(invites => {
this.invites = invites;
});
});
}
}
});
</script>

View file

@ -26,6 +26,7 @@ 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 { UserGroupInvite } from '../models/entities/user-group-invite';
import { Hashtag } from '../models/entities/hashtag';
import { NoteFavorite } from '../models/entities/note-favorite';
import { AbuseUserReport } from '../models/entities/abuse-user-report';
@ -110,6 +111,7 @@ export function initDb(justBorrow = false, sync = false, log = false) {
UserListJoining,
UserGroup,
UserGroupJoining,
UserGroupInvite,
UserNotePining,
Following,
FollowRequest,

View file

@ -0,0 +1,42 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { User } from './user';
import { UserGroup } from './user-group';
import { id } from '../id';
@Entity()
@Index(['userId', 'userGroupId'], { unique: true })
export class UserGroupInvite {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone', {
comment: 'The created date of the UserGroupInvite.'
})
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

@ -4,6 +4,7 @@ import { UserGroup } from './user-group';
import { id } from '../id';
@Entity()
@Index(['userId', 'userGroupId'], { unique: true })
export class UserGroupJoining {
@PrimaryColumn(id())
public id: string;

View file

@ -22,6 +22,7 @@ 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 { UserGroupInviteRepository } from './repositories/user-group-invite';
import { FollowRequestRepository } from './repositories/follow-request';
import { MutingRepository } from './repositories/muting';
import { BlockingRepository } from './repositories/blocking';
@ -56,6 +57,7 @@ export const UserLists = getCustomRepository(UserListRepository);
export const UserListJoinings = getRepository(UserListJoining);
export const UserGroups = getCustomRepository(UserGroupRepository);
export const UserGroupJoinings = getRepository(UserGroupJoining);
export const UserGroupInvites = getCustomRepository(UserGroupInviteRepository);
export const UserNotePinings = getRepository(UserNotePining);
export const Followings = getCustomRepository(FollowingRepository);
export const FollowRequests = getCustomRepository(FollowRequestRepository);

View file

@ -0,0 +1,24 @@
import { EntityRepository, Repository } from 'typeorm';
import { UserGroupInvite } from '../entities/user-group-invite';
import { UserGroups } from '..';
import { ensure } from '../../prelude/ensure';
@EntityRepository(UserGroupInvite)
export class UserGroupInviteRepository extends Repository<UserGroupInvite> {
public async pack(
src: UserGroupInvite['id'] | UserGroupInvite,
) {
const invite = typeof src === 'object' ? src : await this.findOne(src).then(ensure);
return {
id: invite.id,
group: await UserGroups.pack(invite.userGroup || invite.userGroupId),
};
}
public packMany(
invites: any[],
) {
return Promise.all(invites.map(x => this.pack(x)));
}
}

View file

@ -0,0 +1,45 @@
import $ from 'cafy';
import { ID } from '../../../../misc/cafy-id';
import define from '../../define';
import { UserGroupInvites } from '../../../../models';
import { makePaginationQuery } from '../../common/make-pagination-query';
export const meta = {
desc: {
'ja-JP': 'グループへの招待一覧を取得します。',
'en-US': 'Get user group invitations.'
},
tags: ['account', 'groups'],
requireCredential: true,
kind: 'read:user-groups',
params: {
limit: {
validator: $.optional.num.range(1, 100),
default: 10
},
sinceId: {
validator: $.optional.type(ID),
},
untilId: {
validator: $.optional.type(ID),
},
}
};
export default define(meta, async (ps, user) => {
const query = makePaginationQuery(UserGroupInvites.createQueryBuilder('invite'), ps.sinceId, ps.untilId)
.andWhere(`invite.userId = :meId`, { meId: user.id })
.leftJoinAndSelect('invite.userGroup', 'user_group');
const invites = await query
.take(ps.limit!)
.getMany();
return await UserGroupInvites.packMany(invites);
});

View file

@ -0,0 +1,63 @@
import $ from 'cafy';
import { ID } from '../../../../../../misc/cafy-id';
import define from '../../../../define';
import { ApiError } from '../../../../error';
import { UserGroupJoinings, UserGroupInvites } from '../../../../../../models';
import { genId } from '../../../../../../misc/gen-id';
import { UserGroupJoining } from '../../../../../../models/entities/user-group-joining';
export const meta = {
desc: {
'ja-JP': 'ユーザーグループへの招待を承認します。',
'en-US': 'Accept invite of a user group.'
},
tags: ['groups', 'users'],
requireCredential: true,
kind: 'write:user-groups',
params: {
inviteId: {
validator: $.type(ID),
desc: {
'ja-JP': '招待ID',
'en-US': 'The invite ID'
}
},
},
errors: {
noSuchInvitation: {
message: 'No such invitation.',
code: 'NO_SUCH_INVITATION',
id: '98c11eca-c890-4f42-9806-c8c8303ebb5e'
},
}
};
export default define(meta, async (ps, user) => {
// Fetch the invitation
const invite = await UserGroupInvites.findOne({
id: ps.inviteId,
});
if (invite == null) {
throw new ApiError(meta.errors.noSuchInvitation);
}
if (invite.userId !== user.id) {
throw new ApiError(meta.errors.noSuchInvitation);
}
// Push the user
await UserGroupJoinings.save({
id: genId(),
createdAt: new Date(),
userId: user.id,
userGroupId: invite.userGroupId
} as UserGroupJoining);
UserGroupInvites.delete(invite.id);
});

View file

@ -0,0 +1,53 @@
import $ from 'cafy';
import { ID } from '../../../../../../misc/cafy-id';
import define from '../../../../define';
import { ApiError } from '../../../../error';
import { UserGroupInvites } from '../../../../../../models';
export const meta = {
desc: {
'ja-JP': 'ユーザーグループへの招待を拒否します。',
'en-US': 'Reject invite of a user group.'
},
tags: ['groups', 'users'],
requireCredential: true,
kind: 'write:user-groups',
params: {
inviteId: {
validator: $.type(ID),
desc: {
'ja-JP': '招待ID',
'en-US': 'The invite ID'
}
},
},
errors: {
noSuchInvitation: {
message: 'No such invitation.',
code: 'NO_SUCH_INVITATION',
id: 'ad7471d4-2cd9-44b4-ac68-e7136b4ce656'
},
}
};
export default define(meta, async (ps, user) => {
// Fetch the invitation
const invite = await UserGroupInvites.findOne({
id: ps.inviteId,
});
if (invite == null) {
throw new ApiError(meta.errors.noSuchInvitation);
}
if (invite.userId !== user.id) {
throw new ApiError(meta.errors.noSuchInvitation);
}
await UserGroupInvites.delete(invite.id);
});

View file

@ -3,14 +3,14 @@ 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 { UserGroups, UserGroupJoinings, UserGroupInvites } from '../../../../../models';
import { genId } from '../../../../../misc/gen-id';
import { UserGroupJoining } from '../../../../../models/entities/user-group-joining';
import { UserGroupInvite } from '../../../../../models/entities/user-group-invite';
export const meta = {
desc: {
'ja-JP': '指定したユーザーグループに指定したユーザーを追加します。',
'en-US': 'Add a user to a user group.'
'ja-JP': '指定したユーザーグループに指定したユーザーを招待します。',
'en-US': 'Invite a user to a user group.'
},
tags: ['groups', 'users'],
@ -50,6 +50,12 @@ export const meta = {
message: 'That user has already been added to that group.',
code: 'ALREADY_ADDED',
id: '7e35c6a0-39b2-4488-aea6-6ee20bd5da2c'
},
alreadyInvited: {
message: 'That user has already been invited to that group.',
code: 'ALREADY_INVITED',
id: 'ee0f58b4-b529-4d13-b761-b9a3e69f97e6'
}
}
};
@ -71,20 +77,28 @@ export default define(meta, async (ps, me) => {
throw e;
});
const exist = await UserGroupJoinings.findOne({
const joining = await UserGroupJoinings.findOne({
userGroupId: userGroup.id,
userId: user.id
});
if (exist) {
if (joining) {
throw new ApiError(meta.errors.alreadyAdded);
}
// Push the user
await UserGroupJoinings.save({
const invite = await UserGroupInvites.findOne({
userGroupId: userGroup.id,
userId: user.id
});
if (invite) {
throw new ApiError(meta.errors.alreadyInvited);
}
await UserGroupInvites.save({
id: genId(),
createdAt: new Date(),
userId: user.id,
userGroupId: userGroup.id
} as UserGroupJoining);
} as UserGroupInvite);
});

View file

@ -1,6 +1,7 @@
import define from '../../../define';
import { UserGroups, UserGroupJoinings } from '../../../../../models';
import { types, bool } from '../../../../../misc/schema';
import { Not, In } from 'typeorm';
export const meta = {
desc: {
@ -25,8 +26,13 @@ export const meta = {
};
export default define(meta, async (ps, me) => {
const ownedGroups = await UserGroups.find({
userId: me.id,
});
const joinings = await UserGroupJoinings.find({
userId: me.id,
userGroupId: Not(In(ownedGroups.map(x => x.id)))
});
return await Promise.all(joinings.map(x => UserGroups.pack(x.userGroupId)));