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