ユーザーグループ

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

View File

@ -18,6 +18,7 @@
<fa icon="spinner" pulse v-if="type === 'waiting'"/>
</div>
<header v-if="title" v-html="title"></header>
<header v-if="title == null && user">{{ $t('@.enter-username') }}</header>
<div class="body" v-if="text" v-html="text"></div>
<ui-input v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></ui-input>
<ui-input v-if="user" v-model="userInputValue" autofocus @keydown="onInputKeydown"><template #prefix>@</template></ui-input>

View File

@ -44,6 +44,8 @@ import uiSwitch from './ui/switch.vue';
import uiRadio from './ui/radio.vue';
import uiSelect from './ui/select.vue';
import uiInfo from './ui/info.vue';
import uiMargin from './ui/margin.vue';
import uiHr from './ui/hr.vue';
import formButton from './ui/form/button.vue';
import formRadio from './ui/form/radio.vue';
@ -91,5 +93,7 @@ Vue.component('ui-switch', uiSwitch);
Vue.component('ui-radio', uiRadio);
Vue.component('ui-select', uiSelect);
Vue.component('ui-info', uiInfo);
Vue.component('ui-margin', uiMargin);
Vue.component('ui-hr', uiHr);
Vue.component('form-button', formButton);
Vue.component('form-radio', formRadio);

View File

