Merge branch 'develop' into vue3
This commit is contained in:
BIN
src/client/assets/sounds/noizenecio/kick_gaba2.mp3
Normal file
BIN
src/client/assets/sounds/noizenecio/kick_gaba2.mp3
Normal file
Binary file not shown.
BIN
src/client/assets/sounds/syuilo/reverved.mp3
Normal file
BIN
src/client/assets/sounds/syuilo/reverved.mp3
Normal file
Binary file not shown.
BIN
src/client/assets/sounds/syuilo/ryukyu.mp3
Normal file
BIN
src/client/assets/sounds/syuilo/ryukyu.mp3
Normal file
Binary file not shown.
BIN
src/client/assets/sounds/syuilo/square-pico.mp3
Normal file
BIN
src/client/assets/sounds/syuilo/square-pico.mp3
Normal file
Binary file not shown.
141
src/client/components/channel-follow-button.vue
Normal file
141
src/client/components/channel-follow-button.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<button class="hdcaacmi _button"
|
||||
:class="{ wait, active: isFollowing, full }"
|
||||
@click="onClick"
|
||||
:disabled="wait"
|
||||
>
|
||||
<template v-if="!wait">
|
||||
<template v-if="isFollowing">
|
||||
<span v-if="full">{{ $t('unfollow') }}</span><fa :icon="faMinus"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="full">{{ $t('follow') }}</span><fa :icon="faPlus"/>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="full">{{ $t('processing') }}</span><fa :icon="faSpinner" pulse fixed-width/>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faSpinner, faPlus, faMinus, } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
channel: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
full: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isFollowing: this.channel.isFollowing,
|
||||
wait: false,
|
||||
faSpinner, faPlus, faMinus,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
async onClick() {
|
||||
this.wait = true;
|
||||
|
||||
try {
|
||||
if (this.isFollowing) {
|
||||
await this.$root.api('channels/unfollow', {
|
||||
channelId: this.channel.id
|
||||
});
|
||||
this.isFollowing = false;
|
||||
} else {
|
||||
await this.$root.api('channels/follow', {
|
||||
channelId: this.channel.id
|
||||
});
|
||||
this.isFollowing = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.wait = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hdcaacmi {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
border: solid 1px var(--accent);
|
||||
padding: 0;
|
||||
height: 31px;
|
||||
font-size: 16px;
|
||||
border-radius: 32px;
|
||||
background: #fff;
|
||||
|
||||
&.full {
|
||||
padding: 0 8px 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&:not(.full) {
|
||||
width: 31px;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
border: 2px solid var(--focus);
|
||||
border-radius: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
//background: mix($primary, #fff, 20);
|
||||
}
|
||||
|
||||
&:active {
|
||||
//background: mix($primary, #fff, 40);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
|
||||
&:hover {
|
||||
background: var(--accentLighten);
|
||||
border-color: var(--accentLighten);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accentDarken);
|
||||
border-color: var(--accentDarken);
|
||||
}
|
||||
}
|
||||
|
||||
&.wait {
|
||||
cursor: wait !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> span {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
144
src/client/components/channel-preview.vue
Normal file
144
src/client/components/channel-preview.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<router-link :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1">
|
||||
<div class="banner" v-if="channel.bannerUrl" :style="`background-image: url('${channel.bannerUrl}')`">
|
||||
<div class="fade"></div>
|
||||
<div class="name"><fa :icon="faSatelliteDish"/> {{ channel.name }}</div>
|
||||
<div class="status">
|
||||
<div><fa :icon="faUsers" fixed-width/><i18n path="_channel.usersCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.usersCount }}</b></i18n></div>
|
||||
<div><fa :icon="faPencilAlt" fixed-width/><i18n path="_channel.notesCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.notesCount }}</b></i18n></div>
|
||||
</div>
|
||||
</div>
|
||||
<article v-if="channel.description">
|
||||
<p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p>
|
||||
</article>
|
||||
<footer>
|
||||
<span>
|
||||
{{ $t('updatedAt') }}: <mk-time :time="channel.lastNotedAt"/>
|
||||
</span>
|
||||
</footer>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faSatelliteDish, faUsers, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
channel: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faSatelliteDish, faUsers, faPencilAlt,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.eftoefju {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border: 1px solid var(--divider);
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
> .banner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
|
||||
> .fade {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background: linear-gradient(0deg, var(--panel), var(--X15));
|
||||
}
|
||||
|
||||
> .name {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
> .status {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
padding: 8px 12px;
|
||||
font-size: 80%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 16px;
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
padding: 12px 16px;
|
||||
border-top: solid 1px var(--divider);
|
||||
|
||||
> span {
|
||||
opacity: 0.7;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 550px) {
|
||||
font-size: 0.9em;
|
||||
|
||||
> .banner {
|
||||
height: 80px;
|
||||
|
||||
> .status {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
> footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
font-size: 0.8em;
|
||||
|
||||
> .banner {
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="zbcjwnqg">
|
||||
<div class="zbcjwnqg" v-size="{ max: [550, 1200] }">
|
||||
<div class="stats" v-if="info">
|
||||
<div class="_panel">
|
||||
<div>
|
||||
@@ -84,7 +84,7 @@
|
||||
</div>
|
||||
|
||||
<section class="_card">
|
||||
<div class="_title"><fa :icon="faChartBar"/> {{ $t('statistics') }}</div>
|
||||
<div class="_title" style="position: relative;"><fa :icon="faChartBar"/> {{ $t('statistics') }}<button @click="fetchChart" class="_button" style="position: absolute; right: 0; bottom: 0; top: 0; padding: inherit;"><fa :icon="faSync"/></button></div>
|
||||
<div class="_content" style="margin-top: -8px;">
|
||||
<div class="selects" style="display: flex;">
|
||||
<mk-select v-model="chartSrc" style="margin: 0; flex: 1;">
|
||||
@@ -123,12 +123,11 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faChartBar, faUser, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faChartBar, faUser, faPencilAlt, faSync } from '@fortawesome/free-solid-svg-icons';
|
||||
import Chart from 'chart.js';
|
||||
import MkSelect from './ui/select.vue';
|
||||
import number from '../filters/number';
|
||||
|
||||
const chartLimit = 90;
|
||||
const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
|
||||
const negate = arr => arr.map(x => -x);
|
||||
const alpha = (hex, a) => {
|
||||
@@ -144,6 +143,19 @@ export default defineComponent({
|
||||
MkSelect
|
||||
},
|
||||
|
||||
props: {
|
||||
chartLimit: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 90
|
||||
},
|
||||
detailed: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
info: null,
|
||||
@@ -160,7 +172,7 @@ export default defineComponent({
|
||||
chartInstance: null,
|
||||
chartSrc: 'notes',
|
||||
chartSpan: 'hour',
|
||||
faChartBar, faUser, faPencilAlt
|
||||
faChartBar, faUser, faPencilAlt, faSync
|
||||
}
|
||||
},
|
||||
|
||||
@@ -209,62 +221,69 @@ export default defineComponent({
|
||||
|
||||
this.now = new Date();
|
||||
|
||||
const [perHour, perDay] = await Promise.all([Promise.all([
|
||||
this.$root.api('charts/federation', { limit: chartLimit, span: 'hour' }),
|
||||
this.$root.api('charts/users', { limit: chartLimit, span: 'hour' }),
|
||||
this.$root.api('charts/active-users', { limit: chartLimit, span: 'hour' }),
|
||||
this.$root.api('charts/notes', { limit: chartLimit, span: 'hour' }),
|
||||
this.$root.api('charts/drive', { limit: chartLimit, span: 'hour' }),
|
||||
]), Promise.all([
|
||||
this.$root.api('charts/federation', { limit: chartLimit, span: 'day' }),
|
||||
this.$root.api('charts/users', { limit: chartLimit, span: 'day' }),
|
||||
this.$root.api('charts/active-users', { limit: chartLimit, span: 'day' }),
|
||||
this.$root.api('charts/notes', { limit: chartLimit, span: 'day' }),
|
||||
this.$root.api('charts/drive', { limit: chartLimit, span: 'day' }),
|
||||
])]);
|
||||
|
||||
const chart = {
|
||||
perHour: {
|
||||
federation: perHour[0],
|
||||
users: perHour[1],
|
||||
activeUsers: perHour[2],
|
||||
notes: perHour[3],
|
||||
drive: perHour[4],
|
||||
},
|
||||
perDay: {
|
||||
federation: perDay[0],
|
||||
users: perDay[1],
|
||||
activeUsers: perDay[2],
|
||||
notes: perDay[3],
|
||||
drive: perDay[4],
|
||||
}
|
||||
};
|
||||
|
||||
this.notesLocalWoW = this.info.originalNotesCount - chart.perDay.notes.local.total[7];
|
||||
this.notesLocalDoD = this.info.originalNotesCount - chart.perDay.notes.local.total[1];
|
||||
this.notesRemoteWoW = (this.info.notesCount - this.info.originalNotesCount) - chart.perDay.notes.remote.total[7];
|
||||
this.notesRemoteDoD = (this.info.notesCount - this.info.originalNotesCount) - chart.perDay.notes.remote.total[1];
|
||||
this.usersLocalWoW = this.info.originalUsersCount - chart.perDay.users.local.total[7];
|
||||
this.usersLocalDoD = this.info.originalUsersCount - chart.perDay.users.local.total[1];
|
||||
this.usersRemoteWoW = (this.info.usersCount - this.info.originalUsersCount) - chart.perDay.users.remote.total[7];
|
||||
this.usersRemoteDoD = (this.info.usersCount - this.info.originalUsersCount) - chart.perDay.users.remote.total[1];
|
||||
|
||||
this.chart = chart;
|
||||
|
||||
this.renderChart();
|
||||
this.fetchChart();
|
||||
},
|
||||
|
||||
methods: {
|
||||
async fetchChart() {
|
||||
const [perHour, perDay] = await Promise.all([Promise.all([
|
||||
this.$root.api('charts/federation', { limit: this.chartLimit, span: 'hour' }),
|
||||
this.$root.api('charts/users', { limit: this.chartLimit, span: 'hour' }),
|
||||
this.$root.api('charts/active-users', { limit: this.chartLimit, span: 'hour' }),
|
||||
this.$root.api('charts/notes', { limit: this.chartLimit, span: 'hour' }),
|
||||
this.$root.api('charts/drive', { limit: this.chartLimit, span: 'hour' }),
|
||||
]), Promise.all([
|
||||
this.$root.api('charts/federation', { limit: this.chartLimit, span: 'day' }),
|
||||
this.$root.api('charts/users', { limit: this.chartLimit, span: 'day' }),
|
||||
this.$root.api('charts/active-users', { limit: this.chartLimit, span: 'day' }),
|
||||
this.$root.api('charts/notes', { limit: this.chartLimit, span: 'day' }),
|
||||
this.$root.api('charts/drive', { limit: this.chartLimit, span: 'day' }),
|
||||
])]);
|
||||
|
||||
const chart = {
|
||||
perHour: {
|
||||
federation: perHour[0],
|
||||
users: perHour[1],
|
||||
activeUsers: perHour[2],
|
||||
notes: perHour[3],
|
||||
drive: perHour[4],
|
||||
},
|
||||
perDay: {
|
||||
federation: perDay[0],
|
||||
users: perDay[1],
|
||||
activeUsers: perDay[2],
|
||||
notes: perDay[3],
|
||||
drive: perDay[4],
|
||||
}
|
||||
};
|
||||
|
||||
this.notesLocalWoW = this.info.originalNotesCount - chart.perDay.notes.local.total[7];
|
||||
this.notesLocalDoD = this.info.originalNotesCount - chart.perDay.notes.local.total[1];
|
||||
this.notesRemoteWoW = (this.info.notesCount - this.info.originalNotesCount) - chart.perDay.notes.remote.total[7];
|
||||
this.notesRemoteDoD = (this.info.notesCount - this.info.originalNotesCount) - chart.perDay.notes.remote.total[1];
|
||||
this.usersLocalWoW = this.info.originalUsersCount - chart.perDay.users.local.total[7];
|
||||
this.usersLocalDoD = this.info.originalUsersCount - chart.perDay.users.local.total[1];
|
||||
this.usersRemoteWoW = (this.info.usersCount - this.info.originalUsersCount) - chart.perDay.users.remote.total[7];
|
||||
this.usersRemoteDoD = (this.info.usersCount - this.info.originalUsersCount) - chart.perDay.users.remote.total[1];
|
||||
|
||||
this.chart = chart;
|
||||
|
||||
this.renderChart();
|
||||
},
|
||||
|
||||
renderChart() {
|
||||
if (this.chartInstance) {
|
||||
this.chartInstance.destroy();
|
||||
}
|
||||
|
||||
// TODO: var(--panel)の色が暗いか明るいかで判定する
|
||||
const gridColor = this.$store.state.device.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
|
||||
Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
|
||||
this.chartInstance = new Chart(this.$refs.chart, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(),
|
||||
labels: new Array(this.chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(),
|
||||
datasets: this.data.series.map(x => ({
|
||||
label: x.name,
|
||||
data: x.data.slice().reverse(),
|
||||
@@ -272,7 +291,9 @@ export default defineComponent({
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: x.color,
|
||||
borderDash: x.borderDash || [],
|
||||
backgroundColor: alpha(x.color, 0.1),
|
||||
fill: x.fill == null ? true : x.fill,
|
||||
hidden: !!x.hidden
|
||||
}))
|
||||
},
|
||||
@@ -294,17 +315,28 @@ export default defineComponent({
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
type: 'time',
|
||||
time: {
|
||||
stepSize: 1,
|
||||
unit: this.chartSpan == 'day' ? 'month' : 'day',
|
||||
},
|
||||
gridLines: {
|
||||
display: false
|
||||
display: this.detailed,
|
||||
color: gridColor,
|
||||
zeroLineColor: gridColor,
|
||||
},
|
||||
ticks: {
|
||||
display: false
|
||||
display: this.detailed
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
position: 'right',
|
||||
position: 'left',
|
||||
gridLines: {
|
||||
color: gridColor,
|
||||
zeroLineColor: gridColor,
|
||||
},
|
||||
ticks: {
|
||||
display: false
|
||||
display: this.detailed
|
||||
}
|
||||
}]
|
||||
},
|
||||
@@ -326,7 +358,11 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
format(arr) {
|
||||
return arr;
|
||||
const now = Date.now();
|
||||
return arr.map((v, i) => ({
|
||||
x: new Date(now - ((this.chartSpan == 'day' ? 86400000 :3600000 ) * i)),
|
||||
y: v
|
||||
}));
|
||||
},
|
||||
|
||||
federationInstancesChart(total: boolean): any {
|
||||
@@ -348,6 +384,8 @@ export default defineComponent({
|
||||
name: 'All',
|
||||
type: 'line',
|
||||
color: '#008FFB',
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
data: this.format(type == 'combined'
|
||||
? sum(this.stats.notes.local.inc, negate(this.stats.notes.local.dec), this.stats.notes.remote.inc, negate(this.stats.notes.remote.dec))
|
||||
: sum(this.stats.notes[type].inc, negate(this.stats.notes[type].dec))
|
||||
@@ -464,7 +502,9 @@ export default defineComponent({
|
||||
series: [{
|
||||
name: 'All',
|
||||
type: 'line',
|
||||
color: '#008FFB',
|
||||
color: '#09d8e2',
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
data: this.format(
|
||||
sum(
|
||||
this.stats.drive.local.incSize,
|
||||
@@ -481,17 +521,17 @@ export default defineComponent({
|
||||
}, {
|
||||
name: 'Local -',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
color: '#FF4560',
|
||||
data: this.format(negate(this.stats.drive.local.decSize))
|
||||
}, {
|
||||
name: 'Remote +',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
color: '#00E396',
|
||||
data: this.format(this.stats.drive.remote.incSize)
|
||||
}, {
|
||||
name: 'Remote -',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
color: '#FEB019',
|
||||
data: this.format(negate(this.stats.drive.remote.decSize))
|
||||
}]
|
||||
};
|
||||
@@ -526,7 +566,9 @@ export default defineComponent({
|
||||
series: [{
|
||||
name: 'All',
|
||||
type: 'line',
|
||||
color: '#008FFB',
|
||||
color: '#09d8e2',
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
data: this.format(
|
||||
sum(
|
||||
this.stats.drive.local.incCount,
|
||||
@@ -543,17 +585,17 @@ export default defineComponent({
|
||||
}, {
|
||||
name: 'Local -',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
color: '#FF4560',
|
||||
data: this.format(negate(this.stats.drive.local.decCount))
|
||||
}, {
|
||||
name: 'Remote +',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
color: '#00E396',
|
||||
data: this.format(this.stats.drive.remote.incCount)
|
||||
}, {
|
||||
name: 'Remote -',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
color: '#FEB019',
|
||||
data: this.format(negate(this.stats.drive.remote.decCount))
|
||||
}]
|
||||
};
|
||||
@@ -589,17 +631,30 @@ export default defineComponent({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.zbcjwnqg {
|
||||
&.max-width_1200px {
|
||||
> .stats {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_550px {
|
||||
> .stats {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
> .stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
margin: calc(0px - var(--margin) / 2);
|
||||
margin-bottom: calc(var(--margin) / 2);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: var(--margin);
|
||||
margin-bottom: var(--margin);
|
||||
font-size: 90%;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex: 1 0 213px;
|
||||
margin: calc(var(--margin) / 2);
|
||||
box-sizing: border-box;
|
||||
padding: 16px 20px;
|
||||
|
||||
@@ -634,7 +689,7 @@ export default defineComponent({
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> dt {
|
||||
> dd {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="wrpstxzv" :class="{ children }" v-size="[{ max: 450 }]">
|
||||
<div class="wrpstxzv" :class="{ children }" v-size="{ max: [450] }">
|
||||
<div class="main">
|
||||
<mk-avatar class="avatar" :user="note.user"/>
|
||||
<div class="body">
|
||||
|
@@ -6,7 +6,7 @@
|
||||
:tabindex="!isDeleted ? '-1' : null"
|
||||
:class="{ renote: isRenote }"
|
||||
v-hotkey="keymap"
|
||||
v-size="[{ max: 500 }, { max: 450 }, { max: 350 }, { max: 300 }]"
|
||||
v-size="{ max: [500, 450, 350, 300] }"
|
||||
>
|
||||
<x-sub v-for="note in conversation" class="reply-to-more" :key="note.id" :note="note"/>
|
||||
<x-sub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/>
|
||||
@@ -57,6 +57,7 @@
|
||||
<mk-url-preview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="detail" class="url-preview"/>
|
||||
<div class="renote" v-if="appearNote.renote"><x-note-preview :note="appearNote.renote"/></div>
|
||||
</div>
|
||||
<router-link v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><fa :icon="faSatelliteDish"/> {{ appearNote.channel.name }}</router-link>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<x-reactions-viewer :note="appearNote" ref="reactionsViewer"/>
|
||||
@@ -96,7 +97,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
|
||||
import { parse } from '../../mfm/parse';
|
||||
import { sum, unique } from '../../prelude/array';
|
||||
@@ -134,6 +135,12 @@ export default defineComponent({
|
||||
MkUrlPreview,
|
||||
},
|
||||
|
||||
inject: {
|
||||
inChannel: {
|
||||
default: null
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
@@ -160,7 +167,7 @@ export default defineComponent({
|
||||
isDeleted: false,
|
||||
muted: false,
|
||||
noteBody: this.$refs.noteBody,
|
||||
faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug
|
||||
faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug, faSatelliteDish
|
||||
};
|
||||
},
|
||||
|
||||
@@ -957,6 +964,11 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .channel {
|
||||
opacity: 0.7;
|
||||
font-size: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
> .footer {
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="qglefbjs" :class="notification.type" v-size="[{ max: 500 }, { max: 600 }]">
|
||||
<div class="qglefbjs" :class="notification.type" v-size="{ max: [500, 600] }">
|
||||
<div class="head">
|
||||
<mk-avatar v-if="notification.user" class="icon" :user="notification.user"/>
|
||||
<img v-else class="icon" :src="notification.icon" alt=""/>
|
||||
|
@@ -10,7 +10,7 @@
|
||||
<div>
|
||||
<span class="local-only" v-if="localOnly" v-text="$t('_visibility.localOnly')" />
|
||||
<span class="text-count" :class="{ over: trimmedLength(text) > max }">{{ max - trimmedLength(text) }}</span>
|
||||
<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$t('visibility')">
|
||||
<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$t('visibility')" :disabled="channel != null">
|
||||
<span v-if="visibility === 'public'"><fa :icon="faGlobe"/></span>
|
||||
<span v-if="visibility === 'home'"><fa :icon="faHome"/></span>
|
||||
<span v-if="visibility === 'followers'"><fa :icon="faUnlock"/></span>
|
||||
@@ -88,6 +88,10 @@ export default defineComponent({
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
channel: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
mention: {
|
||||
type: Object,
|
||||
required: false
|
||||
@@ -140,30 +144,38 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
computed: {
|
||||
draftId(): string {
|
||||
return this.renote
|
||||
? `renote:${this.renote.id}`
|
||||
: this.reply
|
||||
? `reply:${this.reply.id}`
|
||||
: 'note';
|
||||
draftKey(): string {
|
||||
let key = this.channel ? `channel:${this.channel.id}` : '';
|
||||
|
||||
if (this.renote) {
|
||||
key += `renote:${this.renote.id}`;
|
||||
} else if (this.reply) {
|
||||
key += `reply:${this.reply.id}`;
|
||||
} else {
|
||||
key += 'note';
|
||||
}
|
||||
|
||||
return key;
|
||||
},
|
||||
|
||||
placeholder(): string {
|
||||
const xs = [
|
||||
this.$t('_postForm._placeholders.a'),
|
||||
this.$t('_postForm._placeholders.b'),
|
||||
this.$t('_postForm._placeholders.c'),
|
||||
this.$t('_postForm._placeholders.d'),
|
||||
this.$t('_postForm._placeholders.e'),
|
||||
this.$t('_postForm._placeholders.f')
|
||||
];
|
||||
const x = xs[Math.floor(Math.random() * xs.length)];
|
||||
|
||||
return this.renote
|
||||
? this.$t('_postForm.quotePlaceholder')
|
||||
: this.reply
|
||||
? this.$t('_postForm.replyPlaceholder')
|
||||
: x;
|
||||
if (this.renote) {
|
||||
return this.$t('_postForm.quotePlaceholder');
|
||||
} else if (this.reply) {
|
||||
return this.$t('_postForm.replyPlaceholder');
|
||||
} else if (this.channel) {
|
||||
return this.$t('_postForm.channelPlaceholder');
|
||||
} else {
|
||||
const xs = [
|
||||
this.$t('_postForm._placeholders.a'),
|
||||
this.$t('_postForm._placeholders.b'),
|
||||
this.$t('_postForm._placeholders.c'),
|
||||
this.$t('_postForm._placeholders.d'),
|
||||
this.$t('_postForm._placeholders.e'),
|
||||
this.$t('_postForm._placeholders.f')
|
||||
];
|
||||
return xs[Math.floor(Math.random() * xs.length)];
|
||||
}
|
||||
},
|
||||
|
||||
submitText(): string {
|
||||
@@ -224,9 +236,11 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
// デフォルト公開範囲
|
||||
this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.visibility : this.$store.state.settings.defaultNoteVisibility);
|
||||
if (this.channel == null) {
|
||||
this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.visibility : this.$store.state.settings.defaultNoteVisibility);
|
||||
|
||||
this.localOnly = this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.localOnly : this.$store.state.settings.defaultNoteLocalOnly;
|
||||
this.localOnly = this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.localOnly : this.$store.state.settings.defaultNoteLocalOnly;
|
||||
}
|
||||
|
||||
// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
|
||||
if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
|
||||
@@ -266,7 +280,7 @@ export default defineComponent({
|
||||
this.$nextTick(() => {
|
||||
// 書きかけの投稿を復元
|
||||
if (!this.instant && !this.mention) {
|
||||
const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId];
|
||||
const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey];
|
||||
if (draft) {
|
||||
this.text = draft.data.text;
|
||||
this.useCw = draft.data.useCw;
|
||||
@@ -398,6 +412,10 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
setVisibility() {
|
||||
if (this.channel) {
|
||||
// TODO: information dialog
|
||||
return;
|
||||
}
|
||||
const w = this.$root.new(MkVisibilityChooser, {
|
||||
source: this.$refs.visibilityButton,
|
||||
currentVisibility: this.visibility,
|
||||
@@ -510,7 +528,7 @@ export default defineComponent({
|
||||
|
||||
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
|
||||
|
||||
data[this.draftId] = {
|
||||
data[this.draftKey] = {
|
||||
updatedAt: new Date(),
|
||||
data: {
|
||||
text: this.text,
|
||||
@@ -529,7 +547,7 @@ export default defineComponent({
|
||||
deleteDraft() {
|
||||
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
|
||||
|
||||
delete data[this.draftId];
|
||||
delete data[this.draftKey];
|
||||
|
||||
localStorage.setItem('drafts', JSON.stringify(data));
|
||||
},
|
||||
@@ -540,6 +558,7 @@ export default defineComponent({
|
||||
fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
|
||||
replyId: this.reply ? this.reply.id : undefined,
|
||||
renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined,
|
||||
channelId: this.channel ? this.channel.id : undefined,
|
||||
poll: this.poll ? (this.$refs.poll as any).get() : undefined,
|
||||
cw: this.useCw ? this.cw || '' : undefined,
|
||||
localOnly: this.localOnly,
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="nsbbhtug" v-if="hasDisconnected" @click="resetDisconnected">
|
||||
<div class="nsbbhtug" v-if="hasDisconnected && $store.state.device.serverDisconnectedBehavior === 'quiet'" @click="resetDisconnected">
|
||||
<div>{{ $t('disconnectedFromServer') }}</div>
|
||||
<div class="command">
|
||||
<button class="_textButton" @click="reload">{{ $t('reload') }}</button>
|
||||
@@ -23,21 +23,12 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.$root.stream.on('_connected_', this.onConnected);
|
||||
this.$root.stream.on('_disconnected_', this.onDisconnected);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.stream.off('_connected_', this.onConnected);
|
||||
this.$root.stream.off('_disconnected_', this.onDisconnected);
|
||||
},
|
||||
methods: {
|
||||
onConnected() {
|
||||
if (this.hasDisconnected) {
|
||||
if (this.$store.state.device.autoReload) {
|
||||
this.reload();
|
||||
}
|
||||
}
|
||||
},
|
||||
onDisconnected() {
|
||||
this.hasDisconnected = true;
|
||||
},
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="pxhvhrfw" v-size="[{ max: 500 }]">
|
||||
<div class="pxhvhrfw" v-size="{ max: [500] }">
|
||||
<button v-for="item in items" class="_button" @click="$emit('input', item.value)" :class="{ active: value === item.value }" :key="item.value"><fa v-if="item.icon" :icon="item.icon" class="icon"/>{{ item.label }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
@@ -24,6 +24,10 @@ export default defineComponent({
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
channel: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
sound: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -31,6 +35,12 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
|
||||
provide() {
|
||||
return {
|
||||
inChannel: this.src === 'channel'
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
connection: null,
|
||||
@@ -112,6 +122,15 @@ export default defineComponent({
|
||||
this.connection.on('note', prepend);
|
||||
this.connection.on('userAdded', onUserAdded);
|
||||
this.connection.on('userRemoved', onUserRemoved);
|
||||
} else if (this.src == 'channel') {
|
||||
endpoint = 'channels/timeline';
|
||||
this.query = {
|
||||
channelId: this.channel
|
||||
};
|
||||
this.connection = this.$root.stream.connectToChannel('channel', {
|
||||
channelId: this.channel
|
||||
});
|
||||
this.connection.on('note', prepend);
|
||||
}
|
||||
|
||||
this.pagination = {
|
||||
|
@@ -1,12 +1,14 @@
|
||||
<template>
|
||||
<div class="ukygtjoj _panel" :class="{ naked, hideHeader: !showHeader, scrollable }" v-size="[{ max: 500 }]">
|
||||
<header v-if="showHeader">
|
||||
<div class="ukygtjoj _panel" :class="{ naked, hideHeader: !showHeader, scrollable, closed: !showBody }" v-size="{ max: [380], el: resizeBaseEl }">
|
||||
<header v-if="showHeader" ref="header">
|
||||
<div class="title"><slot name="header"></slot></div>
|
||||
<slot name="func"></slot>
|
||||
<button class="_button" v-if="bodyTogglable" @click="() => showBody = !showBody">
|
||||
<template v-if="showBody"><fa :icon="faAngleUp"/></template>
|
||||
<template v-else><fa :icon="faAngleDown"/></template>
|
||||
</button>
|
||||
<div class="sub">
|
||||
<slot name="func"></slot>
|
||||
<button class="_button" v-if="bodyTogglable" @click="() => showBody = !showBody">
|
||||
<template v-if="showBody"><fa :icon="faAngleUp"/></template>
|
||||
<template v-else><fa :icon="faAngleDown"/></template>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<transition name="container-toggle"
|
||||
@enter="enter"
|
||||
@@ -52,6 +54,9 @@ export default defineComponent({
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
resizeBaseEl: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -59,6 +64,18 @@ export default defineComponent({
|
||||
faAngleUp, faAngleDown
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$watch('showBody', showBody => {
|
||||
this.$el.style.minHeight = `${this.$refs.header.offsetHeight}px`;
|
||||
if (showBody) {
|
||||
this.$el.style.flexBasis = `auto`;
|
||||
} else {
|
||||
this.$el.style.flexBasis = `${this.$refs.header.offsetHeight}px`;
|
||||
}
|
||||
}, {
|
||||
immediate: true
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
toggleContent(show: boolean) {
|
||||
if (!this.bodyTogglable) return;
|
||||
@@ -103,10 +120,6 @@ export default defineComponent({
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
& + .ukygtjoj {
|
||||
margin-top: var(--margin);
|
||||
}
|
||||
|
||||
&.naked {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
@@ -127,6 +140,7 @@ export default defineComponent({
|
||||
z-index: 2;
|
||||
background: var(--panelHeaderBg);
|
||||
color: var(--panelHeaderFg);
|
||||
line-height: 1.4em;
|
||||
|
||||
> .title {
|
||||
margin: 0;
|
||||
@@ -141,23 +155,42 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
> button {
|
||||
> .sub {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 0;
|
||||
width: 42px;
|
||||
height: 100%;
|
||||
|
||||
> button {
|
||||
width: 42px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_500px {
|
||||
> div {
|
||||
> ::v-deep ._content {
|
||||
padding: 24px;
|
||||
|
||||
& + ._content {
|
||||
border-top: solid 1px var(--divider);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_380px {
|
||||
> header {
|
||||
> .title {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
> ::v-deep ._content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
126
src/client/components/ui/folder.vue
Normal file
126
src/client/components/ui/folder.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="ssazuxis" v-size="{ max: [500] }">
|
||||
<header @click="() => showBody = !showBody" class="_button">
|
||||
<div class="title"><slot name="header"></slot></div>
|
||||
<div class="divider"></div>
|
||||
<button class="_button">
|
||||
<template v-if="showBody"><fa :icon="faAngleUp"/></template>
|
||||
<template v-else><fa :icon="faAngleDown"/></template>
|
||||
</button>
|
||||
</header>
|
||||
<transition name="folder-toggle"
|
||||
@enter="enter"
|
||||
@after-enter="afterEnter"
|
||||
@leave="leave"
|
||||
@after-leave="afterLeave"
|
||||
>
|
||||
<div v-show="showBody">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showBody: this.expanded,
|
||||
faAngleUp, faAngleDown
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
toggleContent(show: boolean) {
|
||||
this.showBody = show;
|
||||
},
|
||||
|
||||
enter(el) {
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = 0;
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = elementHeight + 'px';
|
||||
},
|
||||
afterEnter(el) {
|
||||
el.style.height = null;
|
||||
},
|
||||
leave(el) {
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = elementHeight + 'px';
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = 0;
|
||||
},
|
||||
afterLeave(el) {
|
||||
el.style.height = null;
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.folder-toggle-enter-active, .folder-toggle-leave-active {
|
||||
overflow-y: hidden;
|
||||
transition: opacity 0.5s, height 0.5s !important;
|
||||
}
|
||||
.folder-toggle-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
.folder-toggle-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ssazuxis {
|
||||
position: relative;
|
||||
|
||||
> header {
|
||||
display: flex;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
// TODO
|
||||
// position: sticky;
|
||||
// top: var(--stickyTopOffset);
|
||||
// backdrop-filter: blur(20px);
|
||||
|
||||
> .title {
|
||||
margin: 0;
|
||||
padding: 12px 16px 12px 8px;
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .divider {
|
||||
flex: 1;
|
||||
margin: auto;
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
|
||||
> button {
|
||||
width: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_500px {
|
||||
> header {
|
||||
> .title {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -64,7 +64,7 @@
|
||||
<div class="suffix" ref="suffix"><slot name="suffix"></slot></div>
|
||||
</div>
|
||||
<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button>
|
||||
<div class="desc"><slot name="desc"></slot></div>
|
||||
<div class="desc _caption"><slot name="desc"></slot></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -401,13 +401,11 @@ export default defineComponent({
|
||||
|
||||
> .save {
|
||||
margin: 6px 0 0 0;
|
||||
font-size: 13px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
> .desc {
|
||||
margin: 6px 0 0 0;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
|
@@ -196,7 +196,7 @@ export default defineComponent({
|
||||
|
||||
> .text {
|
||||
margin: 6px 0;
|
||||
font-size: 13px;
|
||||
font-size: 0.8em;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
|
@@ -14,7 +14,7 @@
|
||||
></textarea>
|
||||
</div>
|
||||
<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button>
|
||||
<div class="desc"><slot name="desc"></slot></div>
|
||||
<div class="desc _caption"><slot name="desc"></slot></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -163,13 +163,11 @@ export default defineComponent({
|
||||
|
||||
> .save {
|
||||
margin: 6px 0 0 0;
|
||||
font-size: 13px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
> .desc {
|
||||
margin: 6px 0 0 0;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
|
@@ -6,7 +6,7 @@
|
||||
<div v-else-if="tweetId && tweetExpanded" class="twitter" ref="twitter">
|
||||
<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', left: `${tweetLeft}px`, width: `${tweetLeft < 0 ? 'auto' : '100%'}`, height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${$store.state.device.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe>
|
||||
</div>
|
||||
<div v-else class="mk-url-preview" v-size="[{ max: 400 }, { max: 350 }]">
|
||||
<div v-else class="mk-url-preview" v-size="{ max: [400, 350] }">
|
||||
<transition name="zoom" mode="out-in">
|
||||
<component :is="self ? 'router-link' : 'a'" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url" v-if="!fetching">
|
||||
<div class="thumbnail" v-if="thumbnail" :style="`background-image: url('${thumbnail}')`">
|
||||
|
@@ -33,7 +33,7 @@
|
||||
|
||||
<x-sidebar ref="nav" @change-view-mode="calcHeaderWidth"/>
|
||||
|
||||
<div class="contents" ref="contents" :class="{ wallpaper }">
|
||||
<div class="contents" ref="contents" :class="{ wallpaper, full: $store.state.fullView }">
|
||||
<main ref="main">
|
||||
<div class="content">
|
||||
<router-view v-slot="{ Component }">
|
||||
@@ -549,6 +549,18 @@ export default defineComponent({
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
&.full {
|
||||
width: 100%;
|
||||
|
||||
> main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> .widgets {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> main {
|
||||
width: $main-width;
|
||||
min-width: 0;
|
||||
|
@@ -3,27 +3,12 @@ import { Directive } from 'vue';
|
||||
//const observers = new Map<Element, ResizeObserver>();
|
||||
|
||||
export default {
|
||||
mounted(el, binding, vn) {
|
||||
mounted(src, binding, vn) {
|
||||
const query = binding.value;
|
||||
|
||||
/*
|
||||
const addClassRecursive = (el: Element, cls: string) => {
|
||||
el.classList.add(cls);
|
||||
if (el.children) {
|
||||
for (const child of el.children) {
|
||||
addClassRecursive(child, cls);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeClassRecursive = (el: Element, cls: string) => {
|
||||
el.classList.remove(cls);
|
||||
if (el.children) {
|
||||
for (const child of el.children) {
|
||||
removeClassRecursive(child, cls);
|
||||
}
|
||||
}
|
||||
};*/
|
||||
// TODO: 要素をもらうというよりはカスタム幅算出関数をもらうようにしてcalcで都度呼び出して計算するようにした方が柔軟そう
|
||||
// その場合はunbindの方も改修することを忘れずに
|
||||
const el = query.el ? query.el() : src;
|
||||
|
||||
const addClass = (el: Element, cls: string) => {
|
||||
el.classList.add(cls);
|
||||
@@ -36,19 +21,21 @@ export default {
|
||||
const calc = () => {
|
||||
const width = el.clientWidth;
|
||||
|
||||
for (const q of query) {
|
||||
if (q.max) {
|
||||
if (width <= q.max) {
|
||||
addClass(el, 'max-width_' + q.max + 'px');
|
||||
if (query.max) {
|
||||
for (const v of query.max) {
|
||||
if (width <= v) {
|
||||
addClass(src, 'max-width_' + v + 'px');
|
||||
} else {
|
||||
removeClass(el, 'max-width_' + q.max + 'px');
|
||||
removeClass(src, 'max-width_' + v + 'px');
|
||||
}
|
||||
}
|
||||
if (q.min) {
|
||||
if (width >= q.min) {
|
||||
addClass(el, 'min-width_' + q.min + 'px');
|
||||
}
|
||||
if (query.min) {
|
||||
for (const v of query.min) {
|
||||
if (width >= v) {
|
||||
addClass(src, 'min-width_' + v + 'px');
|
||||
} else {
|
||||
removeClass(el, 'min-width_' + q.min + 'px');
|
||||
removeClass(src, 'min-width_' + v + 'px');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,7 +58,11 @@ export default {
|
||||
el._ro_ = ro;
|
||||
},
|
||||
|
||||
unmounted(el, binding, vn) {
|
||||
unmounted(src, binding, vn) {
|
||||
const query = binding.value;
|
||||
|
||||
const el = query.el ? query.el() : src;
|
||||
|
||||
el._ro_.unobserve(el);
|
||||
}
|
||||
} as Directive;
|
||||
|
@@ -217,6 +217,25 @@ store.watch(state => state.device.useBlurEffectForModal, v => {
|
||||
document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none');
|
||||
}, { immediate: true });
|
||||
|
||||
let reloadDialogShowing = false;
|
||||
stream.on('_disconnected_', async () => {
|
||||
if (store.state.device.serverDisconnectedBehavior === 'reload') {
|
||||
location.reload();
|
||||
} else if (store.state.device.serverDisconnectedBehavior === 'dialog') {
|
||||
if (reloadDialogShowing) return;
|
||||
reloadDialogShowing = true;
|
||||
const { canceled } = await app.dialog({
|
||||
type: 'warning',
|
||||
title: app.$t('disconnectedFromServer'),
|
||||
text: app.$t('reloadConfirm'),
|
||||
showCancelButton: true
|
||||
});
|
||||
reloadDialogShowing = false;
|
||||
if (!canceled) {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('emojiAdded', data => {
|
||||
// TODO
|
||||
@@ -337,6 +356,26 @@ if (store.getters.isSignedIn) {
|
||||
});
|
||||
});
|
||||
|
||||
main.on('readAllChannels', () => {
|
||||
store.dispatch('mergeMe', {
|
||||
hasUnreadChannel: false
|
||||
});
|
||||
});
|
||||
|
||||
main.on('unreadChannel', () => {
|
||||
store.dispatch('mergeMe', {
|
||||
hasUnreadChannel: true
|
||||
});
|
||||
|
||||
app.sound('channel');
|
||||
});
|
||||
|
||||
main.on('readAllAnnouncements', () => {
|
||||
store.dispatch('mergeMe', {
|
||||
hasUnreadAnnouncement: false
|
||||
});
|
||||
});
|
||||
|
||||
main.on('clientSettingUpdated', x => {
|
||||
store.commit('settings/set', {
|
||||
key: x.key,
|
||||
|
128
src/client/pages/channel-editor.vue
Normal file
128
src/client/pages/channel-editor.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div>
|
||||
<portal to="icon"><fa :icon="faSatelliteDish"/></portal>
|
||||
<portal to="title">{{ channelId ? $t('_channel.edit') : $t('_channel.create') }}</portal>
|
||||
|
||||
<div class="_card">
|
||||
<div class="_content">
|
||||
<mk-input v-model="name">{{ $t('name') }}</mk-input>
|
||||
|
||||
<mk-textarea v-model="description">{{ $t('description') }}</mk-textarea>
|
||||
|
||||
<div class="banner">
|
||||
<mk-button v-if="bannerId == null" @click="setBannerImage"><fa :icon="faPlus"/> {{ $t('_channel.setBanner') }}</mk-button>
|
||||
<div v-else-if="bannerUrl">
|
||||
<img :src="bannerUrl" style="width: 100%;"/>
|
||||
<mk-button @click="removeBannerImage()"><fa :icon="faTrashAlt"/> {{ $t('_channel.removeBanner') }}</mk-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button @click="save()" primary><fa :icon="faSave"/> {{ channelId ? $t('save') : $t('create') }}</mk-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faPlus, faSatelliteDish } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faSave, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
import MkTextarea from '../components/ui/textarea.vue';
|
||||
import MkButton from '../components/ui/button.vue';
|
||||
import MkInput from '../components/ui/input.vue';
|
||||
import { selectFile } from '../scripts/select-file';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
MkTextarea, MkButton, MkInput,
|
||||
},
|
||||
|
||||
props: {
|
||||
channelId: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
channel: null,
|
||||
name: null,
|
||||
description: null,
|
||||
bannerUrl: null,
|
||||
bannerId: null,
|
||||
faSave, faTrashAlt, faPlus,faSatelliteDish,
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
async bannerId() {
|
||||
if (this.bannerId == null) {
|
||||
this.bannerUrl = null;
|
||||
} else {
|
||||
this.bannerUrl = (await this.$root.api('drive/files/show', {
|
||||
fileId: this.bannerId,
|
||||
})).url;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
async created() {
|
||||
if (this.channelId) {
|
||||
this.channel = await this.$root.api('channels/show', {
|
||||
channelId: this.channelId,
|
||||
});
|
||||
|
||||
this.name = this.channel.name;
|
||||
this.description = this.channel.description;
|
||||
this.bannerId = this.channel.bannerId;
|
||||
this.bannerUrl = this.channel.bannerUrl;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
save() {
|
||||
const params = {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
bannerId: this.bannerId,
|
||||
};
|
||||
|
||||
if (this.channelId) {
|
||||
params.channelId = this.channelId;
|
||||
this.$root.api('channels/update', params)
|
||||
.then(channel => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
});
|
||||
} else {
|
||||
this.$root.api('channels/create', params)
|
||||
.then(channel => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
this.$router.push(`/channels/${channel.id}`);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setBannerImage(e) {
|
||||
selectFile(this, e.currentTarget || e.target, null, false).then(file => {
|
||||
this.bannerId = file.id;
|
||||
});
|
||||
},
|
||||
|
||||
removeBannerImage() {
|
||||
this.bannerId = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
190
src/client/pages/channel.vue
Normal file
190
src/client/pages/channel.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div v-if="channel">
|
||||
<portal to="icon"><fa :icon="faSatelliteDish"/></portal>
|
||||
<portal to="title">{{ channel.name }}</portal>
|
||||
|
||||
<div class="wpgynlbz _panel _vMargin" :class="{ hide: !showBanner }">
|
||||
<x-channel-follow-button :channel="channel" :full="true" class="subscribe"/>
|
||||
<button class="_button toggle" @click="() => showBanner = !showBanner">
|
||||
<template v-if="showBanner"><fa :icon="faAngleUp"/></template>
|
||||
<template v-else><fa :icon="faAngleDown"/></template>
|
||||
</button>
|
||||
<div class="hideOverlay" v-if="!showBanner">
|
||||
</div>
|
||||
<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
|
||||
<div class="status">
|
||||
<div><fa :icon="faUsers" fixed-width/><i18n path="_channel.usersCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.usersCount }}</b></i18n></div>
|
||||
<div><fa :icon="faPencilAlt" fixed-width/><i18n path="_channel.notesCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.notesCount }}</b></i18n></div>
|
||||
</div>
|
||||
<div class="fade"></div>
|
||||
</div>
|
||||
<div class="description" v-if="channel.description">
|
||||
<mfm :text="channel.description" :is-note="false" :i="$store.state.i"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-post-form :channel="channel" class="post-form _panel _vMargin" fixed/>
|
||||
|
||||
<x-timeline class="_vMargin" src="channel" :channel="channelId" @before="before" @after="after"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faSatelliteDish, faUsers, faPencilAlt, faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons';
|
||||
import { } from '@fortawesome/free-regular-svg-icons';
|
||||
import MkContainer from '../components/ui/container.vue';
|
||||
import XPostForm from '../components/post-form.vue';
|
||||
import XTimeline from '../components/timeline.vue';
|
||||
import XChannelFollowButton from '../components/channel-follow-button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('channel') as string
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
MkContainer,
|
||||
XPostForm,
|
||||
XTimeline,
|
||||
XChannelFollowButton
|
||||
},
|
||||
|
||||
props: {
|
||||
channelId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
channel: null,
|
||||
showBanner: true,
|
||||
pagination: {
|
||||
endpoint: 'channels/timeline',
|
||||
limit: 10,
|
||||
params: () => ({
|
||||
channelId: this.channelId,
|
||||
})
|
||||
},
|
||||
faSatelliteDish, faUsers, faPencilAlt, faAngleUp, faAngleDown,
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
channelId: {
|
||||
async handler() {
|
||||
this.channel = await this.$root.api('channels/show', {
|
||||
channelId: this.channelId,
|
||||
});
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wpgynlbz {
|
||||
> .subscribe {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
> .toggle {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
font-size: 1.2em;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 100%;
|
||||
|
||||
> [data-icon] {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
> .banner {
|
||||
position: relative;
|
||||
height: 200px;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
|
||||
> .fade {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background: linear-gradient(0deg, var(--panel), var(--X15));
|
||||
}
|
||||
|
||||
> .status {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
padding: 8px 12px;
|
||||
font-size: 80%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
> .description {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
> .hideOverlay {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&.hide {
|
||||
> .subscribe {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .toggle {
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
> .banner {
|
||||
height: 42px;
|
||||
filter: blur(8px);
|
||||
|
||||
> * {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .description {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
86
src/client/pages/channels.vue
Normal file
86
src/client/pages/channels.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div>
|
||||
<portal to="icon"><fa :icon="faSatelliteDish"/></portal>
|
||||
<portal to="title">{{ $t('channel') }}</portal>
|
||||
|
||||
<mk-tab v-model="tab" :items="[{ label: $t('_channel.featured'), value: 'featured', icon: faFireAlt }, { label: $t('_channel.following'), value: 'following', icon: faHeart }, { label: $t('_channel.owned'), value: 'owned', icon: faEdit }]"/>
|
||||
|
||||
<div class="grwlizim featured" v-if="tab === 'featured'">
|
||||
<mk-pagination :pagination="featuredPagination" #default="{items}">
|
||||
<mk-channel-preview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/>
|
||||
</mk-pagination>
|
||||
</div>
|
||||
|
||||
<div class="grwlizim following" v-if="tab === 'following'">
|
||||
<mk-pagination :pagination="followingPagination" #default="{items}">
|
||||
<mk-channel-preview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/>
|
||||
</mk-pagination>
|
||||
</div>
|
||||
|
||||
<div class="grwlizim owned" v-if="tab === 'owned'">
|
||||
<mk-button class="new" @click="create()"><fa :icon="faPlus"/></mk-button>
|
||||
<mk-pagination :pagination="ownedPagination" #default="{items}">
|
||||
<mk-channel-preview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/>
|
||||
</mk-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faSatelliteDish, faPlus, faEdit, faFireAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faHeart } from '@fortawesome/free-regular-svg-icons';
|
||||
import MkChannelPreview from '../components/channel-preview.vue';
|
||||
import MkPagination from '../components/ui/pagination.vue';
|
||||
import MkButton from '../components/ui/button.vue';
|
||||
import MkTab from '../components/tab.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
MkChannelPreview, MkPagination, MkButton, MkTab
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tab: 'featured',
|
||||
featuredPagination: {
|
||||
endpoint: 'channels/featured',
|
||||
limit: 5,
|
||||
},
|
||||
followingPagination: {
|
||||
endpoint: 'channels/followed',
|
||||
limit: 5,
|
||||
},
|
||||
ownedPagination: {
|
||||
endpoint: 'channels/owned',
|
||||
limit: 5,
|
||||
},
|
||||
faSatelliteDish, faPlus, faEdit, faHeart, faFireAlt
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
create() {
|
||||
this.$router.push(`/channels/new`);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.grwlizim {
|
||||
padding: 16px 0;
|
||||
|
||||
&.my .uveselbe:first-child {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.uveselbe:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 500px) {
|
||||
.uveselbe:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -2,14 +2,15 @@
|
||||
<div class="mk-home" v-hotkey.global="keymap">
|
||||
<portal to="header" v-if="showTitle">
|
||||
<button @click="choose" class="_button _kjvfvyph_">
|
||||
<i><fa v-if="$store.state.i.hasUnreadAntenna" :icon="faCircle"/></i>
|
||||
<i><fa v-if="$store.state.i.hasUnreadAntenna || $store.state.i.hasUnreadChannel" :icon="faCircle"/></i>
|
||||
<fa v-if="src === 'home'" :icon="faHome"/>
|
||||
<fa v-if="src === 'local'" :icon="faComments"/>
|
||||
<fa v-if="src === 'social'" :icon="faShareAlt"/>
|
||||
<fa v-if="src === 'global'" :icon="faGlobe"/>
|
||||
<fa v-if="src === 'list'" :icon="faListUl"/>
|
||||
<fa v-if="src === 'antenna'" :icon="faSatellite"/>
|
||||
<span style="margin-left: 8px;">{{ src === 'list' ? list.name : src === 'antenna' ? antenna.name : $t('_timelines.' + src) }}</span>
|
||||
<fa v-if="src === 'channel'" :icon="faSatelliteDish"/>
|
||||
<span style="margin-left: 8px;">{{ src === 'list' ? list.name : src === 'antenna' ? antenna.name : src === 'channel' ? channel.name : $t('_timelines.' + src) }}</span>
|
||||
<fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/>
|
||||
</button>
|
||||
</portal>
|
||||
@@ -19,13 +20,13 @@
|
||||
<x-tutorial class="tutorial" v-if="$store.state.settings.tutorial != -1"/>
|
||||
|
||||
<x-post-form class="post-form _panel" fixed v-if="$store.state.device.showFixedPostForm"/>
|
||||
<x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src" :src="src" :list="list ? list.id : null" :antenna="antenna ? antenna.id : null" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/>
|
||||
<x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src === 'channel' ? `channel:${channel.id}` : src" :src="src" :list="list ? list.id : null" :antenna="antenna ? antenna.id : null" :channel="channel ? channel.id : null" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, defineAsyncComponent } from 'vue';
|
||||
import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite, faCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite, faSatelliteDish, faCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faComments } from '@fortawesome/free-regular-svg-icons';
|
||||
import Progress from '../scripts/loading';
|
||||
import XTimeline from '../components/timeline.vue';
|
||||
@@ -57,10 +58,11 @@ export default defineComponent({
|
||||
src: 'home',
|
||||
list: null,
|
||||
antenna: null,
|
||||
channel: null,
|
||||
menuOpened: false,
|
||||
queue: 0,
|
||||
width: 0,
|
||||
faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite, faCircle
|
||||
faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite, faSatelliteDish, faCircle
|
||||
};
|
||||
},
|
||||
|
||||
@@ -79,16 +81,20 @@ export default defineComponent({
|
||||
watch: {
|
||||
src() {
|
||||
this.showNav = false;
|
||||
this.saveSrc();
|
||||
},
|
||||
list(x) {
|
||||
this.showNav = false;
|
||||
this.saveSrc();
|
||||
if (x != null) this.antenna = null;
|
||||
if (x != null) this.channel = null;
|
||||
},
|
||||
antenna(x) {
|
||||
this.showNav = false;
|
||||
this.saveSrc();
|
||||
if (x != null) this.list = null;
|
||||
if (x != null) this.channel = null;
|
||||
},
|
||||
channel(x) {
|
||||
this.showNav = false;
|
||||
if (x != null) this.antenna = null;
|
||||
if (x != null) this.list = null;
|
||||
},
|
||||
},
|
||||
@@ -99,6 +105,8 @@ export default defineComponent({
|
||||
this.list = this.$store.state.deviceUser.tl.arg;
|
||||
} else if (this.src === 'antenna') {
|
||||
this.antenna = this.$store.state.deviceUser.tl.arg;
|
||||
} else if (this.src === 'channel') {
|
||||
this.channel = this.$store.state.deviceUser.tl.arg;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -127,9 +135,10 @@ export default defineComponent({
|
||||
async choose(ev) {
|
||||
if (this.meta == null) return;
|
||||
this.menuOpened = true;
|
||||
const [antennas, lists] = await Promise.all([
|
||||
const [antennas, lists, channels] = await Promise.all([
|
||||
this.$root.api('antennas/list'),
|
||||
this.$root.api('users/lists/list')
|
||||
this.$root.api('users/lists/list'),
|
||||
this.$root.api('channels/followed'),
|
||||
]);
|
||||
const antennaItems = antennas.map(antenna => ({
|
||||
text: antenna.name,
|
||||
@@ -137,7 +146,8 @@ export default defineComponent({
|
||||
indicate: antenna.hasUnreadNote,
|
||||
action: () => {
|
||||
this.antenna = antenna;
|
||||
this.setSrc('antenna');
|
||||
this.src = 'antenna';
|
||||
this.saveSrc();
|
||||
}
|
||||
}));
|
||||
const listItems = lists.map(list => ({
|
||||
@@ -145,27 +155,40 @@ export default defineComponent({
|
||||
icon: faListUl,
|
||||
action: () => {
|
||||
this.list = list;
|
||||
this.setSrc('list');
|
||||
this.src = 'list';
|
||||
this.saveSrc();
|
||||
}
|
||||
}));
|
||||
const channelItems = channels.map(channel => ({
|
||||
text: channel.name,
|
||||
icon: faSatelliteDish,
|
||||
indicate: channel.hasUnreadNote,
|
||||
action: () => {
|
||||
// NOTE: チャンネルタイムラインをこのコンポーネントで表示するようにすると投稿フォームはどうするかなどの問題が生じるのでとりあえずページ遷移で
|
||||
//this.channel = channel;
|
||||
//this.src = 'channel';
|
||||
//this.saveSrc();
|
||||
this.$router.push(`/channels/${channel.id}`);
|
||||
}
|
||||
}));
|
||||
this.$root.menu({
|
||||
items: [{
|
||||
text: this.$t('_timelines.home'),
|
||||
icon: faHome,
|
||||
action: () => { this.setSrc('home') }
|
||||
action: () => { this.src = 'home'; this.saveSrc(); }
|
||||
}, this.meta.disableLocalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : {
|
||||
text: this.$t('_timelines.local'),
|
||||
icon: faComments,
|
||||
action: () => { this.setSrc('local') }
|
||||
action: () => { this.src = 'local'; this.saveSrc(); }
|
||||
}, this.meta.disableLocalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : {
|
||||
text: this.$t('_timelines.social'),
|
||||
icon: faShareAlt,
|
||||
action: () => { this.setSrc('social') }
|
||||
action: () => { this.src = 'social'; this.saveSrc(); }
|
||||
}, this.meta.disableGlobalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : {
|
||||
text: this.$t('_timelines.global'),
|
||||
icon: faGlobe,
|
||||
action: () => { this.setSrc('global') }
|
||||
}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems],
|
||||
action: () => { this.src = 'global'; this.saveSrc(); }
|
||||
}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems, channelItems.length > 0 ? null : undefined, ...channelItems],
|
||||
fixed: true,
|
||||
noCenter: true,
|
||||
source: ev.currentTarget || ev.target
|
||||
@@ -174,14 +197,13 @@ export default defineComponent({
|
||||
});
|
||||
},
|
||||
|
||||
setSrc(src) {
|
||||
this.src = src;
|
||||
},
|
||||
|
||||
saveSrc() {
|
||||
this.$store.commit('deviceUser/setTl', {
|
||||
src: this.src,
|
||||
arg: this.src == 'list' ? this.list : this.antenna
|
||||
arg:
|
||||
this.src === 'list' ? this.list :
|
||||
this.src === 'antenna' ? this.antenna :
|
||||
this.channel
|
||||
});
|
||||
},
|
||||
|
||||
|
@@ -3,7 +3,7 @@
|
||||
<portal to="icon"><fa :icon="faLaugh"/></portal>
|
||||
<portal to="title">{{ $t('customEmojis') }}</portal>
|
||||
|
||||
<section class="_card local">
|
||||
<section class="_card _vMargin local">
|
||||
<div class="_title"><fa :icon="faLaugh"/> {{ $t('customEmojis') }}</div>
|
||||
<div class="_content">
|
||||
<mk-pagination :pagination="pagination" class="emojis" ref="emojis">
|
||||
@@ -33,7 +33,7 @@
|
||||
<mk-button inline primary @click="add"><fa :icon="faPlus"/> {{ $t('addEmoji') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="_card remote">
|
||||
<section class="_card _vMargin remote">
|
||||
<div class="_title"><fa :icon="faLaugh"/> {{ $t('customEmojisOfRemote') }}</div>
|
||||
<div class="_content">
|
||||
<mk-input v-model="host" :debounce="true"><span>{{ $t('host') }}</span></mk-input>
|
||||
|
@@ -66,7 +66,7 @@ import MkButton from '../../components/ui/button.vue';
|
||||
import MkInput from '../../components/ui/input.vue';
|
||||
import MkSelect from '../../components/ui/select.vue';
|
||||
import MkPagination from '../../components/ui/pagination.vue';
|
||||
import MkInstanceInfo from './federation.instance.vue';
|
||||
import MkInstanceInfo from './instance.vue';
|
||||
|
||||
export default defineComponent({
|
||||
metaInfo() {
|
||||
|
198
src/client/pages/instance/index.queue-chart.vue
Normal file
198
src/client/pages/instance/index.queue-chart.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<mk-container :body-togglable="false">
|
||||
<template #header><slot name="title"></slot></template>
|
||||
<template #func><button class="_button" @click="resume" :disabled="!paused"><fa :icon="faPlay"/></button><button class="_button" @click="pause" :disabled="paused"><fa :icon="faPause"/></button></template>
|
||||
|
||||
<div class="_content _table">
|
||||
<div class="_row">
|
||||
<div class="_cell"><div class="_label">Process</div>{{ activeSincePrevTick | number }}</div>
|
||||
<div class="_cell"><div class="_label">Active</div>{{ active | number }}</div>
|
||||
<div class="_cell"><div class="_label">Waiting</div>{{ waiting | number }}</div>
|
||||
<div class="_cell"><div class="_label">Delayed</div>{{ delayed | number }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_content" style="margin-bottom: -8px;">
|
||||
<canvas ref="chart"></canvas>
|
||||
</div>
|
||||
</mk-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Chart from 'chart.js';
|
||||
import { faPlay, faPause } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkContainer from '../../components/ui/container.vue';
|
||||
|
||||
const alpha = (hex, a) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
|
||||
const r = parseInt(result[1], 16);
|
||||
const g = parseInt(result[2], 16);
|
||||
const b = parseInt(result[3], 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
};
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
MkContainer,
|
||||
},
|
||||
|
||||
props: {
|
||||
domain: {
|
||||
required: true
|
||||
},
|
||||
connection: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
chart: null,
|
||||
activeSincePrevTick: 0,
|
||||
active: 0,
|
||||
waiting: 0,
|
||||
delayed: 0,
|
||||
paused: false,
|
||||
faPlay, faPause
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// TODO: var(--panel)の色が暗いか明るいかで判定する
|
||||
const gridColor = this.$store.state.device.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
|
||||
Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
|
||||
|
||||
this.chart = new Chart(this.$refs.chart, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Process',
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 0,
|
||||
backgroundColor: '#8BC34A',
|
||||
data: []
|
||||
}, {
|
||||
label: 'Active',
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 0,
|
||||
backgroundColor: '#03A9F4',
|
||||
data: []
|
||||
}, {
|
||||
label: 'Waiting',
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 0,
|
||||
backgroundColor: '#FFC107',
|
||||
data: []
|
||||
}, {
|
||||
label: 'Delayed',
|
||||
order: -1,
|
||||
type: 'line',
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: '#F44336',
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
data: []
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
aspectRatio: 3,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 8,
|
||||
bottom: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
boxWidth: 16,
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
stacked: true,
|
||||
gridLines: {
|
||||
display: false,
|
||||
color: gridColor,
|
||||
zeroLineColor: gridColor,
|
||||
},
|
||||
ticks: {
|
||||
display: false
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
stacked: true,
|
||||
position: 'right',
|
||||
gridLines: {
|
||||
display: true,
|
||||
color: gridColor,
|
||||
zeroLineColor: gridColor,
|
||||
},
|
||||
ticks: {
|
||||
display: false,
|
||||
}
|
||||
}]
|
||||
},
|
||||
tooltips: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.connection.on('stats', this.onStats);
|
||||
this.connection.on('statsLog', this.onStatsLog);
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.connection.off('stats', this.onStats);
|
||||
this.connection.off('statsLog', this.onStatsLog);
|
||||
},
|
||||
|
||||
methods: {
|
||||
onStats(stats) {
|
||||
if (this.paused) return;
|
||||
this.activeSincePrevTick = stats[this.domain].activeSincePrevTick;
|
||||
this.active = stats[this.domain].active;
|
||||
this.waiting = stats[this.domain].waiting;
|
||||
this.delayed = stats[this.domain].delayed;
|
||||
this.chart.data.labels.push('');
|
||||
this.chart.data.datasets[0].data.push(stats[this.domain].activeSincePrevTick);
|
||||
this.chart.data.datasets[1].data.push(stats[this.domain].active);
|
||||
this.chart.data.datasets[2].data.push(stats[this.domain].waiting);
|
||||
this.chart.data.datasets[3].data.push(stats[this.domain].delayed);
|
||||
if (this.chart.data.datasets[0].data.length > 100) {
|
||||
this.chart.data.labels.shift();
|
||||
this.chart.data.datasets[0].data.shift();
|
||||
this.chart.data.datasets[1].data.shift();
|
||||
this.chart.data.datasets[2].data.shift();
|
||||
this.chart.data.datasets[3].data.shift();
|
||||
}
|
||||
this.chart.update();
|
||||
},
|
||||
|
||||
onStatsLog(statsLog) {
|
||||
for (const stats of [...statsLog].reverse()) {
|
||||
this.onStats(stats);
|
||||
}
|
||||
},
|
||||
|
||||
pause() {
|
||||
this.paused = true;
|
||||
},
|
||||
|
||||
resume() {
|
||||
this.paused = false;
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
@@ -1,113 +1,204 @@
|
||||
<template>
|
||||
<div v-if="meta" class="xhexznfu">
|
||||
<div v-if="meta" class="xhexznfu" v-size="{ min: [1600] }">
|
||||
<portal to="icon"><fa :icon="faServer"/></portal>
|
||||
<portal to="title">{{ $t('instance') }}</portal>
|
||||
|
||||
<mk-instance-stats style="margin-bottom: var(--margin);"/>
|
||||
<mk-folder>
|
||||
<template #header><fa :icon="faTachometerAlt"/> {{ $t('overview') }}</template>
|
||||
|
||||
<section class="_card logs">
|
||||
<div class="_title"><fa :icon="faStream"/> {{ $t('serverLogs') }}</div>
|
||||
<div class="_content">
|
||||
<div class="_inputs">
|
||||
<mk-input v-model="logDomain" :debounce="true">
|
||||
<span>{{ $t('domain') }}</span>
|
||||
</mk-input>
|
||||
<mk-select v-model="logLevel">
|
||||
<template #label>{{ $t('level') }}</template>
|
||||
<option value="all">{{ $t('levels.all') }}</option>
|
||||
<option value="info">{{ $t('levels.info') }}</option>
|
||||
<option value="success">{{ $t('levels.success') }}</option>
|
||||
<option value="warning">{{ $t('levels.warning') }}</option>
|
||||
<option value="error">{{ $t('levels.error') }}</option>
|
||||
<option value="debug">{{ $t('levels.debug') }}</option>
|
||||
</mk-select>
|
||||
</div>
|
||||
<div class="sboqnrfi" :style="{ gridTemplateRows: overviewHeight }">
|
||||
<mk-instance-stats :chart-limit="300" :detailed="true" class="stats" ref="stats"/>
|
||||
|
||||
<div class="logs">
|
||||
<code v-for="log in logs" :key="log.id" :class="log.level">
|
||||
<details>
|
||||
<summary><mk-time :time="log.createdAt"/> [{{ log.domain.join('.') }}] {{ log.message }}</summary>
|
||||
<vue-json-pretty v-if="log.data" :data="log.data"></vue-json-pretty>
|
||||
</details>
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button @click="deleteAllLogs()" primary><fa :icon="faTrashAlt"/> {{ $t('deleteAll') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="column">
|
||||
<mk-container :body-togglable="true" :resize-base-el="() => $el" class="info">
|
||||
<template #header><fa :icon="faInfoCircle"/>{{ $t('instanceInfo') }}</template>
|
||||
|
||||
<section class="_card chart">
|
||||
<div class="_title"><fa :icon="faMicrochip"/> {{ $t('cpuAndMemory') }}</div>
|
||||
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
|
||||
<canvas ref="cpumem"></canvas>
|
||||
</div>
|
||||
<div class="_content" v-if="serverInfo">
|
||||
<div class="table">
|
||||
<div class="row">
|
||||
<div class="cell"><div class="label">CPU</div>{{ serverInfo.cpu.model }}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell"><div class="label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div>
|
||||
<div class="cell"><div class="label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
|
||||
<div class="cell"><div class="label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="_card chart">
|
||||
<div class="_title"><fa :icon="faHdd"/> {{ $t('disk') }}</div>
|
||||
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
|
||||
<canvas ref="disk"></canvas>
|
||||
</div>
|
||||
<div class="_content" v-if="serverInfo">
|
||||
<div class="table">
|
||||
<div class="row">
|
||||
<div class="cell"><div class="label">Disk total</div>{{ bytes(serverInfo.fs.total) }}</div>
|
||||
<div class="cell"><div class="label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
|
||||
<div class="cell"><div class="label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="_card chart">
|
||||
<div class="_title"><fa :icon="faExchangeAlt"/> {{ $t('network') }}</div>
|
||||
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
|
||||
<canvas ref="net"></canvas>
|
||||
</div>
|
||||
<div class="_content" v-if="serverInfo">
|
||||
<div class="table">
|
||||
<div class="row">
|
||||
<div class="cell"><div class="label">Interface</div>{{ serverInfo.net.interface }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="_content">
|
||||
<div class="_keyValue"><b>Misskey</b><span>v{{ version }}</span></div>
|
||||
</div>
|
||||
<div class="_content" v-if="serverInfo">
|
||||
<div class="_keyValue"><b>Node.js</b><span>{{ serverInfo.node }}</span></div>
|
||||
<div class="_keyValue"><b>PostgreSQL</b><span>v{{ serverInfo.psql }}</span></div>
|
||||
<div class="_keyValue"><b>Redis</b><span>v{{ serverInfo.redis }}</span></div>
|
||||
</div>
|
||||
</mk-container>
|
||||
|
||||
<mk-container :body-togglable="true" :scrollable="true" :resize-base-el="() => $el" class="db">
|
||||
<template #header><fa :icon="faDatabase"/>{{ $t('database') }}</template>
|
||||
|
||||
<section class="_card info">
|
||||
<div class="_content table">
|
||||
<div><b>Misskey</b><span>v{{ version }}</span></div>
|
||||
<div class="_content" v-if="dbInfo">
|
||||
<table style="border-collapse: collapse; width: 100%;">
|
||||
<tr style="opacity: 0.7;">
|
||||
<th style="text-align: left; padding: 0 8px 8px 0;">Table</th>
|
||||
<th style="text-align: left; padding: 0 8px 8px 0;">Records</th>
|
||||
<th style="text-align: left; padding: 0 0 8px 0;">Size</th>
|
||||
</tr>
|
||||
<tr v-for="table in dbInfo" :key="table[0]">
|
||||
<th style="text-align: left; padding: 0 8px 0 0; word-break: break-all;">{{ table[0] }}</th>
|
||||
<td style="padding: 0 8px 0 0;">{{ number(table[1].count) }}</td>
|
||||
<td style="padding: 0; opacity: 0.7;">{{ bytes(table[1].size) }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</mk-container>
|
||||
|
||||
<mkw-federation class="fed" :body-togglable="true" :scrollable="true"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_content table" v-if="serverInfo">
|
||||
<div><b>Node.js</b><span>{{ serverInfo.node }}</span></div>
|
||||
<div><b>PostgreSQL</b><span>v{{ serverInfo.psql }}</span></div>
|
||||
<div><b>Redis</b><span>v{{ serverInfo.redis }}</span></div>
|
||||
</mk-folder>
|
||||
|
||||
<mk-folder style="margin: var(--margin) 0;">
|
||||
<template #header><fa :icon="faHeartbeat"/> {{ $t('metrics') }}</template>
|
||||
|
||||
<div class="segusily">
|
||||
<mk-container :body-togglable="false" :resize-base-el="() => $el">
|
||||
<template #header><fa :icon="faMicrochip"/>{{ $t('cpuAndMemory') }}</template>
|
||||
<template #func><button class="_button" @click="resume" :disabled="!paused"><fa :icon="faPlay"/></button><button class="_button" @click="pause" :disabled="paused"><fa :icon="faPause"/></button></template>
|
||||
|
||||
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
|
||||
<canvas ref="cpumem"></canvas>
|
||||
</div>
|
||||
<div class="_content" v-if="serverInfo">
|
||||
<div class="_table">
|
||||
<!--
|
||||
<div class="_row">
|
||||
<div class="_cell"><div class="_label">CPU</div>{{ serverInfo.cpu.model }}</div>
|
||||
</div>
|
||||
-->
|
||||
<div class="_row">
|
||||
<div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div>
|
||||
<div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
|
||||
<div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mk-container>
|
||||
|
||||
<mk-container :body-togglable="false" :resize-base-el="() => $el">
|
||||
<template #header><fa :icon="faHdd"/> {{ $t('disk') }}</template>
|
||||
<template #func><button class="_button" @click="resume" :disabled="!paused"><fa :icon="faPlay"/></button><button class="_button" @click="pause" :disabled="paused"><fa :icon="faPause"/></button></template>
|
||||
|
||||
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
|
||||
<canvas ref="disk"></canvas>
|
||||
</div>
|
||||
<div class="_content" v-if="serverInfo">
|
||||
<div class="_table">
|
||||
<div class="_row">
|
||||
<div class="_cell"><div class="_label">Disk total</div>{{ bytes(serverInfo.fs.total) }}</div>
|
||||
<div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
|
||||
<div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mk-container>
|
||||
|
||||
<mk-container :body-togglable="false" :resize-base-el="() => $el">
|
||||
<template #header><fa :icon="faExchangeAlt"/> {{ $t('network') }}</template>
|
||||
<template #func><button class="_button" @click="resume" :disabled="!paused"><fa :icon="faPlay"/></button><button class="_button" @click="pause" :disabled="paused"><fa :icon="faPause"/></button></template>
|
||||
|
||||
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
|
||||
<canvas ref="net"></canvas>
|
||||
</div>
|
||||
<div class="_content" v-if="serverInfo">
|
||||
<div class="_table">
|
||||
<div class="_row">
|
||||
<div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mk-container>
|
||||
</div>
|
||||
</section>
|
||||
</mk-folder>
|
||||
|
||||
<mk-folder>
|
||||
<template #header><fa :icon="faClipboardList"/> {{ $t('jobQueue') }}</template>
|
||||
|
||||
<div class="vkyrmkwb" :style="{ gridTemplateRows: queueHeight }">
|
||||
<mk-container :body-togglable="false" :scrollable="true" :resize-base-el="() => $el">
|
||||
<template #header><fa :icon="faExclamationTriangle"/> {{ $t('delayed') }}</template>
|
||||
|
||||
<div class="_content">
|
||||
<div class="_keyValue" v-for="job in jobs" :key="job[0]">
|
||||
<button class="_button" @click="showInstanceInfo(job[0])">{{ job[0] }}</button>
|
||||
<div style="text-align: right;">{{ number(job[1]) }} jobs</div>
|
||||
</div>
|
||||
</div>
|
||||
</mk-container>
|
||||
<x-queue :connection="queueConnection" domain="inbox" ref="queue" class="queue">
|
||||
<template #title><fa :icon="faExchangeAlt"/> In</template>
|
||||
</x-queue>
|
||||
<x-queue :connection="queueConnection" domain="deliver" class="queue">
|
||||
<template #title><fa :icon="faExchangeAlt"/> Out</template>
|
||||
</x-queue>
|
||||
</div>
|
||||
</mk-folder>
|
||||
|
||||
<mk-folder>
|
||||
<template #header><fa :icon="faStream"/> {{ $t('logs') }}</template>
|
||||
|
||||
<div class="uwuemslx">
|
||||
<mk-container :body-togglable="false" :resize-base-el="() => $el">
|
||||
<template #header><fa :icon="faInfoCircle"/>{{ $t('') }}</template>
|
||||
|
||||
<div class="_content">
|
||||
<div class="_keyValue" v-for="log in modLogs">
|
||||
<b>{{ log.type }}</b><span>by {{ log.user.username }}</span><mk-time :time="log.createdAt" style="opacity: 0.7;"/>
|
||||
</div>
|
||||
</div>
|
||||
</mk-container>
|
||||
|
||||
<section class="_card logs">
|
||||
<div class="_title"><fa :icon="faStream"/> {{ $t('serverLogs') }}</div>
|
||||
<div class="_content">
|
||||
<div class="_inputs">
|
||||
<mk-input v-model="logDomain" :debounce="true">
|
||||
<span>{{ $t('domain') }}</span>
|
||||
</mk-input>
|
||||
<mk-select v-model="logLevel">
|
||||
<template #label>{{ $t('level') }}</template>
|
||||
<option value="all">{{ $t('levels.all') }}</option>
|
||||
<option value="info">{{ $t('levels.info') }}</option>
|
||||
<option value="success">{{ $t('levels.success') }}</option>
|
||||
<option value="warning">{{ $t('levels.warning') }}</option>
|
||||
<option value="error">{{ $t('levels.error') }}</option>
|
||||
<option value="debug">{{ $t('levels.debug') }}</option>
|
||||
</mk-select>
|
||||
</div>
|
||||
|
||||
<div class="logs">
|
||||
<code v-for="log in logs" :key="log.id" :class="log.level">
|
||||
<details>
|
||||
<summary><mk-time :time="log.createdAt"/> [{{ log.domain.join('.') }}] {{ log.message }}</summary>
|
||||
<vue-json-pretty v-if="log.data" :data="log.data"></vue-json-pretty>
|
||||
</details>
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button @click="deleteAllLogs()" primary><fa :icon="faTrashAlt"/> {{ $t('deleteAll') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</mk-folder>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faServer, faExchangeAlt, faMicrochip, faHdd, faStream, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faPlay, faPause, faDatabase, faServer, faExchangeAlt, faMicrochip, faHdd, faStream, faTrashAlt, faInfoCircle, faExclamationTriangle, faTachometerAlt, faHeartbeat, faClipboardList } from '@fortawesome/free-solid-svg-icons';
|
||||
import Chart from 'chart.js';
|
||||
import VueJsonPretty from 'vue-json-pretty';
|
||||
import MkInstanceStats from '../../components/instance-stats.vue';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkSelect from '../../components/ui/select.vue';
|
||||
import MkInput from '../../components/ui/input.vue';
|
||||
import MkContainer from '../../components/ui/container.vue';
|
||||
import MkFolder from '../../components/ui/folder.vue';
|
||||
import MkwFederation from '../../widgets/federation.vue';
|
||||
import { version, url } from '../../config';
|
||||
import bytes from '../../filters/bytes';
|
||||
import XQueue from './index.queue-chart.vue';
|
||||
import MkInstanceInfo from './instance.vue';
|
||||
|
||||
const alpha = (hex, a) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
|
||||
@@ -129,7 +220,11 @@ export default defineComponent({
|
||||
MkButton,
|
||||
MkSelect,
|
||||
MkInput,
|
||||
VueJsonPretty
|
||||
MkContainer,
|
||||
MkFolder,
|
||||
MkwFederation,
|
||||
XQueue,
|
||||
VueJsonPretty,
|
||||
},
|
||||
|
||||
data() {
|
||||
@@ -139,13 +234,20 @@ export default defineComponent({
|
||||
stats: null,
|
||||
serverInfo: null,
|
||||
connection: null,
|
||||
queueConnection: this.$root.stream.useSharedConnection('queueStats'),
|
||||
memUsage: 0,
|
||||
chartCpuMem: null,
|
||||
chartNet: null,
|
||||
jobs: [],
|
||||
logs: [],
|
||||
logLevel: 'all',
|
||||
logDomain: '',
|
||||
faServer, faExchangeAlt, faMicrochip, faHdd, faStream, faTrashAlt
|
||||
modLogs: [],
|
||||
dbInfo: null,
|
||||
overviewHeight: '1fr',
|
||||
queueHeight: '1fr',
|
||||
paused: false,
|
||||
faPlay, faPause, faDatabase, faServer, faExchangeAlt, faMicrochip, faHdd, faStream, faTrashAlt, faInfoCircle, faExclamationTriangle, faTachometerAlt, faHeartbeat, faClipboardList,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -166,9 +268,18 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.$store.commit('setFullView', true);
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.fetchLogs();
|
||||
this.fetchJobs();
|
||||
this.fetchModLogs();
|
||||
|
||||
// TODO: var(--panel)の色が暗いか明るいかで判定する
|
||||
const gridColor = this.$store.state.device.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
|
||||
Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
|
||||
|
||||
this.chartCpuMem = new Chart(this.$refs.cpumem, {
|
||||
@@ -221,14 +332,21 @@ export default defineComponent({
|
||||
scales: {
|
||||
xAxes: [{
|
||||
gridLines: {
|
||||
display: false
|
||||
display: false,
|
||||
color: gridColor,
|
||||
zeroLineColor: gridColor,
|
||||
},
|
||||
ticks: {
|
||||
display: false
|
||||
display: false,
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
position: 'right',
|
||||
gridLines: {
|
||||
display: true,
|
||||
color: gridColor,
|
||||
zeroLineColor: gridColor,
|
||||
},
|
||||
ticks: {
|
||||
display: false,
|
||||
max: 100
|
||||
@@ -283,7 +401,9 @@ export default defineComponent({
|
||||
scales: {
|
||||
xAxes: [{
|
||||
gridLines: {
|
||||
display: false
|
||||
display: false,
|
||||
color: gridColor,
|
||||
zeroLineColor: gridColor,
|
||||
},
|
||||
ticks: {
|
||||
display: false
|
||||
@@ -291,6 +411,11 @@ export default defineComponent({
|
||||
}],
|
||||
yAxes: [{
|
||||
position: 'right',
|
||||
gridLines: {
|
||||
display: true,
|
||||
color: gridColor,
|
||||
zeroLineColor: gridColor,
|
||||
},
|
||||
ticks: {
|
||||
display: false,
|
||||
}
|
||||
@@ -344,7 +469,9 @@ export default defineComponent({
|
||||
scales: {
|
||||
xAxes: [{
|
||||
gridLines: {
|
||||
display: false
|
||||
display: false,
|
||||
color: gridColor,
|
||||
zeroLineColor: gridColor,
|
||||
},
|
||||
ticks: {
|
||||
display: false
|
||||
@@ -352,6 +479,11 @@ export default defineComponent({
|
||||
}],
|
||||
yAxes: [{
|
||||
position: 'right',
|
||||
gridLines: {
|
||||
display: true,
|
||||
color: gridColor,
|
||||
zeroLineColor: gridColor,
|
||||
},
|
||||
ticks: {
|
||||
display: false,
|
||||
}
|
||||
@@ -374,6 +506,31 @@ export default defineComponent({
|
||||
id: Math.random().toString().substr(2, 8),
|
||||
length: 150
|
||||
});
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.queueConnection.send('requestLog', {
|
||||
id: Math.random().toString().substr(2, 8),
|
||||
length: 200
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
this.$root.api('admin/get-table-stats', {}).then(res => {
|
||||
this.dbInfo = Object.entries(res).sort((a, b) => b[1].size - a[1].size);
|
||||
});
|
||||
|
||||
this.$nextTick(() => {
|
||||
new ResizeObserver((entries, observer) => {
|
||||
if (this.$refs.stats && this.$refs.stats.$el) {
|
||||
this.overviewHeight = this.$refs.stats.$el.offsetHeight + 'px';
|
||||
}
|
||||
}).observe(this.$refs.stats.$el);
|
||||
|
||||
new ResizeObserver((entries, observer) => {
|
||||
if (this.$refs.queue && this.$refs.queue.$el) {
|
||||
this.queueHeight = this.$refs.queue.$el.offsetHeight + 'px';
|
||||
}
|
||||
}).observe(this.$refs.queue.$el);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -381,9 +538,23 @@ export default defineComponent({
|
||||
this.connection.off('stats', this.onStats);
|
||||
this.connection.off('statsLog', this.onStatsLog);
|
||||
this.connection.dispose();
|
||||
this.queueConnection.dispose();
|
||||
this.$store.commit('setFullView', false);
|
||||
},
|
||||
|
||||
methods: {
|
||||
async showInstanceInfo(q) {
|
||||
let instance = q;
|
||||
if (typeof q === 'string') {
|
||||
instance = await this.$root.api('federation/show-instance', {
|
||||
host: q
|
||||
});
|
||||
}
|
||||
this.$root.new(MkInstanceInfo, {
|
||||
instance: instance
|
||||
});
|
||||
},
|
||||
|
||||
fetchLogs() {
|
||||
this.$root.api('admin/logs', {
|
||||
level: this.logLevel === 'all' ? null : this.logLevel,
|
||||
@@ -394,6 +565,18 @@ export default defineComponent({
|
||||
});
|
||||
},
|
||||
|
||||
fetchJobs() {
|
||||
this.$root.api('admin/queue/deliver-delayed', {}).then(jobs => {
|
||||
this.jobs = jobs;
|
||||
});
|
||||
},
|
||||
|
||||
fetchModLogs() {
|
||||
this.$root.api('admin/show-moderation-logs', {}).then(logs => {
|
||||
this.modLogs = logs;
|
||||
});
|
||||
},
|
||||
|
||||
deleteAllLogs() {
|
||||
this.$root.api('admin/delete-logs').then(() => {
|
||||
this.$root.dialog({
|
||||
@@ -404,6 +587,8 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
onStats(stats) {
|
||||
if (this.paused) return;
|
||||
|
||||
const cpu = (stats.cpu * 100).toFixed(0);
|
||||
const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0);
|
||||
const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0);
|
||||
@@ -442,13 +627,97 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
|
||||
bytes
|
||||
bytes,
|
||||
|
||||
pause() {
|
||||
this.paused = true;
|
||||
},
|
||||
|
||||
resume() {
|
||||
this.paused = false;
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.xhexznfu {
|
||||
&.min-width_1600px {
|
||||
.sboqnrfi {
|
||||
display: grid;
|
||||
grid-template-columns: 3.2fr 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 16px 16px;
|
||||
|
||||
> .stats {
|
||||
height: min-content;
|
||||
}
|
||||
|
||||
> .column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .info {
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
> .db {
|
||||
flex: 1;
|
||||
flex-grow: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
> .fed {
|
||||
flex: 1;
|
||||
flex-grow: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-bottom: var(--margin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.segusily {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 16px 16px;
|
||||
}
|
||||
|
||||
.vkyrmkwb {
|
||||
display: grid;
|
||||
grid-template-columns: 0.5fr 1fr 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 16px 16px;
|
||||
margin-bottom: var(--margin);
|
||||
|
||||
> .queue {
|
||||
height: min-content;
|
||||
}
|
||||
|
||||
> * {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.uwuemslx {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 3fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 16px 16px;
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.vkyrmkwb {
|
||||
> * {
|
||||
margin-bottom: var(--margin);
|
||||
}
|
||||
}
|
||||
|
||||
> .stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -494,49 +763,5 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .chart {
|
||||
> ._content {
|
||||
> .table {
|
||||
> .row {
|
||||
display: flex;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .cell {
|
||||
flex: 1;
|
||||
|
||||
> .label {
|
||||
font-size: 80%;
|
||||
opacity: 0.7;
|
||||
|
||||
> .icon {
|
||||
margin-right: 4px;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .info {
|
||||
> .table {
|
||||
> div {
|
||||
display: flex;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -2,69 +2,69 @@
|
||||
<x-window @closed="() => { $emit('closed'); destroyDom(); }" :no-padding="true" :width="520" :height="500">
|
||||
<template #header>{{ instance.host }}</template>
|
||||
<div class="mk-instance-info">
|
||||
<div class="table info">
|
||||
<div class="row">
|
||||
<div class="cell">
|
||||
<div class="label">{{ $t('software') }}</div>
|
||||
<div class="data">{{ instance.softwareName || '?' }}</div>
|
||||
<div class="_table">
|
||||
<div class="_row">
|
||||
<div class="_cell">
|
||||
<div class="_label">{{ $t('software') }}</div>
|
||||
<div class="_data">{{ instance.softwareName || '?' }}</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="label">{{ $t('version') }}</div>
|
||||
<div class="data">{{ instance.softwareVersion || '?' }}</div>
|
||||
<div class="_cell">
|
||||
<div class="_label">{{ $t('version') }}</div>
|
||||
<div class="_data">{{ instance.softwareVersion || '?' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table data">
|
||||
<div class="row">
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faCrosshairs" fixed-width class="icon"/>{{ $t('registeredAt') }}</div>
|
||||
<div class="data">{{ new Date(instance.caughtAt).toLocaleString() }} (<mk-time :time="instance.caughtAt"/>)</div>
|
||||
<div class="_table data">
|
||||
<div class="_row">
|
||||
<div class="_cell">
|
||||
<div class="_label">{{ $t('registeredAt') }}</div>
|
||||
<div class="_data">{{ new Date(instance.caughtAt).toLocaleString() }} (<mk-time :time="instance.caughtAt"/>)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faCloudDownloadAlt" fixed-width class="icon"/>{{ $t('following') }}</div>
|
||||
<div class="data clickable" @click="showFollowing()">{{ number(instance.followingCount) }}</div>
|
||||
<div class="_row">
|
||||
<div class="_cell">
|
||||
<div class="_label">{{ $t('following') }}</div>
|
||||
<button class="_data _textButton" @click="showFollowing()">{{ number(instance.followingCount) }}</button>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faCloudUploadAlt" fixed-width class="icon"/>{{ $t('followers') }}</div>
|
||||
<div class="data clickable" @click="showFollowers()">{{ number(instance.followersCount) }}</div>
|
||||
<div class="_cell">
|
||||
<div class="_label">{{ $t('followers') }}</div>
|
||||
<button class="_data _textButton" @click="showFollowers()">{{ number(instance.followersCount) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faUsers" fixed-width class="icon"/>{{ $t('users') }}</div>
|
||||
<div class="data clickable" @click="showUsers()">{{ number(instance.usersCount) }}</div>
|
||||
<div class="_row">
|
||||
<div class="_cell">
|
||||
<div class="_label">{{ $t('users') }}</div>
|
||||
<button class="_data _textButton" @click="showUsers()">{{ number(instance.usersCount) }}</button>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faPencilAlt" fixed-width class="icon"/>{{ $t('notes') }}</div>
|
||||
<div class="data">{{ number(instance.notesCount) }}</div>
|
||||
<div class="_cell">
|
||||
<div class="_label">{{ $t('notes') }}</div>
|
||||
<div class="_data">{{ number(instance.notesCount) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faFileImage" fixed-width class="icon"/>{{ $t('files') }}</div>
|
||||
<div class="data">{{ number(instance.driveFiles) }}</div>
|
||||
<div class="_row">
|
||||
<div class="_cell">
|
||||
<div class="_label">{{ $t('files') }}</div>
|
||||
<div class="_data">{{ number(instance.driveFiles) }}</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faDatabase" fixed-width class="icon"/>{{ $t('storageUsage') }}</div>
|
||||
<div class="data">{{ bytes(instance.driveUsage) }}</div>
|
||||
<div class="_cell">
|
||||
<div class="_label">{{ $t('storageUsage') }}</div>
|
||||
<div class="_data">{{ bytes(instance.driveUsage) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faLongArrowAltUp" fixed-width class="icon"/>{{ $t('latestRequestSentAt') }}</div>
|
||||
<div class="data"><mk-time v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
|
||||
<div class="_row">
|
||||
<div class="_cell">
|
||||
<div class="_label">{{ $t('latestRequestSentAt') }}</div>
|
||||
<div class="_data"><mk-time v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faTrafficLight" fixed-width class="icon"/>{{ $t('latestStatus') }}</div>
|
||||
<div class="data">{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</div>
|
||||
<div class="_cell">
|
||||
<div class="_label">{{ $t('latestStatus') }}</div>
|
||||
<div class="_data">{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faLongArrowAltDown" fixed-width class="icon"/>{{ $t('latestRequestReceivedAt') }}</div>
|
||||
<div class="data"><mk-time v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div>
|
||||
<div class="_row">
|
||||
<div class="_cell">
|
||||
<div class="_label">{{ $t('latestRequestReceivedAt') }}</div>
|
||||
<div class="_data"><mk-time v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -489,39 +489,12 @@ export default defineComponent({
|
||||
.mk-instance-info {
|
||||
overflow: auto;
|
||||
|
||||
> .table {
|
||||
> ._table {
|
||||
padding: 0 32px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
> .row {
|
||||
display: flex;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
> .cell {
|
||||
flex: 1;
|
||||
|
||||
> .label {
|
||||
font-size: 80%;
|
||||
opacity: 0.7;
|
||||
|
||||
> .icon {
|
||||
margin-right: 4px;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .data.clickable {
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .data {
|
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<section class="_card mk-queue-queue">
|
||||
<section class="_card">
|
||||
<div class="_title"><slot name="title"></slot></div>
|
||||
<div class="_content status">
|
||||
<div class="cell"><div class="label">Process</div>{{ number(activeSincePrevTick) }}</div>
|
||||
<div class="cell"><div class="label">Active</div>{{ number(active) }}</div>
|
||||
<div class="cell"><div class="label">Waiting</div>{{ number(waiting) }}</div>
|
||||
<div class="cell"><div class="label">Delayed</div>{{ number(delayed) }}</div>
|
||||
<div class="_content _table">
|
||||
<div class="_row">
|
||||
<div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
|
||||
<div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div>
|
||||
<div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
|
||||
<div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_content" style="margin-bottom: -8px;">
|
||||
<canvas ref="chart"></canvas>
|
||||
@@ -59,6 +61,9 @@ export default defineComponent({
|
||||
mounted() {
|
||||
this.fetchJobs();
|
||||
|
||||
// TODO: var(--panel)の色が暗いか明るいかで判定する
|
||||
const gridColor = this.$store.state.device.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
|
||||
Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
|
||||
|
||||
this.chart = new Chart(this.$refs.chart, {
|
||||
@@ -119,7 +124,9 @@ export default defineComponent({
|
||||
scales: {
|
||||
xAxes: [{
|
||||
gridLines: {
|
||||
display: false
|
||||
display: false,
|
||||
color: gridColor,
|
||||
zeroLineColor: gridColor,
|
||||
},
|
||||
ticks: {
|
||||
display: false
|
||||
@@ -127,6 +134,11 @@ export default defineComponent({
|
||||
}],
|
||||
yAxes: [{
|
||||
position: 'right',
|
||||
gridLines: {
|
||||
display: true,
|
||||
color: gridColor,
|
||||
zeroLineColor: gridColor,
|
||||
},
|
||||
ticks: {
|
||||
display: false,
|
||||
}
|
||||
@@ -185,20 +197,3 @@ export default defineComponent({
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-queue-queue {
|
||||
> .status {
|
||||
display: flex;
|
||||
|
||||
> .cell {
|
||||
flex: 1;
|
||||
|
||||
> .label {
|
||||
font-size: 80%;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -22,7 +22,7 @@ import { defineComponent } from 'vue';
|
||||
import { faExchangeAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faTrashAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import XQueue from './queue.queue.vue';
|
||||
import XQueue from './queue.chart.vue';
|
||||
|
||||
export default defineComponent({
|
||||
metaInfo() {
|
||||
|
@@ -3,7 +3,7 @@
|
||||
<portal to="icon"><fa :icon="faProjectDiagram"/></portal>
|
||||
<portal to="title">{{ $t('relays') }}</portal>
|
||||
|
||||
<section class="_card add">
|
||||
<section class="_card _vMargin add">
|
||||
<div class="_title"><fa :icon="faPlus"/> {{ $t('addRelay') }}</div>
|
||||
<div class="_content">
|
||||
<mk-input v-model="inbox">
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card relays">
|
||||
<section class="_card _vMargin relays">
|
||||
<div class="_title"><fa :icon="faProjectDiagram"/> {{ $t('addedRelays') }}</div>
|
||||
<div class="_content relay" v-for="relay in relays" :key="relay.inbox">
|
||||
<div>{{ relay.inbox }}</div>
|
||||
|
@@ -3,7 +3,7 @@
|
||||
<portal to="icon"><fa :icon="faCog"/></portal>
|
||||
<portal to="title">{{ $t('settings') }}</portal>
|
||||
|
||||
<section class="_card info">
|
||||
<section class="_card _vMargin info">
|
||||
<div class="_title"><fa :icon="faInfoCircle"/> {{ $t('basicInfo') }}</div>
|
||||
<div class="_content">
|
||||
<mk-input v-model="name">{{ $t('instanceName') }}</mk-input>
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card info">
|
||||
<section class="_card _vMargin info">
|
||||
<div class="_content">
|
||||
<mk-input v-model="maxNoteTextLength" type="number" :save="() => save()" style="margin:0;"><template #icon><fa :icon="faPencilAlt"/></template>{{ $t('maxNoteTextLength') }}</mk-input>
|
||||
</div>
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card info">
|
||||
<section class="_card _vMargin info">
|
||||
<div class="_title"><fa :icon="faUser"/> {{ $t('registration') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="enableRegistration" @change="save()">{{ $t('enableRegistration') }}</mk-switch>
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faShieldAlt"/> {{ $t('hcaptcha') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="enableHcaptcha" ref="enableHcaptcha">{{ $t('enableHcaptcha') }}</mk-switch>
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="enableRecaptcha" ref="enableRecaptcha">{{ $t('enableRecaptcha') }}</mk-switch>
|
||||
@@ -77,7 +77,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faEnvelope" /> {{ $t('emailConfig') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="enableEmail" @change="save()">{{ $t('enableEmail') }}<template #desc>{{ $t('emailConfigInfo') }}</template></mk-switch>
|
||||
@@ -100,7 +100,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faBolt"/> {{ $t('serviceworker') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="enableServiceWorker">{{ $t('enableServiceworker') }}<template #desc>{{ $t('serviceworkerInfo') }}</template></mk-switch>
|
||||
@@ -116,7 +116,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faThumbtack"/> {{ $t('pinnedUsers') }}</div>
|
||||
<div class="_content">
|
||||
<mk-textarea v-model="pinnedUsers">
|
||||
@@ -128,7 +128,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faCloud"/> {{ $t('files') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="cacheRemoteFiles">{{ $t('cacheRemoteFiles') }}<template #desc>{{ $t('cacheRemoteFilesDescription') }}</template></mk-switch>
|
||||
@@ -141,7 +141,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faCloud"/> {{ $t('objectStorage') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="useObjectStorage">{{ $t('useObjectStorage') }}</mk-switch>
|
||||
@@ -161,6 +161,7 @@
|
||||
</div>
|
||||
<mk-switch v-model="objectStorageUseSSL" :disabled="!useObjectStorage">{{ $t('objectStorageUseSSL') }}<template #desc>{{ $t('objectStorageUseSSLDesc') }}</template></mk-switch>
|
||||
<mk-switch v-model="objectStorageUseProxy" :disabled="!useObjectStorage">{{ $t('objectStorageUseProxy') }}<template #desc>{{ $t('objectStorageUseProxyDesc') }}</template></mk-switch>
|
||||
<mk-switch v-model="objectStorageSetPublicRead" :disabled="!useObjectStorage">{{ $t('objectStorageSetPublicRead') }}</mk-switch>
|
||||
</template>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
@@ -168,7 +169,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faGhost"/> {{ $t('proxyAccount') }}</div>
|
||||
<div class="_content">
|
||||
<mk-input :value="proxyAccount ? proxyAccount.username : null" style="margin: 0;" disabled><template #prefix>@</template>{{ $t('proxyAccount') }}<template #desc>{{ $t('proxyAccountDescription') }}</template></mk-input>
|
||||
@@ -176,7 +177,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faBan"/> {{ $t('blockedInstances') }}</div>
|
||||
<div class="_content">
|
||||
<mk-textarea v-model="blockedHosts">
|
||||
@@ -188,7 +189,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div>
|
||||
<div class="_content">
|
||||
<header><fa :icon="faTwitter"/> Twitter</header>
|
||||
@@ -221,7 +222,8 @@
|
||||
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="_card">
|
||||
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faArchway" /> Summaly Proxy</div>
|
||||
<div class="_content">
|
||||
<mk-input v-model="summalyProxy">URL</mk-input>
|
||||
@@ -305,6 +307,7 @@ export default defineComponent({
|
||||
objectStorageSecretKey: null,
|
||||
objectStorageUseSSL: false,
|
||||
objectStorageUseProxy: false,
|
||||
objectStorageSetPublicRead: false,
|
||||
enableTwitterIntegration: false,
|
||||
twitterConsumerKey: null,
|
||||
twitterConsumerSecret: null,
|
||||
@@ -372,6 +375,7 @@ export default defineComponent({
|
||||
this.objectStorageSecretKey = this.meta.objectStorageSecretKey;
|
||||
this.objectStorageUseSSL = this.meta.objectStorageUseSSL;
|
||||
this.objectStorageUseProxy = this.meta.objectStorageUseProxy;
|
||||
this.objectStorageSetPublicRead = this.meta.objectStorageSetPublicRead;
|
||||
this.enableTwitterIntegration = this.meta.enableTwitterIntegration;
|
||||
this.twitterConsumerKey = this.meta.twitterConsumerKey;
|
||||
this.twitterConsumerSecret = this.meta.twitterConsumerSecret;
|
||||
@@ -521,6 +525,7 @@ export default defineComponent({
|
||||
objectStorageSecretKey: this.objectStorageSecretKey ? this.objectStorageSecretKey : null,
|
||||
objectStorageUseSSL: this.objectStorageUseSSL,
|
||||
objectStorageUseProxy: this.objectStorageUseProxy,
|
||||
objectStorageSetPublicRead: this.objectStorageSetPublicRead,
|
||||
enableTwitterIntegration: this.enableTwitterIntegration,
|
||||
twitterConsumerKey: this.twitterConsumerKey,
|
||||
twitterConsumerSecret: this.twitterConsumerSecret,
|
||||
|
@@ -3,7 +3,7 @@
|
||||
<portal to="icon"><fa :icon="faUsers"/></portal>
|
||||
<portal to="title">{{ $t('users') }}</portal>
|
||||
|
||||
<section class="_card lookup">
|
||||
<section class="_card _vMargin lookup">
|
||||
<div class="_title"><fa :icon="faSearch"/> {{ $t('lookup') }}</div>
|
||||
<div class="_content">
|
||||
<mk-input class="target" v-model="target" type="text" @enter="showUser()">
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card users">
|
||||
<section class="_card _vMargin users">
|
||||
<div class="_title"><fa :icon="faUsers"/> {{ $t('users') }}</div>
|
||||
<div class="_content">
|
||||
<div class="inputs" style="display: flex;">
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="mk-messaging" v-size="[{ max: 400 }]">
|
||||
<div class="mk-messaging" v-size="{ max: [400] }">
|
||||
<portal to="icon"><fa :icon="faComments"/></portal>
|
||||
<portal to="title">{{ $t('messaging') }}</portal>
|
||||
|
||||
|
@@ -53,7 +53,7 @@ export default defineComponent({
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
draftId(): string {
|
||||
draftKey(): string {
|
||||
return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
|
||||
},
|
||||
canSend(): boolean {
|
||||
@@ -79,7 +79,7 @@ export default defineComponent({
|
||||
autosize(this.$refs.text);
|
||||
|
||||
// 書きかけの投稿を復元
|
||||
const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftId];
|
||||
const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey];
|
||||
if (draft) {
|
||||
this.text = draft.data.text;
|
||||
this.file = draft.data.file;
|
||||
@@ -199,7 +199,7 @@ export default defineComponent({
|
||||
saveDraft() {
|
||||
const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
data[this.draftId] = {
|
||||
data[this.draftKey] = {
|
||||
updatedAt: new Date(),
|
||||
data: {
|
||||
text: this.text,
|
||||
@@ -213,7 +213,7 @@ export default defineComponent({
|
||||
deleteDraft() {
|
||||
const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
delete data[this.draftId];
|
||||
delete data[this.draftKey];
|
||||
|
||||
localStorage.setItem('message_drafts', JSON.stringify(data));
|
||||
},
|
||||
|
@@ -1,22 +1,22 @@
|
||||
<template>
|
||||
<div v-if="$store.getters.isSignedIn">
|
||||
<div class="waiting _card" v-if="state == 'waiting'">
|
||||
<div class="waiting _card _vMargin" v-if="state == 'waiting'">
|
||||
<div class="_content">
|
||||
<mk-loading/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="denied _card" v-if="state == 'denied'">
|
||||
<div class="denied _card _vMargin" v-if="state == 'denied'">
|
||||
<div class="_content">
|
||||
<p>{{ $t('_auth.denied') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accepted _card" v-else-if="state == 'accepted'">
|
||||
<div class="accepted _card _vMargin" v-else-if="state == 'accepted'">
|
||||
<div class="_content">
|
||||
<p v-if="callback">{{ $t('_auth.callback') }}<mk-ellipsis/></p>
|
||||
<p v-else>{{ $t('_auth.pleaseGoBack') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_card" v-else>
|
||||
<div class="_card _vMargin" v-else>
|
||||
<div class="_title" v-if="name">{{ $t('_auth.shareAccess', { name: name }) }}</div>
|
||||
<div class="_title" v-else>{{ $t('_auth.shareAccessAsk') }}</div>
|
||||
<div class="_content">
|
||||
|
@@ -4,7 +4,7 @@
|
||||
<portal to="title">{{ group.name }}</portal>
|
||||
|
||||
<transition name="zoom" mode="out-in">
|
||||
<div v-if="group" class="_card">
|
||||
<div v-if="group" class="_card _vMargin">
|
||||
<div class="_content">
|
||||
<mk-button inline @click="renameGroup()">{{ $t('rename') }}</mk-button>
|
||||
<mk-button inline @click="transfer()">{{ $t('transfer') }}</mk-button>
|
||||
@@ -14,7 +14,7 @@
|
||||
</transition>
|
||||
|
||||
<transition name="zoom" mode="out-in">
|
||||
<div v-if="group" class="_card members">
|
||||
<div v-if="group" class="_card members _vMargin">
|
||||
<div class="_title">{{ $t('members') }}</div>
|
||||
<div class="_content">
|
||||
<div class="users">
|
||||
|
@@ -4,7 +4,7 @@
|
||||
<portal to="title">{{ list.name }}</portal>
|
||||
|
||||
<transition name="zoom" mode="out-in">
|
||||
<div v-if="list" class="_card">
|
||||
<div v-if="list" class="_card _vMargin">
|
||||
<div class="_content">
|
||||
<mk-button inline @click="renameList()">{{ $t('rename') }}</mk-button>
|
||||
<mk-button inline @click="deleteList()">{{ $t('delete') }}</mk-button>
|
||||
@@ -13,7 +13,7 @@
|
||||
</transition>
|
||||
|
||||
<transition name="zoom" mode="out-in">
|
||||
<div v-if="list" class="_card members">
|
||||
<div v-if="list" class="_card members _vMargin">
|
||||
<div class="_title">{{ $t('members') }}</div>
|
||||
<div class="_content">
|
||||
<div class="users">
|
||||
|
@@ -3,11 +3,11 @@
|
||||
<portal to="icon"><fa :icon="faCog"/></portal>
|
||||
<portal to="title">{{ $t('accountSettings') }}</portal>
|
||||
|
||||
<x-profile-setting/>
|
||||
<x-privacy-setting/>
|
||||
<x-reaction-setting/>
|
||||
<x-profile-setting class="_vMargin"/>
|
||||
<x-privacy-setting class="_vMargin"/>
|
||||
<x-reaction-setting class="_vMargin"/>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch">
|
||||
@@ -24,14 +24,14 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<x-import-export/>
|
||||
<x-drive/>
|
||||
<x-mute-block/>
|
||||
<x-word-mute/>
|
||||
<x-security/>
|
||||
<x-2fa/>
|
||||
<x-integration/>
|
||||
<x-api/>
|
||||
<x-import-export class="_vMargin"/>
|
||||
<x-drive class="_vMargin"/>
|
||||
<x-mute-block class="_vMargin"/>
|
||||
<x-word-mute class="_vMargin"/>
|
||||
<x-security class="_vMargin"/>
|
||||
<x-2fa class="_vMargin"/>
|
||||
<x-integration class="_vMargin"/>
|
||||
<x-api class="_vMargin"/>
|
||||
|
||||
<router-link class="_panel _buttonPrimary" to="/my/apps" style="margin: var(--margin) auto;">{{ $t('installedApps') }}</router-link>
|
||||
|
||||
|
@@ -13,10 +13,11 @@
|
||||
</div>
|
||||
<div class="_content" v-show="tab === 'hard'">
|
||||
<mk-info>{{ $t('_wordMute.hardDescription') }}</mk-info>
|
||||
<mk-textarea v-model="hardMutedWords">
|
||||
<mk-textarea v-model="hardMutedWords" style="margin-bottom: 16px;">
|
||||
<span>{{ $t('_wordMute.muteWords') }}</span>
|
||||
<template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template>
|
||||
</mk-textarea>
|
||||
<div v-if="hardWordMutedNotesCount != null" class="_caption">{{ $t('_wordMute.mutedNotes') }}: {{ hardWordMutedNotesCount | number }}</div>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button @click="save()" primary inline :disabled="!changed"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
|
||||
@@ -45,6 +46,7 @@ export default defineComponent({
|
||||
tab: 'soft',
|
||||
softMutedWords: '',
|
||||
hardMutedWords: '',
|
||||
hardWordMutedNotesCount: null,
|
||||
changed: false,
|
||||
faCommentSlash, faSave,
|
||||
}
|
||||
@@ -59,9 +61,11 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
async created() {
|
||||
this.softMutedWords = this.$store.state.settings.mutedWords.map(x => x.join(' ')).join('\n');
|
||||
this.hardMutedWords = this.$store.state.i.mutedWords.map(x => x.join(' ')).join('\n');
|
||||
|
||||
this.hardWordMutedNotesCount = (await this.$root.api('i/get-word-muted-notes-count', {})).count;
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
@@ -5,13 +5,13 @@
|
||||
|
||||
<router-link v-if="$store.getters.isSignedIn" class="_panel _buttonPrimary" to="/my/settings" style="margin-bottom: var(--margin);">{{ $t('accountSettings') }}</router-link>
|
||||
|
||||
<x-theme/>
|
||||
<x-theme class="_vMargin"/>
|
||||
|
||||
<x-sidebar/>
|
||||
<x-sidebar class="_vMargin"/>
|
||||
|
||||
<x-plugins/>
|
||||
<x-plugins class="_vMargin"/>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faMusic"/> {{ $t('sounds') }}</div>
|
||||
<div class="_content">
|
||||
<mk-range v-model="sfxVolume" :min="0" :max="1" :step="0.1">
|
||||
@@ -50,10 +50,15 @@
|
||||
<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
|
||||
<template #text><button class="_textButton" @click="listen(sfxAntenna)" v-if="sfxAntenna"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
|
||||
</mk-select>
|
||||
<mk-select v-model="sfxChannel">
|
||||
<template #label>{{ $t('_sfx.channel') }}</template>
|
||||
<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
|
||||
<template #text><button class="_textButton" @click="listen(sfxChannel)" v-if="sfxChannel"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
|
||||
</mk-select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faColumns"/> {{ $t('deck') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="deckAlwaysShowMainColumn">
|
||||
@@ -67,7 +72,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faCog"/> {{ $t('appearance') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="disableAnimatedMfm">{{ $t('disableAnimatedMfm') }}</mk-switch>
|
||||
@@ -87,12 +92,13 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="autoReload">
|
||||
{{ $t('autoReloadWhenDisconnected') }}
|
||||
</mk-switch>
|
||||
<div>{{ $t('whenServerDisconnected') }}</div>
|
||||
<mk-radio v-model="serverDisconnectedBehavior" value="reload">{{ $t('_serverDisconnectedBehavior.reload') }}</mk-radio>
|
||||
<mk-radio v-model="serverDisconnectedBehavior" value="dialog">{{ $t('_serverDisconnectedBehavior.dialog') }}</mk-radio>
|
||||
<mk-radio v-model="serverDisconnectedBehavior" value="quiet">{{ $t('_serverDisconnectedBehavior.quiet') }}</mk-radio>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="imageNewTab">{{ $t('openImageInNewTab') }}</mk-switch>
|
||||
@@ -142,10 +148,14 @@ const sounds = [
|
||||
'syuilo/pirori',
|
||||
'syuilo/pirori-wet',
|
||||
'syuilo/pirori-square-wet',
|
||||
'syuilo/square-pico',
|
||||
'syuilo/reverved',
|
||||
'syuilo/ryukyu',
|
||||
'aisha/1',
|
||||
'aisha/2',
|
||||
'aisha/3',
|
||||
'noizenecio/kick_gaba',
|
||||
'noizenecio/kick_gaba2',
|
||||
];
|
||||
|
||||
export default defineComponent({
|
||||
@@ -177,9 +187,9 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
computed: {
|
||||
autoReload: {
|
||||
get() { return this.$store.state.device.autoReload; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'autoReload', value }); }
|
||||
serverDisconnectedBehavior: {
|
||||
get() { return this.$store.state.device.serverDisconnectedBehavior; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'serverDisconnectedBehavior', value }); }
|
||||
},
|
||||
|
||||
reduceAnimation: {
|
||||
@@ -272,6 +282,11 @@ export default defineComponent({
|
||||
set(value) { this.$store.commit('device/set', { key: 'sfxAntenna', value }); }
|
||||
},
|
||||
|
||||
sfxChannel: {
|
||||
get() { return this.$store.state.device.sfxChannel; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'sfxChannel', value }); }
|
||||
},
|
||||
|
||||
volumeIcon: {
|
||||
get() {
|
||||
return this.sfxVolume === 0 ? faVolumeMute : faVolumeUp;
|
||||
|
@@ -8,7 +8,7 @@
|
||||
/>
|
||||
</portal>
|
||||
|
||||
<div class="controller _card" v-if="objectSelected">
|
||||
<div class="controller _card _vMargin" v-if="objectSelected">
|
||||
<div class="_content">
|
||||
<p class="name">{{ selectedFurnitureName }}</p>
|
||||
<x-preview ref="preview"/>
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu _card" v-if="isMyRoom">
|
||||
<div class="menu _card _vMargin" v-if="isMyRoom">
|
||||
<div class="_content">
|
||||
<mk-button @click="add()"><fa :icon="faBoxOpen"/> {{ $t('_rooms.addFurniture') }}</mk-button>
|
||||
</div>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="kjeftjfm" v-size="[{ max: 500 }]">
|
||||
<div class="kjeftjfm" v-size="{ max: [500] }">
|
||||
<div class="with">
|
||||
<button class="_button" @click="with_ = null" :class="{ active: with_ === null }">{{ $t('notes') }}</button>
|
||||
<button class="_button" @click="with_ = 'replies'" :class="{ active: with_ === 'replies' }">{{ $t('notesAndReplies') }}</button>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="mk-user-page" v-if="user" v-size="[{ max: 500 }]">
|
||||
<div class="mk-user-page" v-if="user" v-size="{ max: [500] }">
|
||||
<portal to="title" v-if="user"><mk-user-name :user="user" :nowrap="false" class="name"/></portal>
|
||||
<portal to="avatar" v-if="user"><mk-avatar class="avatar" :user="user" :disable-preview="true"/></portal>
|
||||
|
||||
|
@@ -27,6 +27,10 @@ export const router = createRouter({
|
||||
{ path: '/explore', component: page('explore') },
|
||||
{ path: '/explore/tags/:tag', props: true, component: page('explore') },
|
||||
{ path: '/search', component: page('search') },
|
||||
{ path: '/channels', component: page('channels') },
|
||||
{ path: '/channels/new', component: page('channel-editor') },
|
||||
{ path: '/channels/:channelId/edit', component: page('channel-editor'), props: true },
|
||||
{ path: '/channels/:channelId', component: page('channel'), props: true },
|
||||
{ path: '/my/notifications', component: page('notifications') },
|
||||
{ path: '/my/favorites', component: page('favorites') },
|
||||
{ path: '/my/messages', component: page('messages') },
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { getScrollPosition, onScrollTop } from './scroll';
|
||||
import { onScrollTop, isTopVisible } from './scroll';
|
||||
|
||||
const SECOND_FETCH_LIMIT = 30;
|
||||
|
||||
@@ -147,7 +147,7 @@ export default (opts) => ({
|
||||
},
|
||||
|
||||
prepend(item) {
|
||||
const isTop = this.isBackTop || (document.body.contains(this.$el) && (getScrollPosition(this.$el) === 0));
|
||||
const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el));
|
||||
|
||||
if (isTop) {
|
||||
// Prepend the item
|
||||
|
@@ -13,14 +13,20 @@ export function getScrollPosition(el: Element | null): number {
|
||||
return container == null ? window.scrollY : container.scrollTop;
|
||||
}
|
||||
|
||||
export function isTopVisible(el: Element | null): boolean {
|
||||
const scrollTop = getScrollPosition(el);
|
||||
const topPosition = el.offsetTop; // TODO: container内でのelの相対位置を取得できればより正確になる
|
||||
|
||||
return scrollTop <= topPosition;
|
||||
}
|
||||
|
||||
export function onScrollTop(el: Element, cb) {
|
||||
const container = getScrollContainer(el) || window;
|
||||
const onScroll = ev => {
|
||||
if (!document.body.contains(el)) return;
|
||||
const pos = getScrollPosition(el);
|
||||
if (pos === 0) {
|
||||
if (isTopVisible(el)) {
|
||||
cb();
|
||||
container.removeEventListener('scroll', onscroll);
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
};
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
@@ -33,7 +39,7 @@ export function onScrollBottom(el: Element, cb) {
|
||||
const pos = getScrollPosition(el);
|
||||
if (pos + el.clientHeight > el.scrollHeight - 1) {
|
||||
cb();
|
||||
container.removeEventListener('scroll', onscroll);
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
};
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { createStore } from 'vuex';
|
||||
import createPersistedState from 'vuex-persistedstate';
|
||||
import * as nestedProperty from 'nested-property';
|
||||
import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faSatelliteDish, faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faBell, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons';
|
||||
import { AiScript, utils, values } from '@syuilo/aiscript';
|
||||
import { apiUrl, deckmode } from './config';
|
||||
@@ -60,7 +60,7 @@ export const defaultDeviceSettings = {
|
||||
loadRawImages: false,
|
||||
alwaysShowNsfw: false,
|
||||
useOsNativeEmojis: false,
|
||||
autoReload: false,
|
||||
serverDisconnectedBehavior: 'quiet',
|
||||
accounts: [],
|
||||
recentEmojis: [],
|
||||
themes: [],
|
||||
@@ -90,6 +90,7 @@ export const defaultDeviceSettings = {
|
||||
sfxChat: 'syuilo/pope1',
|
||||
sfxChatBg: 'syuilo/waon',
|
||||
sfxAntenna: 'syuilo/triple',
|
||||
sfxChannel: 'syuilo/square-pico',
|
||||
userData: {},
|
||||
};
|
||||
|
||||
@@ -113,6 +114,7 @@ export const store = createStore({
|
||||
text: string;
|
||||
result: any;
|
||||
}[],
|
||||
fullView: false,
|
||||
|
||||
// Plugin
|
||||
pluginContexts: new Map<string, AiScript>(),
|
||||
@@ -219,6 +221,11 @@ export const store = createStore({
|
||||
get show() { return getters.isSignedIn; },
|
||||
to: '/my/pages',
|
||||
},
|
||||
channels: {
|
||||
title: 'channel',
|
||||
icon: faSatelliteDish,
|
||||
to: '/channels',
|
||||
},
|
||||
games: {
|
||||
title: 'games',
|
||||
icon: faGamepad,
|
||||
@@ -259,6 +266,10 @@ export const store = createStore({
|
||||
state.dialogs.push(dialog);
|
||||
},
|
||||
|
||||
setFullView(state, v) {
|
||||
state.fullView = v;
|
||||
},
|
||||
|
||||
initPlugin(state, { plugin, aiscript }) {
|
||||
state.pluginContexts.set(plugin.id, aiscript);
|
||||
},
|
||||
|
@@ -329,10 +329,6 @@ hr {
|
||||
._card {
|
||||
@extend ._panel;
|
||||
|
||||
& + ._card {
|
||||
margin-top: var(--margin);
|
||||
}
|
||||
|
||||
> ._title {
|
||||
margin: 0;
|
||||
padding: 22px 32px;
|
||||
@@ -389,6 +385,40 @@ hr {
|
||||
}
|
||||
}
|
||||
|
||||
._vMargin {
|
||||
& + ._vMargin {
|
||||
margin-top: var(--margin);
|
||||
}
|
||||
}
|
||||
|
||||
._table {
|
||||
> ._row {
|
||||
display: flex;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> ._cell {
|
||||
flex: 1;
|
||||
|
||||
> ._label {
|
||||
font-size: 80%;
|
||||
opacity: 0.7;
|
||||
|
||||
> ._icon {
|
||||
margin-right: 4px;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
._fullinfo {
|
||||
padding: 64px 32px;
|
||||
text-align: center;
|
||||
@@ -404,7 +434,7 @@ hr {
|
||||
._keyValue {
|
||||
display: flex;
|
||||
|
||||
> div {
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -413,6 +443,11 @@ hr {
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
._caption {
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.zoom-enter-active, .zoom-leave-active {
|
||||
transition: opacity 0.5s, transform 0.5s !important;
|
||||
}
|
||||
|
@@ -72,5 +72,6 @@
|
||||
X12: 'rgba(255, 255, 255, 0.1)',
|
||||
X13: 'rgba(255, 255, 255, 0.15)',
|
||||
X14: ':alpha<0.5<@navBg',
|
||||
X15: ':alpha<0<@panel',
|
||||
},
|
||||
}
|
||||
|
@@ -72,5 +72,6 @@
|
||||
X12: 'rgba(0, 0, 0, 0.1)',
|
||||
X13: 'rgba(0, 0, 0, 0.15)',
|
||||
X14: ':alpha<0.5<@navBg',
|
||||
X15: ':alpha<0<@panel',
|
||||
},
|
||||
}
|
||||
|
@@ -8,7 +8,8 @@ export default function <T extends Form>(data: {
|
||||
return defineComponent({
|
||||
props: {
|
||||
widget: {
|
||||
type: Object
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
isCustomizeMode: {
|
||||
type: Boolean,
|
||||
@@ -18,11 +19,11 @@ export default function <T extends Form>(data: {
|
||||
|
||||
computed: {
|
||||
id(): string {
|
||||
return this.widget.id;
|
||||
return this.widget ? this.widget.id : null;
|
||||
},
|
||||
|
||||
props(): Record<string, any> {
|
||||
return this.widget.data;
|
||||
return this.widget ? this.widget.data : {};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -61,7 +62,9 @@ export default function <T extends Form>(data: {
|
||||
},
|
||||
|
||||
save() {
|
||||
this.$store.commit('deviceUser/updateWidget', this.widget);
|
||||
if (this.widget) {
|
||||
this.$store.commit('deviceUser/updateWidget', this.widget);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<mk-container :show-header="props.showHeader">
|
||||
<mk-container :show-header="props.showHeader" :body-togglable="bodyTogglable" :scrollable="scrollable">
|
||||
<template #header><fa :icon="faGlobe"/>{{ $t('_widgets.federation') }}</template>
|
||||
|
||||
<div class="wbrkwalb">
|
||||
@@ -40,6 +40,18 @@ export default defineComponent({
|
||||
components: {
|
||||
MkContainer, MkMiniChart
|
||||
},
|
||||
props: {
|
||||
bodyTogglable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
instances: [],
|
||||
|
@@ -38,7 +38,7 @@ import { FollowRequest } from '../models/entities/follow-request';
|
||||
import { Emoji } from '../models/entities/emoji';
|
||||
import { ReversiGame } from '../models/entities/games/reversi/game';
|
||||
import { ReversiMatching } from '../models/entities/games/reversi/matching';
|
||||
import { UserNotePining } from '../models/entities/user-note-pinings';
|
||||
import { UserNotePining } from '../models/entities/user-note-pining';
|
||||
import { Poll } from '../models/entities/poll';
|
||||
import { UserKeypair } from '../models/entities/user-keypair';
|
||||
import { UserPublickey } from '../models/entities/user-publickey';
|
||||
@@ -60,6 +60,9 @@ import { PromoRead } from '../models/entities/promo-read';
|
||||
import { program } from '../argv';
|
||||
import { Relay } from '../models/entities/relay';
|
||||
import { MutedNote } from '../models/entities/muted-note';
|
||||
import { Channel } from '../models/entities/channel';
|
||||
import { ChannelFollowing } from '../models/entities/channel-following';
|
||||
import { ChannelNotePining } from '../models/entities/channel-note-pining';
|
||||
|
||||
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
|
||||
|
||||
@@ -153,6 +156,9 @@ export const entities = [
|
||||
ReversiMatching,
|
||||
Relay,
|
||||
MutedNote,
|
||||
Channel,
|
||||
ChannelFollowing,
|
||||
ChannelNotePining,
|
||||
...charts as any
|
||||
];
|
||||
|
||||
|
@@ -25,4 +25,6 @@ export const kinds = [
|
||||
'read:page-likes',
|
||||
'read:user-groups',
|
||||
'write:user-groups',
|
||||
'read:channels',
|
||||
'write:channels',
|
||||
];
|
||||
|
43
src/models/entities/channel-following.ts
Normal file
43
src/models/entities/channel-following.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
import { Channel } from './channel';
|
||||
|
||||
@Entity()
|
||||
@Index(['followerId', 'followeeId'], { unique: true })
|
||||
export class ChannelFollowing {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the ChannelFollowing.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The followee channel ID.'
|
||||
})
|
||||
public followeeId: Channel['id'];
|
||||
|
||||
@ManyToOne(type => Channel, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public followee: Channel | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The follower user ID.'
|
||||
})
|
||||
public followerId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public follower: User | null;
|
||||
}
|
35
src/models/entities/channel-note-pining.ts
Normal file
35
src/models/entities/channel-note-pining.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { Note } from './note';
|
||||
import { Channel } from './channel';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
@Index(['channelId', 'noteId'], { unique: true })
|
||||
export class ChannelNotePining {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the ChannelNotePining.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public channelId: Channel['id'];
|
||||
|
||||
@ManyToOne(type => Channel, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public channel: Channel | null;
|
||||
|
||||
@Column(id())
|
||||
public noteId: Note['id'];
|
||||
|
||||
@ManyToOne(type => Note, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public note: Note | null;
|
||||
}
|
74
src/models/entities/channel.ts
Normal file
74
src/models/entities/channel.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
import { DriveFile } from './drive-file';
|
||||
|
||||
@Entity()
|
||||
export class Channel {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the Channel.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
nullable: true
|
||||
})
|
||||
public lastNotedAt: Date | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The owner ID.'
|
||||
})
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'SET NULL'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
comment: 'The name of the Channel.'
|
||||
})
|
||||
public name: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 2048, nullable: true,
|
||||
comment: 'The description of the Channel.'
|
||||
})
|
||||
public description: string | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
comment: 'The ID of banner Channel.'
|
||||
})
|
||||
public bannerId: DriveFile['id'] | null;
|
||||
|
||||
@ManyToOne(type => DriveFile, {
|
||||
onDelete: 'SET NULL'
|
||||
})
|
||||
@JoinColumn()
|
||||
public banner: DriveFile | null;
|
||||
|
||||
@Index()
|
||||
@Column('integer', {
|
||||
default: 0,
|
||||
comment: 'The count of notes.'
|
||||
})
|
||||
public notesCount: number;
|
||||
|
||||
@Index()
|
||||
@Column('integer', {
|
||||
default: 0,
|
||||
comment: 'The count of users.'
|
||||
})
|
||||
public usersCount: number;
|
||||
}
|
@@ -145,6 +145,7 @@ export class DriveFile {
|
||||
@JoinColumn()
|
||||
public folder: DriveFolder | null;
|
||||
|
||||
@Index()
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
comment: 'Whether the DriveFile is NSFW.'
|
||||
|
@@ -370,4 +370,9 @@ export class Meta {
|
||||
default: true,
|
||||
})
|
||||
public objectStorageUseProxy: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public objectStorageSetPublicRead: boolean;
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typ
|
||||
import { User } from './user';
|
||||
import { Note } from './note';
|
||||
import { id } from '../id';
|
||||
import { Channel } from './channel';
|
||||
|
||||
@Entity()
|
||||
@Index(['userId', 'noteId'], { unique: true })
|
||||
@@ -29,15 +30,34 @@ export class NoteUnread {
|
||||
@JoinColumn()
|
||||
public note: Note | null;
|
||||
|
||||
/**
|
||||
* メンションか否か
|
||||
*/
|
||||
@Index()
|
||||
@Column('boolean')
|
||||
public isMentioned: boolean;
|
||||
|
||||
/**
|
||||
* ダイレクト投稿か否か
|
||||
*/
|
||||
@Index()
|
||||
@Column('boolean')
|
||||
public isSpecified: boolean;
|
||||
|
||||
//#region Denormalized fields
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public noteUserId: User['id'];
|
||||
|
||||
/**
|
||||
* ダイレクト投稿か
|
||||
*/
|
||||
@Column('boolean')
|
||||
public isSpecified: boolean;
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public noteChannelId: Channel['id'] | null;
|
||||
//#endregion
|
||||
}
|
||||
|
@@ -3,7 +3,7 @@ import { User } from './user';
|
||||
import { DriveFile } from './drive-file';
|
||||
import { id } from '../id';
|
||||
import { noteVisibilities } from '../../types';
|
||||
|
||||
import { Channel } from './channel';
|
||||
|
||||
@Entity()
|
||||
@Index('IDX_NOTE_TAGS', { synchronize: false })
|
||||
@@ -173,6 +173,20 @@ export class Note {
|
||||
})
|
||||
public hasPoll: boolean;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true, default: null,
|
||||
comment: 'The ID of source channel.'
|
||||
})
|
||||
public channelId: Channel['id'] | null;
|
||||
|
||||
@ManyToOne(type => Channel, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public channel: Channel | null;
|
||||
|
||||
//#region Denormalized fields
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
|
@@ -15,7 +15,7 @@ import { DriveFileRepository } from './repositories/drive-file';
|
||||
import { DriveFolderRepository } from './repositories/drive-folder';
|
||||
import { Log } from './entities/log';
|
||||
import { AccessToken } from './entities/access-token';
|
||||
import { UserNotePining } from './entities/user-note-pinings';
|
||||
import { UserNotePining } from './entities/user-note-pining';
|
||||
import { SigninRepository } from './repositories/signin';
|
||||
import { MessagingMessageRepository } from './repositories/messaging-message';
|
||||
import { ReversiGameRepository } from './repositories/games/reversi/game';
|
||||
@@ -53,7 +53,10 @@ import { PromoNote } from './entities/promo-note';
|
||||
import { PromoRead } from './entities/promo-read';
|
||||
import { EmojiRepository } from './repositories/emoji';
|
||||
import { RelayRepository } from './repositories/relay';
|
||||
import { ChannelRepository } from './repositories/channel';
|
||||
import { MutedNote } from './entities/muted-note';
|
||||
import { ChannelFollowing } from './entities/channel-following';
|
||||
import { ChannelNotePining } from './entities/channel-note-pining';
|
||||
|
||||
export const Announcements = getRepository(Announcement);
|
||||
export const AnnouncementReads = getRepository(AnnouncementRead);
|
||||
@@ -110,3 +113,6 @@ export const PromoNotes = getRepository(PromoNote);
|
||||
export const PromoReads = getRepository(PromoRead);
|
||||
export const Relays = getCustomRepository(RelayRepository);
|
||||
export const MutedNotes = getRepository(MutedNote);
|
||||
export const Channels = getCustomRepository(ChannelRepository);
|
||||
export const ChannelFollowings = getRepository(ChannelFollowing);
|
||||
export const ChannelNotePinings = getRepository(ChannelNotePining);
|
||||
|
101
src/models/repositories/channel.ts
Normal file
101
src/models/repositories/channel.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { EntityRepository, Repository } from 'typeorm';
|
||||
import { Channel } from '../entities/channel';
|
||||
import { ensure } from '../../prelude/ensure';
|
||||
import { SchemaType } from '../../misc/schema';
|
||||
import { DriveFiles, ChannelFollowings, NoteUnreads } from '..';
|
||||
import { User } from '../entities/user';
|
||||
|
||||
export type PackedChannel = SchemaType<typeof packedChannelSchema>;
|
||||
|
||||
@EntityRepository(Channel)
|
||||
export class ChannelRepository extends Repository<Channel> {
|
||||
public async pack(
|
||||
src: Channel['id'] | Channel,
|
||||
me?: User['id'] | User | null | undefined,
|
||||
): Promise<PackedChannel> {
|
||||
const channel = typeof src === 'object' ? src : await this.findOne(src).then(ensure);
|
||||
const meId = me ? typeof me === 'string' ? me : me.id : null;
|
||||
|
||||
const banner = channel.bannerId ? await DriveFiles.findOne(channel.bannerId) : null;
|
||||
|
||||
const hasUnreadNote = me ? (await NoteUnreads.findOne({ noteChannelId: channel.id, userId: meId })) != null : undefined;
|
||||
|
||||
const following = await ChannelFollowings.findOne({
|
||||
followerId: meId,
|
||||
followeeId: channel.id,
|
||||
});
|
||||
|
||||
return {
|
||||
id: channel.id,
|
||||
createdAt: channel.createdAt.toISOString(),
|
||||
lastNotedAt: channel.lastNotedAt ? channel.lastNotedAt.toISOString() : null,
|
||||
name: channel.name,
|
||||
description: channel.description,
|
||||
userId: channel.userId,
|
||||
bannerUrl: banner ? DriveFiles.getPublicUrl(banner, false) : null,
|
||||
usersCount: channel.usersCount,
|
||||
notesCount: channel.notesCount,
|
||||
|
||||
...(me ? {
|
||||
isFollowing: following != null,
|
||||
hasUnreadNote,
|
||||
} : {})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const packedChannelSchema = {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
format: 'id',
|
||||
description: 'The unique identifier for this Channel.',
|
||||
example: 'xxxxxxxxxx',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
format: 'date-time',
|
||||
description: 'The date that the Channel was created.'
|
||||
},
|
||||
lastNotedAt: {
|
||||
type: 'string' as const,
|
||||
optional: false as const, nullable: true as const,
|
||||
format: 'date-time',
|
||||
},
|
||||
name: {
|
||||
type: 'string' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
description: 'The name of the Channel.'
|
||||
},
|
||||
description: {
|
||||
type: 'string' as const,
|
||||
nullable: true as const, optional: false as const,
|
||||
},
|
||||
bannerUrl: {
|
||||
type: 'string' as const,
|
||||
format: 'url',
|
||||
nullable: true as const, optional: false as const,
|
||||
},
|
||||
notesCount: {
|
||||
type: 'number' as const,
|
||||
nullable: false as const, optional: false as const,
|
||||
},
|
||||
usersCount: {
|
||||
type: 'number' as const,
|
||||
nullable: false as const, optional: false as const,
|
||||
},
|
||||
isFollowing: {
|
||||
type: 'boolean' as const,
|
||||
optional: true as const, nullable: false as const,
|
||||
},
|
||||
userId: {
|
||||
type: 'string' as const,
|
||||
nullable: false as const, optional: false as const,
|
||||
format: 'id',
|
||||
},
|
||||
},
|
||||
};
|
@@ -1,7 +1,7 @@
|
||||
import { EntityRepository, Repository, In } from 'typeorm';
|
||||
import { Note } from '../entities/note';
|
||||
import { User } from '../entities/user';
|
||||
import { Emojis, Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls } from '..';
|
||||
import { Emojis, Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls, Channels } from '..';
|
||||
import { ensure } from '../../prelude/ensure';
|
||||
import { SchemaType } from '../../misc/schema';
|
||||
import { awaitAll } from '../../prelude/await-all';
|
||||
@@ -207,6 +207,12 @@ export class NoteRepository extends Repository<Note> {
|
||||
text = `【${note.name}】\n${(note.text || '').trim()}\n\n${note.url || note.uri}`;
|
||||
}
|
||||
|
||||
const channel = note.channelId
|
||||
? note.channel
|
||||
? note.channel
|
||||
: await Channels.findOne(note.channelId)
|
||||
: null;
|
||||
|
||||
const packed = await awaitAll({
|
||||
id: note.id,
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
@@ -227,6 +233,11 @@ export class NoteRepository extends Repository<Note> {
|
||||
files: DriveFiles.packMany(note.fileIds),
|
||||
replyId: note.replyId,
|
||||
renoteId: note.renoteId,
|
||||
channelId: note.channelId || undefined,
|
||||
channel: channel ? {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
} : undefined,
|
||||
mentions: note.mentions.length > 0 ? note.mentions : undefined,
|
||||
uri: note.uri || undefined,
|
||||
url: note.url || undefined,
|
||||
@@ -391,6 +402,16 @@ export const packedNoteSchema = {
|
||||
type: 'object' as const,
|
||||
optional: true as const, nullable: true as const,
|
||||
},
|
||||
|
||||
channelId: {
|
||||
type: 'string' as const,
|
||||
optional: true as const, nullable: true as const,
|
||||
format: 'id',
|
||||
example: 'xxxxxxxxxx',
|
||||
},
|
||||
channel: {
|
||||
type: 'object' as const,
|
||||
optional: true as const, nullable: true as const,
|
||||
ref: 'Channel'
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import $ from 'cafy';
|
||||
import { EntityRepository, Repository, In, Not } from 'typeorm';
|
||||
import { User, ILocalUser, IRemoteUser } from '../entities/user';
|
||||
import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes } from '..';
|
||||
import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes, ChannelFollowings } from '..';
|
||||
import { ensure } from '../../prelude/ensure';
|
||||
import config from '../../config';
|
||||
import { SchemaType } from '../../misc/schema';
|
||||
@@ -107,6 +107,17 @@ export class UserRepository extends Repository<User> {
|
||||
return unread != null;
|
||||
}
|
||||
|
||||
public async getHasUnreadChannel(userId: User['id']): Promise<boolean> {
|
||||
const channels = await ChannelFollowings.find({ followerId: userId });
|
||||
|
||||
const unread = channels.length > 0 ? await NoteUnreads.findOne({
|
||||
userId: userId,
|
||||
noteChannelId: In(channels.map(x => x.id)),
|
||||
}) : null;
|
||||
|
||||
return unread != null;
|
||||
}
|
||||
|
||||
public async getHasUnreadNotification(userId: User['id']): Promise<boolean> {
|
||||
const mute = await Mutings.find({
|
||||
muterId: userId
|
||||
@@ -139,7 +150,6 @@ export class UserRepository extends Repository<User> {
|
||||
options?: {
|
||||
detail?: boolean,
|
||||
includeSecrets?: boolean,
|
||||
includeHasUnreadNotes?: boolean
|
||||
}
|
||||
): Promise<PackedUser> {
|
||||
const opts = Object.assign({
|
||||
@@ -181,17 +191,6 @@ export class UserRepository extends Repository<User> {
|
||||
select: ['name', 'host', 'url', 'aliases']
|
||||
}) : [],
|
||||
|
||||
...(opts.includeHasUnreadNotes ? {
|
||||
hasUnreadSpecifiedNotes: NoteUnreads.count({
|
||||
where: { userId: user.id, isSpecified: true },
|
||||
take: 1
|
||||
}).then(count => count > 0),
|
||||
hasUnreadMentions: NoteUnreads.count({
|
||||
where: { userId: user.id },
|
||||
take: 1
|
||||
}).then(count => count > 0),
|
||||
} : {}),
|
||||
|
||||
...(opts.detail ? {
|
||||
url: profile!.url,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
@@ -233,8 +232,17 @@ export class UserRepository extends Repository<User> {
|
||||
alwaysMarkNsfw: profile!.alwaysMarkNsfw,
|
||||
carefulBot: profile!.carefulBot,
|
||||
autoAcceptFollowed: profile!.autoAcceptFollowed,
|
||||
hasUnreadSpecifiedNotes: NoteUnreads.count({
|
||||
where: { userId: user.id, isSpecified: true },
|
||||
take: 1
|
||||
}).then(count => count > 0),
|
||||
hasUnreadMentions: NoteUnreads.count({
|
||||
where: { userId: user.id, isMentioned: true },
|
||||
take: 1
|
||||
}).then(count => count > 0),
|
||||
hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id),
|
||||
hasUnreadAntenna: this.getHasUnreadAntenna(user.id),
|
||||
hasUnreadChannel: this.getHasUnreadChannel(user.id),
|
||||
hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id),
|
||||
hasUnreadNotification: this.getHasUnreadNotification(user.id),
|
||||
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
|
||||
@@ -276,7 +284,6 @@ export class UserRepository extends Repository<User> {
|
||||
options?: {
|
||||
detail?: boolean,
|
||||
includeSecrets?: boolean,
|
||||
includeHasUnreadNotes?: boolean
|
||||
}
|
||||
) {
|
||||
return Promise.all(users.map(u => this.pack(u, me, options)));
|
||||
|
@@ -15,7 +15,7 @@ import { updateUsertags } from '../../../services/update-hashtag';
|
||||
import { Users, UserNotePinings, Instances, DriveFiles, Followings, UserProfiles, UserPublickeys } from '../../../models';
|
||||
import { User, IRemoteUser } from '../../../models/entities/user';
|
||||
import { Emoji } from '../../../models/entities/emoji';
|
||||
import { UserNotePining } from '../../../models/entities/user-note-pinings';
|
||||
import { UserNotePining } from '../../../models/entities/user-note-pining';
|
||||
import { genId } from '../../../misc/gen-id';
|
||||
import { instanceChart, usersChart } from '../../../services/chart';
|
||||
import { UserPublickey } from '../../../models/entities/user-publickey';
|
||||
|
@@ -49,7 +49,7 @@ export default async (user: ILocalUser, url: string, object: any) => {
|
||||
authorizationHeaderName: 'Signature',
|
||||
key: keypair.privateKey,
|
||||
keyId: `${config.url}/users/${user.id}#main-key`,
|
||||
headers: ['date', 'host', 'digest']
|
||||
headers: ['(request-target)', 'date', 'host', 'digest']
|
||||
});
|
||||
|
||||
req.on('timeout', () => req.abort());
|
||||
|
24
src/server/api/common/generate-channel-query.ts
Normal file
24
src/server/api/common/generate-channel-query.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { User } from '../../../models/entities/user';
|
||||
import { ChannelFollowings } from '../../../models';
|
||||
import { Brackets, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
export function generateChannelQuery(q: SelectQueryBuilder<any>, me?: User | null) {
|
||||
if (me == null) {
|
||||
q.andWhere('note.channelId IS NULL');
|
||||
} else {
|
||||
q.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
const channelFollowingQuery = ChannelFollowings.createQueryBuilder('channelFollowing')
|
||||
.select('channelFollowing.followeeId')
|
||||
.where('channelFollowing.followerId = :followerId', { followerId: me.id });
|
||||
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
// チャンネルのノートではない
|
||||
.where('note.channelId IS NULL')
|
||||
// または自分がフォローしているチャンネルのノート
|
||||
.orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(channelFollowingQuery.getParameters());
|
||||
}
|
||||
}
|
@@ -35,5 +35,8 @@ export default define(meta, async (ps, me) => {
|
||||
throw new Error('cannot show info of admin');
|
||||
}
|
||||
|
||||
return user;
|
||||
return {
|
||||
...user,
|
||||
token: user.token != null ? '<MASKED>' : user.token,
|
||||
};
|
||||
});
|
||||
|
@@ -418,6 +418,10 @@ export const meta = {
|
||||
|
||||
objectStorageUseProxy: {
|
||||
validator: $.optional.bool
|
||||
},
|
||||
|
||||
objectStorageSetPublicRead: {
|
||||
validator: $.optional.bool
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -673,6 +677,10 @@ export default define(meta, async (ps, me) => {
|
||||
set.objectStorageUseProxy = ps.objectStorageUseProxy;
|
||||
}
|
||||
|
||||
if (ps.objectStorageSetPublicRead !== undefined) {
|
||||
set.objectStorageSetPublicRead = ps.objectStorageSetPublicRead;
|
||||
}
|
||||
|
||||
await getConnection().transaction(async transactionalEntityManager => {
|
||||
const meta = await transactionalEntityManager.findOne(Meta, {
|
||||
order: {
|
||||
|
68
src/server/api/endpoints/channels/create.ts
Normal file
68
src/server/api/endpoints/channels/create.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import $ from 'cafy';
|
||||
import define from '../../define';
|
||||
import { ApiError } from '../../error';
|
||||
import { Channels, DriveFiles } from '../../../../models';
|
||||
import { Channel } from '../../../../models/entities/channel';
|
||||
import { genId } from '../../../../misc/gen-id';
|
||||
import { ID } from '../../../../misc/cafy-id';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels'],
|
||||
|
||||
requireCredential: true as const,
|
||||
|
||||
kind: 'write:channels',
|
||||
|
||||
params: {
|
||||
name: {
|
||||
validator: $.str.range(1, 128)
|
||||
},
|
||||
|
||||
description: {
|
||||
validator: $.nullable.optional.str.range(1, 2048)
|
||||
},
|
||||
|
||||
bannerId: {
|
||||
validator: $.nullable.optional.type(ID),
|
||||
}
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
ref: 'Channel',
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchFile: {
|
||||
message: 'No such file.',
|
||||
code: 'NO_SUCH_FILE',
|
||||
id: 'cd1e9f3e-5a12-4ab4-96f6-5d0a2cc32050'
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, user) => {
|
||||
let banner = null;
|
||||
if (ps.bannerId != null) {
|
||||
banner = await DriveFiles.findOne({
|
||||
id: ps.bannerId,
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
if (banner == null) {
|
||||
throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
}
|
||||
|
||||
const channel = await Channels.save({
|
||||
id: genId(),
|
||||
createdAt: new Date(),
|
||||
userId: user.id,
|
||||
name: ps.name,
|
||||
description: ps.description || null,
|
||||
bannerId: banner ? banner.id : null,
|
||||
} as Channel);
|
||||
|
||||
return await Channels.pack(channel, user);
|
||||
});
|
28
src/server/api/endpoints/channels/featured.ts
Normal file
28
src/server/api/endpoints/channels/featured.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import define from '../../define';
|
||||
import { Channels } from '../../../../models';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels'],
|
||||
|
||||
requireCredential: false as const,
|
||||
|
||||
res: {
|
||||
type: 'array' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
ref: 'Channel',
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, me) => {
|
||||
const query = Channels.createQueryBuilder('channel')
|
||||
.where('channel.lastNotedAt IS NOT NULL')
|
||||
.orderBy('channel.lastNotedAt', 'DESC');
|
||||
|
||||
const channels = await query.take(10).getMany();
|
||||
|
||||
return await Promise.all(channels.map(x => Channels.pack(x, me)));
|
||||
});
|
45
src/server/api/endpoints/channels/follow.ts
Normal file
45
src/server/api/endpoints/channels/follow.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import $ from 'cafy';
|
||||
import { ID } from '../../../../misc/cafy-id';
|
||||
import define from '../../define';
|
||||
import { ApiError } from '../../error';
|
||||
import { Channels, ChannelFollowings } from '../../../../models';
|
||||
import { genId } from '../../../../misc/gen-id';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels'],
|
||||
|
||||
requireCredential: true as const,
|
||||
|
||||
kind: 'write:channels',
|
||||
|
||||
params: {
|
||||
channelId: {
|
||||
validator: $.type(ID),
|
||||
},
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchChannel: {
|
||||
message: 'No such channel.',
|
||||
code: 'NO_SUCH_CHANNEL',
|
||||
id: 'c0031718-d573-4e85-928e-10039f1fbb68'
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, user) => {
|
||||
const channel = await Channels.findOne({
|
||||
id: ps.channelId,
|
||||
});
|
||||
|
||||
if (channel == null) {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
await ChannelFollowings.save({
|
||||
id: genId(),
|
||||
createdAt: new Date(),
|
||||
followerId: user.id,
|
||||
followeeId: channel.id,
|
||||
});
|
||||
});
|
28
src/server/api/endpoints/channels/followed.ts
Normal file
28
src/server/api/endpoints/channels/followed.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import define from '../../define';
|
||||
import { Channels, ChannelFollowings } from '../../../../models';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels', 'account'],
|
||||
|
||||
requireCredential: true as const,
|
||||
|
||||
kind: 'read:channels',
|
||||
|
||||
res: {
|
||||
type: 'array' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
ref: 'Channel',
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, me) => {
|
||||
const followings = await ChannelFollowings.find({
|
||||
followerId: me.id,
|
||||
});
|
||||
|
||||
return await Promise.all(followings.map(x => Channels.pack(x.followeeId, me)));
|
||||
});
|
28
src/server/api/endpoints/channels/owned.ts
Normal file
28
src/server/api/endpoints/channels/owned.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import define from '../../define';
|
||||
import { Channels } from '../../../../models';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels', 'account'],
|
||||
|
||||
requireCredential: true as const,
|
||||
|
||||
kind: 'read:channels',
|
||||
|
||||
res: {
|
||||
type: 'array' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
ref: 'Channel',
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, me) => {
|
||||
const channels = await Channels.find({
|
||||
userId: me.id,
|
||||
});
|
||||
|
||||
return await Promise.all(channels.map(x => Channels.pack(x, me)));
|
||||
});
|
0
src/server/api/endpoints/channels/pin-note.ts
Normal file
0
src/server/api/endpoints/channels/pin-note.ts
Normal file
43
src/server/api/endpoints/channels/show.ts
Normal file
43
src/server/api/endpoints/channels/show.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import $ from 'cafy';
|
||||
import { ID } from '../../../../misc/cafy-id';
|
||||
import define from '../../define';
|
||||
import { ApiError } from '../../error';
|
||||
import { Channels } from '../../../../models';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels'],
|
||||
|
||||
requireCredential: false as const,
|
||||
|
||||
params: {
|
||||
channelId: {
|
||||
validator: $.type(ID),
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
ref: 'Channel',
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchChannel: {
|
||||
message: 'No such channel.',
|
||||
code: 'NO_SUCH_CHANNEL',
|
||||
id: '6f6c314b-7486-4897-8966-c04a66a02923'
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, me) => {
|
||||
const channel = await Channels.findOne({
|
||||
id: ps.channelId,
|
||||
});
|
||||
|
||||
if (channel == null) {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
return await Channels.pack(channel, me);
|
||||
});
|
99
src/server/api/endpoints/channels/timeline.ts
Normal file
99
src/server/api/endpoints/channels/timeline.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import $ from 'cafy';
|
||||
import { ID } from '../../../../misc/cafy-id';
|
||||
import define from '../../define';
|
||||
import { ApiError } from '../../error';
|
||||
import { Notes, Channels } from '../../../../models';
|
||||
import { makePaginationQuery } from '../../common/make-pagination-query';
|
||||
import { activeUsersChart } from '../../../../services/chart';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes', 'channels'],
|
||||
|
||||
requireCredential: false as const,
|
||||
|
||||
params: {
|
||||
channelId: {
|
||||
validator: $.type(ID),
|
||||
desc: {
|
||||
'ja-JP': 'チャンネルのID'
|
||||
}
|
||||
},
|
||||
|
||||
limit: {
|
||||
validator: $.optional.num.range(1, 100),
|
||||
default: 10,
|
||||
desc: {
|
||||
'ja-JP': '最大数'
|
||||
}
|
||||
},
|
||||
|
||||
sinceId: {
|
||||
validator: $.optional.type(ID),
|
||||
desc: {
|
||||
'ja-JP': '指定すると、その投稿を基点としてより新しい投稿を取得します'
|
||||
}
|
||||
},
|
||||
|
||||
untilId: {
|
||||
validator: $.optional.type(ID),
|
||||
desc: {
|
||||
'ja-JP': '指定すると、その投稿を基点としてより古い投稿を取得します'
|
||||
}
|
||||
},
|
||||
|
||||
sinceDate: {
|
||||
validator: $.optional.num,
|
||||
desc: {
|
||||
'ja-JP': '指定した時間を基点としてより新しい投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。'
|
||||
}
|
||||
},
|
||||
|
||||
untilDate: {
|
||||
validator: $.optional.num,
|
||||
desc: {
|
||||
'ja-JP': '指定した時間を基点としてより古い投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。'
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'array' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
ref: 'Note',
|
||||
}
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchChannel: {
|
||||
message: 'No such channel.',
|
||||
code: 'NO_SUCH_CHANNEL',
|
||||
id: '4d0eeeba-a02c-4c3c-9966-ef60d38d2e7f'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, user) => {
|
||||
const channel = await Channels.findOne({
|
||||
id: ps.channelId,
|
||||
});
|
||||
|
||||
if (channel == null) {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
//#region Construct query
|
||||
const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere('note.channelId = :channelId', { channelId: channel.id })
|
||||
.leftJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
//#endregion
|
||||
|
||||
const timeline = await query.take(ps.limit!).getMany();
|
||||
|
||||
activeUsersChart.update(user);
|
||||
|
||||
return await Notes.packMany(timeline, user);
|
||||
});
|
42
src/server/api/endpoints/channels/unfollow.ts
Normal file
42
src/server/api/endpoints/channels/unfollow.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import $ from 'cafy';
|
||||
import { ID } from '../../../../misc/cafy-id';
|
||||
import define from '../../define';
|
||||
import { ApiError } from '../../error';
|
||||
import { Channels, ChannelFollowings } from '../../../../models';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels'],
|
||||
|
||||
requireCredential: true as const,
|
||||
|
||||
kind: 'write:channels',
|
||||
|
||||
params: {
|
||||
channelId: {
|
||||
validator: $.type(ID),
|
||||
},
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchChannel: {
|
||||
message: 'No such channel.',
|
||||
code: 'NO_SUCH_CHANNEL',
|
||||
id: '19959ee9-0153-4c51-bbd9-a98c49dc59d6'
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, user) => {
|
||||
const channel = await Channels.findOne({
|
||||
id: ps.channelId,
|
||||
});
|
||||
|
||||
if (channel == null) {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
await ChannelFollowings.delete({
|
||||
followerId: user.id,
|
||||
followeeId: channel.id,
|
||||
});
|
||||
});
|
93
src/server/api/endpoints/channels/update.ts
Normal file
93
src/server/api/endpoints/channels/update.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import $ from 'cafy';
|
||||
import { ID } from '../../../../misc/cafy-id';
|
||||
import define from '../../define';
|
||||
import { ApiError } from '../../error';
|
||||
import { Channels, DriveFiles } from '../../../../models';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels'],
|
||||
|
||||
requireCredential: true as const,
|
||||
|
||||
kind: 'write:channels',
|
||||
|
||||
params: {
|
||||
channelId: {
|
||||
validator: $.type(ID),
|
||||
},
|
||||
|
||||
name: {
|
||||
validator: $.optional.str.range(1, 128)
|
||||
},
|
||||
|
||||
description: {
|
||||
validator: $.nullable.optional.str.range(1, 2048)
|
||||
},
|
||||
|
||||
bannerId: {
|
||||
validator: $.nullable.optional.type(ID),
|
||||
}
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
ref: 'Channel',
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchChannel: {
|
||||
message: 'No such channel.',
|
||||
code: 'NO_SUCH_CHANNEL',
|
||||
id: 'f9c5467f-d492-4c3c-9a8d-a70dacc86512'
|
||||
},
|
||||
|
||||
accessDenied: {
|
||||
message: 'You do not have edit privilege of the channel.',
|
||||
code: 'ACCESS_DENIED',
|
||||
id: '1fb7cb09-d46a-4fdf-b8df-057788cce513'
|
||||
},
|
||||
|
||||
noSuchFile: {
|
||||
message: 'No such file.',
|
||||
code: 'NO_SUCH_FILE',
|
||||
id: 'e86c14a4-0da2-4032-8df3-e737a04c7f3b'
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, me) => {
|
||||
const channel = await Channels.findOne({
|
||||
id: ps.channelId,
|
||||
});
|
||||
|
||||
if (channel == null) {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
if (channel.userId !== me.id) {
|
||||
throw new ApiError(meta.errors.accessDenied);
|
||||
}
|
||||
|
||||
let banner = undefined;
|
||||
if (ps.bannerId != null) {
|
||||
banner = await DriveFiles.findOne({
|
||||
id: ps.bannerId,
|
||||
userId: me.id
|
||||
});
|
||||
|
||||
if (banner == null) {
|
||||
throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
} else if (ps.bannerId === null) {
|
||||
banner = null;
|
||||
}
|
||||
|
||||
await Channels.update(channel.id, {
|
||||
...(ps.name !== undefined ? { name: ps.name } : {}),
|
||||
...(ps.description !== undefined ? { description: ps.description } : {}),
|
||||
...(banner ? { bannerId: banner.id } : {}),
|
||||
});
|
||||
|
||||
return await Channels.pack(channel.id, me);
|
||||
});
|
@@ -24,7 +24,6 @@ export default define(meta, async (ps, user, token) => {
|
||||
|
||||
return await Users.pack(user, user, {
|
||||
detail: true,
|
||||
includeHasUnreadNotes: true,
|
||||
includeSecrets: isSecure
|
||||
});
|
||||
});
|
||||
|
22
src/server/api/endpoints/i/get-word-muted-notes-count.ts
Normal file
22
src/server/api/endpoints/i/get-word-muted-notes-count.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import define from '../../define';
|
||||
import { MutedNotes } from '../../../../models';
|
||||
|
||||
export const meta = {
|
||||
tags: ['account'],
|
||||
|
||||
requireCredential: true as const,
|
||||
|
||||
kind: 'read:account',
|
||||
|
||||
params: {
|
||||
}
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, user) => {
|
||||
return {
|
||||
count: await MutedNotes.count({
|
||||
userId: user.id,
|
||||
reason: 'word'
|
||||
})
|
||||
};
|
||||
});
|
@@ -195,6 +195,7 @@ export default define(meta, async (ps, me) => {
|
||||
response.objectStorageSecretKey = instance.objectStorageSecretKey;
|
||||
response.objectStorageUseSSL = instance.objectStorageUseSSL;
|
||||
response.objectStorageUseProxy = instance.objectStorageUseProxy;
|
||||
response.objectStorageSetPublicRead = instance.objectStorageSetPublicRead;
|
||||
}
|
||||
|
||||
return response;
|
||||
|
@@ -7,11 +7,12 @@ import { fetchMeta } from '../../../../misc/fetch-meta';
|
||||
import { ApiError } from '../../error';
|
||||
import { ID } from '../../../../misc/cafy-id';
|
||||
import { User } from '../../../../models/entities/user';
|
||||
import { Users, DriveFiles, Notes } from '../../../../models';
|
||||
import { Users, DriveFiles, Notes, Channels } from '../../../../models';
|
||||
import { DriveFile } from '../../../../models/entities/drive-file';
|
||||
import { Note } from '../../../../models/entities/note';
|
||||
import { DB_MAX_NOTE_TEXT_LENGTH } from '../../../../misc/hard-limits';
|
||||
import { noteVisibilities } from '../../../../types';
|
||||
import { Channel } from '../../../../models/entities/channel';
|
||||
|
||||
let maxNoteTextLength = 500;
|
||||
|
||||
@@ -128,19 +129,26 @@ export const meta = {
|
||||
},
|
||||
|
||||
replyId: {
|
||||
validator: $.optional.type(ID),
|
||||
validator: $.optional.nullable.type(ID),
|
||||
desc: {
|
||||
'ja-JP': '返信対象'
|
||||
}
|
||||
},
|
||||
|
||||
renoteId: {
|
||||
validator: $.optional.type(ID),
|
||||
validator: $.optional.nullable.type(ID),
|
||||
desc: {
|
||||
'ja-JP': 'Renote対象'
|
||||
}
|
||||
},
|
||||
|
||||
channelId: {
|
||||
validator: $.optional.nullable.type(ID),
|
||||
desc: {
|
||||
'ja-JP': 'チャンネル'
|
||||
}
|
||||
},
|
||||
|
||||
poll: {
|
||||
validator: $.optional.obj({
|
||||
choices: $.arr($.str)
|
||||
@@ -206,7 +214,13 @@ export const meta = {
|
||||
message: 'Poll is already expired.',
|
||||
code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
|
||||
id: '04da457d-b083-4055-9082-955525eda5a5'
|
||||
}
|
||||
},
|
||||
|
||||
noSuchChannel: {
|
||||
message: 'No such channel.',
|
||||
code: 'NO_SUCH_CHANNEL',
|
||||
id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb'
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -269,6 +283,15 @@ export default define(meta, async (ps, user) => {
|
||||
throw new ApiError(meta.errors.contentRequired);
|
||||
}
|
||||
|
||||
let channel: Channel | undefined;
|
||||
if (ps.channelId != null) {
|
||||
channel = await Channels.findOne(ps.channelId);
|
||||
|
||||
if (channel == null) {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
}
|
||||
|
||||
// 投稿を作成
|
||||
const note = await create(user, {
|
||||
createdAt: new Date(),
|
||||
@@ -286,6 +309,7 @@ export default define(meta, async (ps, user) => {
|
||||
localOnly: ps.localOnly,
|
||||
visibility: ps.visibility,
|
||||
visibleUsers,
|
||||
channel,
|
||||
apMentions: ps.noExtractMentions ? [] : undefined,
|
||||
apHashtags: ps.noExtractHashtags ? [] : undefined,
|
||||
apEmojis: ps.noExtractEmojis ? [] : undefined,
|
||||
|
@@ -80,6 +80,7 @@ export default define(meta, async (ps, user) => {
|
||||
const query = makePaginationQuery(Notes.createQueryBuilder('note'),
|
||||
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.visibility = \'public\'')
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.leftJoinAndSelect('note.user', 'user');
|
||||
|
||||
generateRepliesQuery(query, user);
|
||||
|
@@ -13,6 +13,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query';
|
||||
import { injectPromo } from '../../common/inject-promo';
|
||||
import { injectFeatured } from '../../common/inject-featured';
|
||||
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
|
||||
import { generateChannelQuery } from '../../common/generate-channel-query';
|
||||
|
||||
export const meta = {
|
||||
desc: {
|
||||
@@ -131,6 +132,7 @@ export default define(meta, async (ps, user) => {
|
||||
.leftJoinAndSelect('note.user', 'user')
|
||||
.setParameters(followingQuery.getParameters());
|
||||
|
||||
generateChannelQuery(query, user);
|
||||
generateRepliesQuery(query, user);
|
||||
generateVisibilityQuery(query, user);
|
||||
generateMutedUserQuery(query, user);
|
||||
|
@@ -13,6 +13,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query';
|
||||
import { injectPromo } from '../../common/inject-promo';
|
||||
import { injectFeatured } from '../../common/inject-featured';
|
||||
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
|
||||
import { generateChannelQuery } from '../../common/generate-channel-query';
|
||||
|
||||
export const meta = {
|
||||
desc: {
|
||||
@@ -99,6 +100,7 @@ export default define(meta, async (ps, user) => {
|
||||
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
|
||||
.leftJoinAndSelect('note.user', 'user');
|
||||
|
||||
generateChannelQuery(query, user);
|
||||
generateRepliesQuery(query, user);
|
||||
generateVisibilityQuery(query, user);
|
||||
if (user) generateMutedUserQuery(query, user);
|
||||
|
@@ -11,6 +11,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query';
|
||||
import { injectPromo } from '../../common/inject-promo';
|
||||
import { injectFeatured } from '../../common/inject-featured';
|
||||
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
|
||||
import { generateChannelQuery } from '../../common/generate-channel-query';
|
||||
|
||||
export const meta = {
|
||||
desc: {
|
||||
@@ -124,6 +125,7 @@ export default define(meta, async (ps, user) => {
|
||||
.leftJoinAndSelect('note.user', 'user')
|
||||
.setParameters(followingQuery.getParameters());
|
||||
|
||||
generateChannelQuery(query, user);
|
||||
generateRepliesQuery(query, user);
|
||||
generateVisibilityQuery(query, user);
|
||||
generateMutedUserQuery(query, user);
|
||||
|
@@ -27,6 +27,10 @@ export default abstract class Channel {
|
||||
return this.connection.muting;
|
||||
}
|
||||
|
||||
protected get followingChannels() {
|
||||
return this.connection.followingChannels;
|
||||
}
|
||||
|
||||
protected get subscriber() {
|
||||
return this.connection.subscriber;
|
||||
}
|
||||
|
49
src/server/api/stream/channels/channel.ts
Normal file
49
src/server/api/stream/channels/channel.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import Channel from '../channel';
|
||||
import { Notes } from '../../../../models';
|
||||
import { isMutedUserRelated } from '../../../../misc/is-muted-user-related';
|
||||
import { PackedNote } from '../../../../models/repositories/note';
|
||||
|
||||
export default class extends Channel {
|
||||
public readonly chName = 'channel';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = false;
|
||||
private channelId: string;
|
||||
|
||||
@autobind
|
||||
public async init(params: any) {
|
||||
this.channelId = params.channelId as string;
|
||||
|
||||
// Subscribe stream
|
||||
this.subscriber.on('notesStream', this.onNote);
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async onNote(note: PackedNote) {
|
||||
if (note.channelId !== this.channelId) return;
|
||||
|
||||
// リプライなら再pack
|
||||
if (note.replyId != null) {
|
||||
note.reply = await Notes.pack(note.replyId, this.user, {
|
||||
detail: true
|
||||
});
|
||||
}
|
||||
// Renoteなら再pack
|
||||
if (note.renoteId != null) {
|
||||
note.renote = await Notes.pack(note.renoteId, this.user, {
|
||||
detail: true
|
||||
});
|
||||
}
|
||||
|
||||
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
|
||||
if (isMutedUserRelated(note, this.muting)) return;
|
||||
|
||||
this.send('note', note);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public dispose() {
|
||||
// Unsubscribe events
|
||||
this.subscriber.off('notesStream', this.onNote);
|
||||
}
|
||||
}
|
@@ -25,6 +25,7 @@ export default class extends Channel {
|
||||
@autobind
|
||||
private async onNote(note: PackedNote) {
|
||||
if (note.visibility !== 'public') return;
|
||||
if (note.channelId != null) return;
|
||||
|
||||
// リプライなら再pack
|
||||
if (note.replyId != null) {
|
||||
|
@@ -18,8 +18,12 @@ export default class extends Channel {
|
||||
|
||||
@autobind
|
||||
private async onNote(note: PackedNote) {
|
||||
// その投稿のユーザーをフォローしていなかったら弾く
|
||||
if (this.user!.id !== note.userId && !this.following.includes(note.userId)) return;
|
||||
if (note.channelId) {
|
||||
if (!this.followingChannels.includes(note.channelId)) return;
|
||||
} else {
|
||||
// その投稿のユーザーをフォローしていなかったら弾く
|
||||
if ((this.user!.id !== note.userId) && !this.following.includes(note.userId)) return;
|
||||
}
|
||||
|
||||
if (['followers', 'specified'].includes(note.visibility)) {
|
||||
note = await Notes.pack(note.id, this.user!, {
|
||||
|
@@ -23,11 +23,15 @@ export default class extends Channel {
|
||||
|
||||
@autobind
|
||||
private async onNote(note: PackedNote) {
|
||||
// 自分自身の投稿 または その投稿のユーザーをフォローしている または 全体公開のローカルの投稿 の場合だけ
|
||||
// チャンネルの投稿ではなく、自分自身の投稿 または
|
||||
// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
|
||||
// チャンネルの投稿ではなく、全体公開のローカルの投稿 または
|
||||
// フォローしているチャンネルの投稿 の場合だけ
|
||||
if (!(
|
||||
this.user!.id === note.userId ||
|
||||
this.following.includes(note.userId) ||
|
||||
((note.user as PackedUser).host == null && note.visibility === 'public')
|
||||
(note.channelId == null && this.user!.id === note.userId) ||
|
||||
(note.channelId == null && this.following.includes(note.userId)) ||
|
||||
(note.channelId == null && ((note.user as PackedUser).host == null && note.visibility === 'public')) ||
|
||||
(note.channelId != null && this.followingChannels.includes(note.channelId))
|
||||
)) return;
|
||||
|
||||
if (['followers', 'specified'].includes(note.visibility)) {
|
||||
|
@@ -11,6 +11,7 @@ import messaging from './messaging';
|
||||
import messagingIndex from './messaging-index';
|
||||
import drive from './drive';
|
||||
import hashtag from './hashtag';
|
||||
import channel from './channel';
|
||||
import admin from './admin';
|
||||
import gamesReversi from './games/reversi';
|
||||
import gamesReversiGame from './games/reversi-game';
|
||||
@@ -29,6 +30,7 @@ export default {
|
||||
messagingIndex,
|
||||
drive,
|
||||
hashtag,
|
||||
channel,
|
||||
admin,
|
||||
gamesReversi,
|
||||
gamesReversiGame
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user