Merge branch 'develop' into vue3

This commit is contained in:
syuilo
2020-08-19 22:15:04 +09:00
119 changed files with 3454 additions and 640 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

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

View File

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

View File

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

View File

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

View File

@@ -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=""/>

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -196,7 +196,7 @@ export default defineComponent({
> .text {
margin: 6px 0;
font-size: 13px;
font-size: 0.8em;
&:empty {
display: none;

View File

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

View File

@@ -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}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.device.darkMode ? 'dark' : 'light'}&amp;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}')`">

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@@ -145,6 +145,7 @@ export class DriveFile {
@JoinColumn()
public folder: DriveFolder | null;
@Index()
@Column('boolean', {
default: false,
comment: 'Whether the DriveFile is NSFW.'

View File

@@ -370,4 +370,9 @@ export class Meta {
default: true,
})
public objectStorageUseProxy: boolean;
@Column('boolean', {
default: false,
})
public objectStorageSetPublicRead: boolean;
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

View File

@@ -24,7 +24,6 @@ export default define(meta, async (ps, user, token) => {
return await Users.pack(user, user, {
detail: true,
includeHasUnreadNotes: true,
includeSecrets: isSecure
});
});

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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