@ -33,7 +33,16 @@ import * as autosize from 'autosize';
export default Vue.extend({
i18n: i18n('common/views/components/messaging-room.form.vue'),
props: ['user'],
props: {
user: {
type: Object,
requird: false,
},
group: {
type: Object,
requird: false,
},
},
data() {
return {
text: null,
@ -43,7 +52,7 @@ export default Vue.extend({
},
computed: {
draftId(): string {
return this.user.id;
return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
},
canSend(): boolean {
return (this.text != null && this.text != '') || this.file != null;
@ -159,7 +168,8 @@ export default Vue.extend({
send() {
this.sending = true;
this.$root.api('messaging/messages/create', {
userId: this.user.id,
userId: this.user ? this.user.id : undefined,
groupId: this.group ? this.group.id : undefined,
text: this.text ? this.text : undefined,
fileId: this.file ? this.file.id : undefined
}).then(message => {

View File

@ -23,7 +23,12 @@
<div></div>
<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
<footer>
<span class="read" v-if="isMe && message.isRead">{{ $t('is-read') }}</span>
<template v-if="isGroup">
<span class="read" v-if="message.reads.length > 0">{{ $t('is-read') }} {{ message.reads.length }}</span>
</template>
<template v-else>
<span class="read" v-if="isMe && message.isRead">{{ $t('is-read') }}</span>
</template>
<mk-time :time="message.createdAt"/>
<template v-if="message.is_edited"><fa icon="pencil-alt"/></template>
</footer>
@ -42,6 +47,9 @@ export default Vue.extend({
props: {
message: {
required: true
},
isGroup: {
required: false
}
},
computed: {

View File

@ -4,14 +4,14 @@
@drop.prevent.stop="onDrop"
>
<div class="body">
<p class="init" v-if="init"><fa icon="spinner .spin"/>{{ $t('@.loading') }}</p>
<p class="empty" v-if="!init && messages.length == 0"><fa icon="info-circle"/>{{ $t('empty') }}</p>
<p class="init" v-if="init"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}</p>
<p class="empty" v-if="!init && messages.length == 0"><fa icon="info-circle"/>{{ user ? $t('not-talked-user') : $t('not-talked-group') }}</p>
<p class="no-history" v-if="!init && messages.length > 0 && !existMoreMessages"><fa :icon="faFlag"/>{{ $t('no-history') }}</p>
<button class="more" :class="{ fetching: fetchingMoreMessages }" v-if="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages">
<template v-if="fetchingMoreMessages"><fa icon="spinner" pulse fixed-width/></template>{{ fetchingMoreMessages ? $t('@.loading') : $t('@.load-more') }}
</button>
<template v-for="(message, i) in _messages">
<x-message :message="message" :key="message.id"/>
<x-message :message="message" :key="message.id" :is-group="group != null"/>
<p class="date" v-if="i != messages.length - 1 && message._date != _messages[i + 1]._date">
<span>{{ _messages[i + 1]._datetext }}</span>
</p>
@ -23,7 +23,7 @@
<button @click="onIndicatorClick"><i><fa :icon="faArrowCircleDown"/></i>{{ $t('new-message') }}</button>
</div>
</transition>
<x-form :user="user" ref="form"/>
<x-form :user="user" :group="group" ref="form"/>
</footer>
</div>
</template>
@ -34,17 +34,30 @@ import i18n from '../../../i18n';
import XMessage from './messaging-room.message.vue';
import XForm from './messaging-room.form.vue';
import { url } from '../../../config';
import { faArrowCircleDown } from '@fortawesome/free-solid-svg-icons';
import { faFlag } from '@fortawesome/free-regular-svg-icons';
import { faArrowCircleDown, faFlag } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
i18n: i18n('common/views/components/messaging-room.vue'),
components: {
XMessage,
XForm
},
props: ['user', 'isNaked'],
props: {
user: {
type: Object,
requird: false,
},
group: {
type: Object,
requird: false,
},
isNaked: {
type: Boolean,
requird: false,
},
},
data() {
return {
@ -76,7 +89,10 @@ export default Vue.extend({
},
mounted() {
this.connection = this.$root.stream.connectToChannel('messaging', { otherparty: this.user.id });
this.connection = this.$root.stream.connectToChannel('messaging', {
otherparty: this.user ? this.user.id : undefined,
group: this.group ? this.group.id : undefined,
});
this.connection.on('message', this.onMessage);
this.connection.on('read', this.onRead);
@ -147,7 +163,8 @@ export default Vue.extend({
const max = this.existMoreMessages ? 20 : 10;
this.$root.api('messaging/messages', {
userId: this.user.id,
userId: this.user ? this.user.id : undefined,
groupId: this.group ? this.group.id : undefined,
limit: max + 1,
untilId: this.existMoreMessages ? this.messages[0].id : undefined
}).then(messages => {
@ -199,12 +216,21 @@ export default Vue.extend({
}
},
onRead(ids) {
if (!Array.isArray(ids)) ids = [ids];
for (const id of ids) {
if (this.messages.some(x => x.id == id)) {
const exist = this.messages.map(x => x.id).indexOf(id);
this.messages[exist].isRead = true;
onRead(x) {
if (this.user) {
if (!Array.isArray(x)) x = [x];
for (const id of x) {
if (this.messages.some(x => x.id == id)) {
const exist = this.messages.map(x => x.id).indexOf(id);
this.messages[exist].isRead = true;
}
}
} else if (this.group) {
for (const id of x.ids) {
if (this.messages.some(x => x.id == id)) {
const exist = this.messages.map(x => x.id).indexOf(id);
this.messages[exist].reads.push(x.userId);
}
}
}
},

View File

@ -21,36 +21,62 @@
</div>
</div>
<div class="history" v-if="messages.length > 0">
<template>
<a v-for="message in messages"
class="user"
:href="`/i/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
:data-is-me="isMe(message)"
:data-is-read="message.isRead"
@click.prevent="navigate(isMe(message) ? message.recipient : message.user)"
:key="message.id"
>
<div>
<mk-avatar class="avatar" :user="isMe(message) ? message.recipient : message.user"/>
<header>
<span class="name"><mk-user-name :user="isMe(message) ? message.recipient : message.user"/></span>
<span class="username">@{{ isMe(message) ? message.recipient : message.user | acct }}</span>
<mk-time :time="message.createdAt"/>
</header>
<div class="body">
<p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p>
</div>
<div class="title">{{ $t('user') }}</div>
<a v-for="message in messages"
class="user"
:href="`/i/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
:data-is-me="isMe(message)"
:data-is-read="message.isRead"
@click.prevent="navigate(isMe(message) ? message.recipient : message.user)"
:key="message.id"
>
<div>
<mk-avatar class="avatar" :user="isMe(message) ? message.recipient : message.user"/>
<header>
<span class="name"><mk-user-name :user="isMe(message) ? message.recipient : message.user"/></span>
<span class="username">@{{ isMe(message) ? message.recipient : message.user | acct }}</span>
<mk-time :time="message.createdAt"/>
</header>
<div class="body">
<p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p>
</div>
</a>
</template>
</div>
</a>
</div>
<p class="no-history" v-if="!fetching && messages.length == 0">{{ $t('no-history') }}</p>
<div class="history" v-if="groupMessages.length > 0">
<div class="title">{{ $t('group') }}</div>
<a v-for="message in groupMessages"
class="user"
:href="`/i/messaging/group/${message.groupId}`"
:data-is-me="isMe(message)"
:data-is-read="message.reads.includes($store.state.i.id)"
@click.prevent="navigateGroup(message.group)"
:key="message.id"
>
<div>
<mk-avatar class="avatar" :user="message.user"/>
<header>
<span class="name">{{ message.group.name }}</span>
<mk-time :time="message.createdAt"/>
</header>
<div class="body">
<p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p>
</div>
</div>
</a>
</div>
<p class="no-history" v-if="!fetching && (messages.length == 0 && groupMessages.length == 0)">{{ $t('no-history') }}</p>
<p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p>
<ui-margin>
<ui-button @click="startUser()"><fa :icon="faUser"/> {{ $t('start-with-user') }}</ui-button>
<ui-button @click="startGroup()"><fa :icon="faUsers"/> {{ $t('start-with-group') }}</ui-button>
</ui-margin>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faUser, faUsers } from '@fortawesome/free-solid-svg-icons';
import i18n from '../../../i18n';
import getAcct from '../../../../../misc/acct/render';
@ -71,9 +97,11 @@ export default Vue.extend({
fetching: true,
moreFetching: false,
messages: [],
groupMessages: [],
q: null,
result: [],
connection: null
connection: null,
faUser, faUsers
};
},
mounted() {
@ -82,9 +110,12 @@ export default Vue.extend({
this.connection.on('message', this.onMessage);
this.connection.on('read', this.onRead);
this.$root.api('messaging/history').then(messages => {
this.messages = messages;
this.fetching = false;
this.$root.api('messaging/history', { group: false }).then(messages => {
this.$root.api('messaging/history', { group: true }).then(groupMessages => {
this.messages = messages;
this.groupMessages = groupMessages;
this.fetching = false;
});
});
},
beforeDestroy() {
@ -96,16 +127,27 @@ export default Vue.extend({
return message.userId == this.$store.state.i.id;
},
onMessage(message) {
this.messages = this.messages.filter(m => !(
(m.recipientId == message.recipientId && m.userId == message.userId) ||
(m.recipientId == message.userId && m.userId == message.recipientId)));
if (message.recipientId) {
this.messages = this.messages.filter(m => !(
(m.recipientId == message.recipientId && m.userId == message.userId) ||
(m.recipientId == message.userId && m.userId == message.recipientId)));
this.messages.unshift(message);
this.messages.unshift(message);
} else if (message.groupId) {
this.groupMessages = this.groupMessages.filter(m => m.groupId !== message.groupId);
this.groupMessages.unshift(message);
}
},
onRead(ids) {
for (const id of ids) {
const found = this.messages.find(m => m.id == id);
if (found) found.isRead = true;
if (found) {
if (found.recipientId) {
found.isRead = true;
} else if (found.groupId) {
found.reads.push(this.$store.state.i.id);
}
}
}
},
search() {
@ -125,6 +167,9 @@ export default Vue.extend({
navigate(user) {
this.$emit('navigate', user);
},
navigateGroup(group) {
this.$emit('navigateGroup', group);
},
onSearchKeydown(e) {
switch (e.which) {
case 9: // [TAB]
@ -161,6 +206,30 @@ export default Vue.extend({
(list.childNodes[i].nextElementSibling || list.childNodes[0]).focus();
break;
}
},
async startUser() {
const { result: user } = await this.$root.dialog({
user: {
local: true
}
});
if (user == null) return;
this.navigate(user);
},
async startGroup() {
const groups = await this.$root.api('users/groups/joined');
const { canceled, result: group } = await this.$root.dialog({
type: null,
title: this.$t('select-group'),
select: {
items: groups.map(group => ({
value: group, text: group.name
}))
},
showCancelButton: true
});
if (canceled) return;
this.navigateGroup(group);
}
}
});
@ -173,6 +242,9 @@ export default Vue.extend({
font-size 0.8em
> .history
> .title
padding 8px
> a
&:last-child
border-bottom none
@ -311,6 +383,13 @@ export default Vue.extend({
color rgba(#000, 0.3)
> .history
> .title
padding 6px 16px
margin 0 auto
max-width 500px
background rgba(0, 0, 0, 0.05)
color var(--text)
font-size 85%
> a
display block

View File

@ -0,0 +1,15 @@
<template>
<div class="evrzpitu"></div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({});
</script>
<style lang="stylus" scoped>
.evrzpitu
margin 16px 0
border-bottom solid var(--lineWidth) var(--faceDivider)
</style>

View File

@ -0,0 +1,16 @@
<template>
<div class="zdcrxcne">
<slot></slot>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({});
</script>
<style lang="stylus" scoped>
.zdcrxcne
margin 16px
</style>

View File

@ -1,150 +0,0 @@
<template>
<div class="cudqjmnl">
<ui-card>
<template #title><fa :icon="faList"/> {{ 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>
</ui-card>
<ui-card>
<template #title><fa :icon="faUsers"/> {{ $t('users') }}</template>
<section>
<sequential-entrance animation="entranceFromTop" delay="25">
<div class="phcqulfl" 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-card>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import { faList, faICursor, faUsers } 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: {
required: true
}
},
data() {
return {
users: [],
faList, faICursor, faTrashAlt, faUsers
};
},
mounted() {
this.fetchUsers();
},
methods: {
fetchUsers() {
this.$root.api('users/show', {
userIds: this.list.userIds
}).then(users => {
this.users = users;
});
},
rename() {
this.$root.dialog({
title: this.$t('rename'),
input: {
default: this.list.name
}
}).then(({ canceled, result: name }) => {
if (canceled) return;
this.$root.api('users/lists/update', {
listId: this.list.id,
name: name
});
});
},
del() {
this.$root.dialog({
type: 'warning',
text: this.$t('delete-are-you-sure').replace('$1', this.list.name),
showCancelButton: true
}).then(({ canceled }) => {
if (canceled) return;
this.$root.api('users/lists/delete', {
listId: this.list.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/lists/pull', {
listId: this.list.id,
userId: user.id
}).then(() => {
this.fetchUsers();
});
}
}
});
</script>
<style lang="stylus" scoped>
.cudqjmnl
.phcqulfl
display flex
padding 16px 0
border-top solid 1px var(--faceDivider)
> div:first-child
> a
> .avatar
width 64px
height 64px
> div:last-child
flex 1
padding-left 16px
@media (max-width 500px)
font-size 14px
> header
> .username
margin-left 8px
opacity 0.7
</style>

View File

@ -1,95 +0,0 @@
<template>
<div class="xkxvokkjlptzyewouewmceqcxhpgzprp">
<button class="ui" @click="add">{{ $t('create-list') }}</button>
<a v-for="list in lists" :key="list.id" @click="choice(list)">{{ list.name }}</a>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
export default Vue.extend({
i18n: i18n('common/views/components/user-lists.vue'),
data() {
return {
fetching: true,
lists: []
};
},
mounted() {
this.$root.api('users/lists/list').then(lists => {
this.fetching = false;
this.lists = lists;
});
},
methods: {
add() {
this.$root.dialog({
title: this.$t('list-name'),
input: true
}).then(async ({ canceled, result: name }) => {
if (canceled) return;
const list = await this.$root.api('users/lists/create', {
name
});
this.lists.push(list)
this.$emit('choosen', list);
});
},
choice(list) {
this.$emit('choosen', list);
}
}
});
</script>
<style lang="stylus" scoped>
.xkxvokkjlptzyewouewmceqcxhpgzprp
padding 16px
background: var(--bg)
> button
display block
margin-bottom 16px
color var(--primaryForeground)
background var(--primary)
width 100%
border-radius 38px
user-select none
cursor pointer
padding 0 16px
min-width 100px
line-height 38px
font-size 14px
font-weight 700
&:hover
background var(--primaryLighten10)
&:active
background var(--primaryDarken10)
a
display block
margin 8px 0
padding 8px
color var(--text)
background var(--face)
box-shadow 0 2px 16px var(--reversiListItemShadow)
border-radius 6px
cursor pointer
line-height 32px
*
pointer-events none
user-select none
&:hover
box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05)
&:active
box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1)
</style>

View File

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