summaryrefslogtreecommitdiff
path: root/packages/client/src
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2022-02-09 14:50:38 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2022-02-09 14:50:38 +0900
commit5f985ee832eed61e31ceb51eaa1c51810ad6de39 (patch)
tree60cd65d92e377ea0a0f0d2bc3b767f8f26196310 /packages/client/src
parentMerge branch 'develop' (diff)
parent12.104.0 (diff)
downloadmisskey-5f985ee832eed61e31ceb51eaa1c51810ad6de39.tar.gz
misskey-5f985ee832eed61e31ceb51eaa1c51810ad6de39.tar.bz2
misskey-5f985ee832eed61e31ceb51eaa1c51810ad6de39.zip
Merge branch 'develop'
Diffstat (limited to 'packages/client/src')
-rw-r--r--packages/client/src/components/chart-tooltip.vue2
-rw-r--r--packages/client/src/components/chart.vue224
-rw-r--r--packages/client/src/components/emoji-picker.vue4
-rw-r--r--packages/client/src/components/instance-stats.vue6
-rw-r--r--packages/client/src/components/instance-ticker.vue18
-rw-r--r--packages/client/src/components/post-form.vue1
-rw-r--r--packages/client/src/components/ui/modal.vue3
-rw-r--r--packages/client/src/components/ui/tooltip.vue141
-rw-r--r--packages/client/src/directives/get-size.ts61
-rw-r--r--packages/client/src/directives/size.ts137
-rw-r--r--packages/client/src/init.ts6
-rw-r--r--packages/client/src/pages/about.vue27
-rw-r--r--packages/client/src/pages/admin/files.vue123
-rw-r--r--packages/client/src/pages/admin/users.vue152
-rw-r--r--packages/client/src/pages/settings/drive.vue6
-rw-r--r--packages/client/src/pages/settings/general.vue13
-rw-r--r--packages/client/src/pages/settings/privacy.vue2
-rw-r--r--packages/client/src/pages/settings/profile.vue4
-rw-r--r--packages/client/src/pages/timeline.vue23
-rw-r--r--packages/client/src/pages/user/index.activity.vue2
-rw-r--r--packages/client/src/scripts/device-kind.ts10
-rw-r--r--packages/client/src/scripts/is-mobile.ts2
-rw-r--r--packages/client/src/store.ts4
23 files changed, 633 insertions, 338 deletions
diff --git a/packages/client/src/components/chart-tooltip.vue b/packages/client/src/components/chart-tooltip.vue
index b080eaf2b4..20e094a5a7 100644
--- a/packages/client/src/components/chart-tooltip.vue
+++ b/packages/client/src/components/chart-tooltip.vue
@@ -1,5 +1,5 @@
<template>
-<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" @closed="emit('closed')">
+<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'left'" :inner-margin="16" @closed="emit('closed')">
<div v-if="title" class="qpcyisrl">
<div class="title">{{ title }}</div>
<div v-for="x in series" class="series">
diff --git a/packages/client/src/components/chart.vue b/packages/client/src/components/chart.vue
index 3e46c51b47..ced0d481c4 100644
--- a/packages/client/src/components/chart.vue
+++ b/packages/client/src/components/chart.vue
@@ -29,6 +29,7 @@ import {
import 'chartjs-adapter-date-fns';
import { enUS } from 'date-fns/locale';
import zoomPlugin from 'chartjs-plugin-zoom';
+import gradient from 'chartjs-plugin-gradient';
import * as os from '@/os';
import { defaultStore } from '@/store';
import MkChartTooltip from '@/components/chart-tooltip.vue';
@@ -49,6 +50,7 @@ Chart.register(
SubTitle,
Filler,
zoomPlugin,
+ gradient,
);
const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
@@ -61,9 +63,17 @@ const alpha = (hex, a) => {
return `rgba(${r}, ${g}, ${b}, ${a})`;
};
-const colors = ['#008FFB', '#00E396', '#FEB019', '#FF4560'];
+const colors = {
+ blue: '#008FFB',
+ green: '#00E396',
+ yellow: '#FEB019',
+ red: '#FF4560',
+ purple: '#e300db',
+ orange: '#fe6919',
+};
+const colorSets = [colors.blue, colors.green, colors.yellow, colors.red, colors.purple];
const getColor = (i) => {
- return colors[i % colors.length];
+ return colorSets[i % colorSets.length];
};
export default defineComponent({
@@ -95,6 +105,11 @@ export default defineComponent({
required: false,
default: false
},
+ bar: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
aspectRatio: {
type: Number,
required: false,
@@ -186,22 +201,36 @@ export default defineComponent({
// フォントカラー
Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+ const maxes = data.series.map((x, i) => Math.max(...x.data.map(d => d.y)));
+
chartInstance = new Chart(chartEl.value, {
- type: 'line',
+ type: props.bar ? 'bar' : 'line',
data: {
labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(),
datasets: data.series.map((x, i) => ({
parsing: false,
label: x.name,
data: x.data.slice().reverse(),
+ tension: 0.3,
pointRadius: 0,
- tension: 0,
borderWidth: 2,
borderColor: x.color ? x.color : getColor(i),
borderDash: x.borderDash || [],
borderJoinStyle: 'round',
backgroundColor: alpha(x.color ? x.color : getColor(i), 0.1),
+ gradient: {
+ backgroundColor: {
+ axis: 'y',
+ colors: {
+ 0: alpha(x.color ? x.color : getColor(i), 0),
+ [maxes[i]]: alpha(x.color ? x.color : getColor(i), 0.1),
+ },
+ },
+ },
+ barPercentage: 0.9,
+ categoryPercentage: 0.9,
fill: x.type === 'area',
+ clip: 8,
hidden: !!x.hidden,
})),
},
@@ -210,7 +239,7 @@ export default defineComponent({
layout: {
padding: {
left: 0,
- right: 0,
+ right: 8,
top: 0,
bottom: 0,
},
@@ -218,6 +247,7 @@ export default defineComponent({
scales: {
x: {
type: 'time',
+ stacked: props.stacked,
time: {
stepSize: 1,
unit: props.span === 'day' ? 'month' : 'day',
@@ -228,6 +258,8 @@ export default defineComponent({
},
ticks: {
display: props.detailed,
+ maxRotation: 0,
+ autoSkipPadding: 16,
},
adapters: {
date: {
@@ -245,12 +277,21 @@ export default defineComponent({
},
ticks: {
display: props.detailed,
+ //mirror: true,
},
},
},
interaction: {
intersect: false,
+ mode: 'index',
},
+ elements: {
+ point: {
+ hoverRadius: 5,
+ hoverBorderWidth: 2,
+ },
+ },
+ animation: false,
plugins: {
legend: {
display: props.detailed,
@@ -294,6 +335,7 @@ export default defineComponent({
},
}
},
+ gradient,
},
},
plugins: [{
@@ -324,20 +366,60 @@ export default defineComponent({
// TODO
};
- const fetchFederationInstancesChart = async (total: boolean): Promise<typeof data> => {
+ const fetchFederationChart = async (): Promise<typeof data> => {
const raw = await os.api('charts/federation', { limit: props.limit, span: props.span });
return {
series: [{
- name: 'Instances',
+ name: 'Total',
type: 'area',
- data: format(total
- ? raw.instance.total
- : sum(raw.instance.inc, negate(raw.instance.dec))
- ),
+ data: format(raw.instance.total),
+ color: '#888888',
+ }, {
+ name: 'Inc/Dec',
+ type: 'area',
+ data: format(sum(raw.instance.inc, negate(raw.instance.dec))),
+ color: colors.purple,
+ }, {
+ name: 'Received',
+ type: 'area',
+ data: format(raw.inboxInstances),
+ color: colors.blue,
+ }, {
+ name: 'Delivered',
+ type: 'area',
+ data: format(raw.deliveredInstances),
+ color: colors.green,
+ }, {
+ name: 'Stalled',
+ type: 'area',
+ data: format(raw.stalled),
+ color: colors.red,
}],
};
};
+ const fetchApRequestChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/ap-request', { limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'In',
+ type: 'area',
+ color: '#008FFB',
+ data: format(raw.inboxReceived)
+ }, {
+ name: 'Out (succ)',
+ type: 'area',
+ color: '#00E396',
+ data: format(raw.deliverSucceeded)
+ }, {
+ name: 'Out (fail)',
+ type: 'area',
+ color: '#FEB019',
+ data: format(raw.deliverFailed)
+ }]
+ };
+ };
+
const fetchNotesChart = async (type: string): Promise<typeof data> => {
const raw = await os.api('charts/notes', { limit: props.limit, span: props.span });
return {
@@ -349,6 +431,7 @@ export default defineComponent({
? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
: sum(raw[type].inc, negate(raw[type].dec))
),
+ color: '#888888',
}, {
name: 'Renotes',
type: 'area',
@@ -356,6 +439,7 @@ export default defineComponent({
? sum(raw.local.diffs.renote, raw.remote.diffs.renote)
: raw[type].diffs.renote
),
+ color: colors.green,
}, {
name: 'Replies',
type: 'area',
@@ -363,6 +447,7 @@ export default defineComponent({
? sum(raw.local.diffs.reply, raw.remote.diffs.reply)
: raw[type].diffs.reply
),
+ color: colors.yellow,
}, {
name: 'Normal',
type: 'area',
@@ -370,6 +455,15 @@ export default defineComponent({
? sum(raw.local.diffs.normal, raw.remote.diffs.normal)
: raw[type].diffs.normal
),
+ color: colors.blue,
+ }, {
+ name: 'With file',
+ type: 'area',
+ data: format(type == 'combined'
+ ? sum(raw.local.diffs.withFile, raw.remote.diffs.withFile)
+ : raw[type].diffs.withFile
+ ),
+ color: colors.purple,
}],
};
};
@@ -425,17 +519,50 @@ export default defineComponent({
const raw = await os.api('charts/active-users', { limit: props.limit, span: props.span });
return {
series: [{
- name: 'Combined',
- type: 'line',
- data: format(sum(raw.local.users, raw.remote.users)),
+ name: 'Read & Write',
+ type: 'area',
+ data: format(raw.readWrite),
+ color: colors.orange,
}, {
- name: 'Local',
+ name: 'Write',
type: 'area',
- data: format(raw.local.users),
+ data: format(raw.write),
+ color: colors.blue,
}, {
- name: 'Remote',
+ name: 'Read',
+ type: 'area',
+ data: format(raw.read),
+ color: '#888888',
+ }, {
+ name: '< Week',
type: 'area',
- data: format(raw.remote.users),
+ data: format(raw.registeredWithinWeek),
+ color: colors.green,
+ }, {
+ name: '< Month',
+ type: 'area',
+ data: format(raw.registeredWithinMonth),
+ color: colors.yellow,
+ }, {
+ name: '< Year',
+ type: 'area',
+ data: format(raw.registeredWithinYear),
+ color: colors.red,
+ }, {
+ name: '> Week',
+ type: 'area',
+ data: format(raw.registeredOutsideWeek),
+ color: colors.yellow,
+ }, {
+ name: '> Month',
+ type: 'area',
+ data: format(raw.registeredOutsideMonth),
+ color: colors.red,
+ }, {
+ name: '> Year',
+ type: 'area',
+ data: format(raw.registeredOutsideYear),
+ color: colors.purple,
}],
};
};
@@ -476,26 +603,6 @@ export default defineComponent({
};
};
- const fetchDriveTotalChart = async (): Promise<typeof data> => {
- const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
- return {
- bytes: true,
- series: [{
- name: 'Combined',
- type: 'line',
- data: format(sum(raw.local.totalSize, raw.remote.totalSize)),
- }, {
- name: 'Local',
- type: 'area',
- data: format(raw.local.totalSize),
- }, {
- name: 'Remote',
- type: 'area',
- data: format(raw.remote.totalSize),
- }],
- };
- };
-
const fetchDriveFilesChart = async (): Promise<typeof data> => {
const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
return {
@@ -531,25 +638,6 @@ export default defineComponent({
};
};
- const fetchDriveFilesTotalChart = async (): Promise<typeof data> => {
- const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
- return {
- series: [{
- name: 'Combined',
- type: 'line',
- data: format(sum(raw.local.totalCount, raw.remote.totalCount)),
- }, {
- name: 'Local',
- type: 'area',
- data: format(raw.local.totalCount),
- }, {
- name: 'Remote',
- type: 'area',
- data: format(raw.remote.totalCount),
- }],
- };
- };
-
const fetchInstanceRequestsChart = async (): Promise<typeof data> => {
const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
return {
@@ -680,11 +768,26 @@ export default defineComponent({
};
};
+ const fetchPerUserDriveChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Inc',
+ type: 'area',
+ data: format(raw.incSize),
+ }, {
+ name: 'Dec',
+ type: 'area',
+ data: format(raw.decSize),
+ }],
+ };
+ };
+
const fetchAndRender = async () => {
const fetchData = () => {
switch (props.src) {
- case 'federation-instances': return fetchFederationInstancesChart(false);
- case 'federation-instances-total': return fetchFederationInstancesChart(true);
+ case 'federation': return fetchFederationChart();
+ case 'ap-request': return fetchApRequestChart();
case 'users': return fetchUsersChart(false);
case 'users-total': return fetchUsersChart(true);
case 'active-users': return fetchActiveUsersChart();
@@ -693,9 +796,7 @@ export default defineComponent({
case 'remote-notes': return fetchNotesChart('remote');
case 'notes-total': return fetchNotesTotalChart();
case 'drive': return fetchDriveChart();
- case 'drive-total': return fetchDriveTotalChart();
case 'drive-files': return fetchDriveFilesChart();
- case 'drive-files-total': return fetchDriveFilesTotalChart();
case 'instance-requests': return fetchInstanceRequestsChart();
case 'instance-users': return fetchInstanceUsersChart(false);
@@ -710,6 +811,7 @@ export default defineComponent({
case 'instance-drive-files-total': return fetchInstanceDriveFilesChart(true);
case 'per-user-notes': return fetchPerUserNotesChart();
+ case 'per-user-drive': return fetchPerUserDriveChart();
}
};
fetching.value = true;
diff --git a/packages/client/src/components/emoji-picker.vue b/packages/client/src/components/emoji-picker.vue
index 6999ad6517..3e1208979f 100644
--- a/packages/client/src/components/emoji-picker.vue
+++ b/packages/client/src/components/emoji-picker.vue
@@ -81,7 +81,7 @@ import { getStaticImageUrl } from '@/scripts/get-static-image-url';
import Ripple from '@/components/ripple.vue';
import * as os from '@/os';
import { isTouchUsing } from '@/scripts/touch';
-import { isMobile } from '@/scripts/is-mobile';
+import { deviceKind } from '@/scripts/device-kind';
import { emojiCategories, instance } from '@/instance';
import XSection from './emoji-picker.section.vue';
import { i18n } from '@/i18n';
@@ -263,7 +263,7 @@ watch(q, () => {
});
function focus() {
- if (!isMobile && !isTouchUsing) {
+ if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) {
search.value?.focus({
preventScroll: true
});
diff --git a/packages/client/src/components/instance-stats.vue b/packages/client/src/components/instance-stats.vue
index 409c3a49ca..d2aa5a151a 100644
--- a/packages/client/src/components/instance-stats.vue
+++ b/packages/client/src/components/instance-stats.vue
@@ -3,8 +3,8 @@
<div class="selects" style="display: flex;">
<MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
<optgroup :label="$ts.federation">
- <option value="federation-instances">{{ $ts._charts.federationInstancesIncDec }}</option>
- <option value="federation-instances-total">{{ $ts._charts.federationInstancesTotal }}</option>
+ <option value="federation">{{ $ts._charts.federation }}</option>
+ <option value="ap-request">{{ $ts._charts.apRequest }}</option>
</optgroup>
<optgroup :label="$ts.users">
<option value="users">{{ $ts._charts.usersIncDec }}</option>
@@ -19,9 +19,7 @@
</optgroup>
<optgroup :label="$ts.drive">
<option value="drive-files">{{ $ts._charts.filesIncDec }}</option>
- <option value="drive-files-total">{{ $ts._charts.filesTotal }}</option>
<option value="drive">{{ $ts._charts.storageUsageIncDec }}</option>
- <option value="drive-total">{{ $ts._charts.storageUsageTotal }}</option>
</optgroup>
</MkSelect>
<MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;">
diff --git a/packages/client/src/components/instance-ticker.vue b/packages/client/src/components/instance-ticker.vue
index 77fd8bb344..9b0a18ec90 100644
--- a/packages/client/src/components/instance-ticker.vue
+++ b/packages/client/src/components/instance-ticker.vue
@@ -7,15 +7,27 @@
<script lang="ts" setup>
import { } from 'vue';
+import { instanceName } from '@/config';
const props = defineProps<{
- instance: any; // TODO
+ instance?: {
+ faviconUrl?: string
+ name: string
+ themeColor?: string
+ }
}>();
-const themeColor = props.instance.themeColor || '#777777';
+// if no instance data is given, this is for the local instance
+const instance = props.instance ?? {
+ faviconUrl: '/favicon.ico',
+ name: instanceName,
+ themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content
+};
+
+const themeColor = instance.themeColor ?? '#777777';
const bg = {
- background: `linear-gradient(90deg, ${themeColor}, ${themeColor + '00'})`
+ background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`
};
</script>
diff --git a/packages/client/src/components/post-form.vue b/packages/client/src/components/post-form.vue
index 535218ecf9..64a6478f45 100644
--- a/packages/client/src/components/post-form.vue
+++ b/packages/client/src/components/post-form.vue
@@ -342,6 +342,7 @@ function addTag(tag: string) {
function focus() {
textareaEl.focus();
+ textareaEl.setSelectionRange(textareaEl.value.length, textareaEl.value.length);
}
function chooseFileFrom(ev) {
diff --git a/packages/client/src/components/ui/modal.vue b/packages/client/src/components/ui/modal.vue
index 3c3bb5c226..b42c0e4d42 100644
--- a/packages/client/src/components/ui/modal.vue
+++ b/packages/client/src/components/ui/modal.vue
@@ -14,6 +14,7 @@ import { nextTick, onMounted, computed, ref, watch, provide } from 'vue';
import * as os from '@/os';
import { isTouchUsing } from '@/scripts/touch';
import { defaultStore } from '@/store';
+import { deviceKind } from '@/scripts/device-kind';
function getFixedContainer(el: Element | null): Element | null {
if (el == null || el.tagName === 'BODY') return null;
@@ -62,7 +63,7 @@ const content = ref<HTMLElement>();
const zIndex = os.claimZIndex(props.zPriority);
const type = computed(() => {
if (props.preferType === 'auto') {
- if (!defaultStore.state.disableDrawer && isTouchUsing && window.innerWidth < 500 && window.innerHeight < 1000) {
+ if (!defaultStore.state.disableDrawer && isTouchUsing && deviceKind === 'smartphone') {
return 'drawer';
} else {
return props.src != null ? 'popup' : 'dialog';
diff --git a/packages/client/src/components/ui/tooltip.vue b/packages/client/src/components/ui/tooltip.vue
index 1892877cc1..3ccd1b7316 100644
--- a/packages/client/src/components/ui/tooltip.vue
+++ b/packages/client/src/components/ui/tooltip.vue
@@ -17,8 +17,12 @@ const props = withDefaults(defineProps<{
y?: number;
text?: string;
maxWidth?: number;
+ direction?: 'top' | 'bottom' | 'right' | 'left';
+ innerMargin?: number;
}>(), {
maxWidth: 250,
+ direction: 'top',
+ innerMargin: 0,
});
const emit = defineEmits<{
@@ -34,39 +38,144 @@ const setPosition = () => {
const contentWidth = el.value.offsetWidth;
const contentHeight = el.value.offsetHeight;
- let left: number;
- let top: number;
-
let rect: DOMRect;
if (props.targetElement) {
rect = props.targetElement.getBoundingClientRect();
+ }
+
+ const calcPosWhenTop = () => {
+ let left: number;
+ let top: number;
- left = rect.left + window.pageXOffset + (props.targetElement.offsetWidth / 2);
- top = rect.top + window.pageYOffset - contentHeight;
+ if (props.targetElement) {
+ left = rect.left + window.pageXOffset + (props.targetElement.offsetWidth / 2);
+ top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin;
+ } else {
+ left = props.x;
+ top = (props.y - contentHeight) - props.innerMargin;
+ }
- el.value.style.transformOrigin = 'center bottom';
- } else {
- left = props.x;
- top = props.y - contentHeight;
+ left -= (el.value.offsetWidth / 2);
+
+ if (left + contentWidth - window.pageXOffset > window.innerWidth) {
+ left = window.innerWidth - contentWidth + window.pageXOffset - 1;
+ }
+
+ return [left, top];
}
- left -= (el.value.offsetWidth / 2);
+ const calcPosWhenBottom = () => {
+ let left: number;
+ let top: number;
+
+ if (props.targetElement) {
+ left = rect.left + window.pageXOffset + (props.targetElement.offsetWidth / 2);
+ top = (rect.top + window.pageYOffset + props.targetElement.offsetHeight) + props.innerMargin;
+ } else {
+ left = props.x;
+ top = (props.y) + props.innerMargin;
+ }
+
+ left -= (el.value.offsetWidth / 2);
- if (left + contentWidth - window.pageXOffset > window.innerWidth) {
- left = window.innerWidth - contentWidth + window.pageXOffset - 1;
+ if (left + contentWidth - window.pageXOffset > window.innerWidth) {
+ left = window.innerWidth - contentWidth + window.pageXOffset - 1;
+ }
+
+ return [left, top];
}
- // ツールチップを上に向かって表示するスペースがなければ下に向かって出す
- if (top - window.pageYOffset < 0) {
+ const calcPosWhenLeft = () => {
+ let left: number;
+ let top: number;
+
if (props.targetElement) {
- top = rect.top + window.pageYOffset + props.targetElement.offsetHeight;
- el.value.style.transformOrigin = 'center top';
+ left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin;
+ top = rect.top + window.pageYOffset + (props.targetElement.offsetHeight / 2);
} else {
+ left = (props.x - contentWidth) - props.innerMargin;
top = props.y;
}
+
+ top -= (el.value.offsetHeight / 2);
+
+ if (top + contentHeight - window.pageYOffset > window.innerHeight) {
+ top = window.innerHeight - contentHeight + window.pageYOffset - 1;
+ }
+
+ return [left, top];
+ }
+
+ const calcPosWhenRight = () => {
+ let left: number;
+ let top: number;
+
+ if (props.targetElement) {
+ left = (rect.left + window.pageXOffset) + props.innerMargin;
+ top = rect.top + window.pageYOffset + (props.targetElement.offsetHeight / 2);
+ } else {
+ left = props.x + props.innerMargin;
+ top = props.y;
+ }
+
+ top -= (el.value.offsetHeight / 2);
+
+ if (top + contentHeight - window.pageYOffset > window.innerHeight) {
+ top = window.innerHeight - contentHeight + window.pageYOffset - 1;
+ }
+
+ return [left, top];
+ }
+
+ const calc = (): {
+ left: number;
+ top: number;
+ transformOrigin: string;
+ } => {
+ switch (props.direction) {
+ case 'top': {
+ const [left, top] = calcPosWhenTop();
+
+ // ツールチップを上に向かって表示するスペースがなければ下に向かって出す
+ if (top - window.pageYOffset < 0) {
+ const [left, top] = calcPosWhenBottom();
+ return { left, top, transformOrigin: 'center top' };
+ }
+
+ return { left, top, transformOrigin: 'center bottom' };
+ }
+
+ case 'bottom': {
+ const [left, top] = calcPosWhenBottom();
+ // TODO: ツールチップを下に向かって表示するスペースがなければ上に向かって出す
+ return { left, top, transformOrigin: 'center top' };
+ }
+
+ case 'left': {
+ const [left, top] = calcPosWhenLeft();
+
+ // ツールチップを左に向かって表示するスペースがなければ右に向かって出す
+ if (left - window.pageXOffset < 0) {
+ const [left, top] = calcPosWhenRight();
+ return { left, top, transformOrigin: 'left center' };
+ }
+
+ return { left, top, transformOrigin: 'right center' };
+ }
+
+ case 'right': {
+ const [left, top] = calcPosWhenRight();
+ // TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す
+ return { left, top, transformOrigin: 'left center' };
+ }
+ }
+
+ return null as never;
}
+ const { left, top, transformOrigin } = calc();
+ el.value.style.transformOrigin = transformOrigin;
el.value.style.left = left + 'px';
el.value.style.top = top + 'px';
};
diff --git a/packages/client/src/directives/get-size.ts b/packages/client/src/directives/get-size.ts
index e3b5dea0f3..1fcd0718dc 100644
--- a/packages/client/src/directives/get-size.ts
+++ b/packages/client/src/directives/get-size.ts
@@ -1,34 +1,55 @@
import { Directive } from 'vue';
-export default {
- mounted(src, binding, vn) {
- const calc = () => {
- const height = src.clientHeight;
- const width = src.clientWidth;
+const mountings = new Map<Element, {
+ resize: ResizeObserver;
+ intersection?: IntersectionObserver;
+ fn: (w: number, h: number) => void;
+}>();
+
+function calc(src: Element) {
+ const info = mountings.get(src);
+ const height = src.clientHeight;
+ const width = src.clientWidth;
- // 要素が(一時的に)DOMに存在しないときは計算スキップ
- if (height === 0) return;
+ if (!info) return;
- binding.value(width, height);
- };
+ // アクティベート前などでsrcが描画されていない場合
+ if (!height) {
+ // IntersectionObserverで表示検出する
+ if (!info.intersection) {
+ info.intersection = new IntersectionObserver(entries => {
+ if (entries.some(entry => entry.isIntersecting)) calc(src);
+ });
+ }
+ info.intersection.observe(src);
+ return;
+ }
+ if (info.intersection) {
+ info.intersection.disconnect()
+ delete info.intersection;
+ };
- calc();
+ info.fn(width, height);
+};
- // Vue3では使えなくなった
- // 無くても大丈夫か...?
- // TODO: ↑大丈夫じゃなかったので解決策を探す
- //vn.context.$on('hook:activated', calc);
+export default {
+ mounted(src, binding, vn) {
- const ro = new ResizeObserver((entries, observer) => {
- calc();
+ const resize = new ResizeObserver((entries, observer) => {
+ calc(src);
});
- ro.observe(src);
+ resize.observe(src);
- src._get_size_ro_ = ro;
+ mountings.set(src, { resize, fn: binding.value, });
+ calc(src);
},
unmounted(src, binding, vn) {
binding.value(0, 0);
- src._get_size_ro_.unobserve(src);
+ const info = mountings.get(src);
+ if (!info) return;
+ info.resize.disconnect();
+ if (info.intersection) info.intersection.disconnect();
+ mountings.delete(src);
}
-} as Directive;
+} as Directive<Element, (w: number, h: number) => void>;
diff --git a/packages/client/src/directives/size.ts b/packages/client/src/directives/size.ts
index a72a97abcc..36f649f180 100644
--- a/packages/client/src/directives/size.ts
+++ b/packages/client/src/directives/size.ts
@@ -1,68 +1,107 @@
import { Directive } from 'vue';
+type Value = { max?: number[]; min?: number[]; };
+
//const observers = new Map<Element, ResizeObserver>();
+const mountings = new Map<Element, {
+ value: Value;
+ resize: ResizeObserver;
+ intersection?: IntersectionObserver;
+ previousWidth: number;
+}>();
-export default {
- mounted(src, binding, vn) {
- const query = binding.value;
+type ClassOrder = {
+ add: string[];
+ remove: string[];
+};
- const addClass = (el: Element, cls: string) => {
- el.classList.add(cls);
- };
+const cache = new Map<string, ClassOrder>();
- const removeClass = (el: Element, cls: string) => {
- el.classList.remove(cls);
- };
+function getClassOrder(width: number, queue: Value): ClassOrder {
+ const getMaxClass = (v: number) => `max-width_${v}px`;
+ const getMinClass = (v: number) => `min-width_${v}px`;
- const calc = () => {
- const width = src.clientWidth;
+ return {
+ add: [
+ ...(queue.max ? queue.max.filter(v => width <= v).map(getMaxClass) : []),
+ ...(queue.min ? queue.min.filter(v => width >= v).map(getMinClass) : []),
+ ],
+ remove: [
+ ...(queue.max ? queue.max.filter(v => width > v).map(getMaxClass) : []),
+ ...(queue.min ? queue.min.filter(v => width < v).map(getMinClass) : []),
+ ]
+ };
+}
- // 要素が(一時的に)DOMに存在しないときは計算スキップ
- if (width === 0) return;
+function applyClassOrder(el: Element, order: ClassOrder) {
+ el.classList.add(...order.add);
+ el.classList.remove(...order.remove);
+}
- if (query.max) {
- for (const v of query.max) {
- if (width <= v) {
- addClass(src, 'max-width_' + v + 'px');
- } else {
- removeClass(src, 'max-width_' + v + 'px');
- }
- }
- }
- if (query.min) {
- for (const v of query.min) {
- if (width >= v) {
- addClass(src, 'min-width_' + v + 'px');
- } else {
- removeClass(src, 'min-width_' + v + 'px');
- }
- }
- }
- };
+function getOrderName(width: number, queue: Value): string {
+ return `${width}|${queue.max ? queue.max.join(',') : ''}|${queue.min ? queue.min.join(',') : ''}`;
+}
- calc();
+function calc(el: Element) {
+ const info = mountings.get(el);
+ const width = el.clientWidth;
- window.addEventListener('resize', calc);
+ if (!info || info.previousWidth === width) return;
- // Vue3では使えなくなった
- // 無くても大丈夫か...?
- // TODO: ↑大丈夫じゃなかったので解決策を探す
- //vn.context.$on('hook:activated', calc);
+ // アクティベート前などでsrcが描画されていない場合
+ if (!width) {
+ // IntersectionObserverで表示検出する
+ if (!info.intersection) {
+ info.intersection = new IntersectionObserver(entries => {
+ if (entries.some(entry => entry.isIntersecting)) calc(el);
+ });
+ }
+ info.intersection.observe(el);
+ return;
+ }
+ if (info.intersection) {
+ info.intersection.disconnect()
+ delete info.intersection;
+ };
+
+ mountings.set(el, Object.assign(info, { previousWidth: width }));
- //const ro = new ResizeObserver((entries, observer) => {
- // calc();
- //});
+ const cached = cache.get(getOrderName(width, info.value));
+ if (cached) {
+ applyClassOrder(el, cached);
+ } else {
+ const order = getClassOrder(width, info.value);
+ cache.set(getOrderName(width, info.value), order);
+ applyClassOrder(el, order);
+ }
+}
- //ro.observe(el);
+export default {
+ mounted(src, binding, vn) {
+ const resize = new ResizeObserver((entries, observer) => {
+ calc(src);
+ });
+
+ mountings.set(src, {
+ value: binding.value,
+ resize,
+ previousWidth: 0,
+ });
+
+ calc(src);
+ resize.observe(src);
+ },
- // TODO: 新たにプロパティを作るのをやめMapを使う
- // ただメモリ的には↓の方が省メモリかもしれないので検討中
- //el._ro_ = ro;
- src._calc_ = calc;
+ updated(src, binding, vn) {
+ mountings.set(src, Object.assign({}, mountings.get(src), { value: binding.value }));
+ calc(src);
},
unmounted(src, binding, vn) {
- //el._ro_.unobserve(el);
- window.removeEventListener('resize', src._calc_);
+ const info = mountings.get(src);
+ if (!info) return;
+ info.resize.disconnect();
+ if (info.intersection) info.intersection.disconnect();
+ mountings.delete(src);
}
-} as Directive;
+} as Directive<Element, Value>;
diff --git a/packages/client/src/init.ts b/packages/client/src/init.ts
index 81e41febd1..b7fc8b1d1e 100644
--- a/packages/client/src/init.ts
+++ b/packages/client/src/init.ts
@@ -14,7 +14,7 @@ if (localStorage.getItem('accounts') != null) {
//#endregion
import { computed, createApp, watch, markRaw, version as vueVersion } from 'vue';
-import * as compareVersions from 'compare-versions';
+import compareVersions from 'compare-versions';
import widgets from '@/widgets';
import directives from '@/directives';
@@ -32,7 +32,7 @@ import { defaultStore, ColdDeviceStorage } from '@/store';
import { fetchInstance, instance } from '@/instance';
import { makeHotkey } from '@/scripts/hotkey';
import { search } from '@/scripts/search';
-import { isMobile } from '@/scripts/is-mobile';
+import { deviceKind } from '@/scripts/device-kind';
import { initializeSw } from '@/scripts/initialize-sw';
import { reloadChannel } from '@/scripts/unison-reload';
import { reactionPicker } from '@/scripts/reaction-picker';
@@ -92,7 +92,7 @@ window.addEventListener('resize', () => {
//#endregion
// If mobile, insert the viewport meta tag
-if (isMobile || window.innerWidth <= 1024) {
+if (['smartphone', 'tablet'].includes(deviceKind)) {
const viewport = document.getElementsByName('viewport').item(0);
viewport.setAttribute('content',
`${viewport.getAttribute('content')},minimum-scale=1,maximum-scale=1,user-scalable=no`);
diff --git a/packages/client/src/pages/about.vue b/packages/client/src/pages/about.vue
index d5bab4baf8..6cc2e387ec 100644
--- a/packages/client/src/pages/about.vue
+++ b/packages/client/src/pages/about.vue
@@ -1,5 +1,5 @@
<template>
-<MkSpacer :content-max="600" :margin-min="20">
+<MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20">
<div class="_formRoot">
<div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
<div class="content">
@@ -65,35 +65,50 @@
</FormSection>
</div>
</MkSpacer>
+<MkSpacer v-else-if="tab === 'charts'" :content-max="1200" :margin-min="20">
+ <MkInstanceStats :chart-limit="500" :detailed="true"/>
+</MkSpacer>
</template>
<script lang="ts" setup>
-import { ref } from 'vue';
+import { ref, computed } from 'vue';
import { version, instanceName } from '@/config';
import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue';
import FormSuspense from '@/components/form/suspense.vue';
import FormSplit from '@/components/form/split.vue';
import MkKeyValue from '@/components/key-value.vue';
+import MkInstanceStats from '@/components/instance-stats.vue';
import * as os from '@/os';
import number from '@/filters/number';
import * as symbols from '@/symbols';
import { host } from '@/config';
import { i18n } from '@/i18n';
-const stats = ref(null);
+let stats = $ref(null);
+let tab = $ref('overview');
const initStats = () => os.api('stats', {
}).then((res) => {
- stats.value = res;
+ stats = res;
});
defineExpose({
- [symbols.PAGE_INFO]: {
+ [symbols.PAGE_INFO]: computed(() => ({
title: i18n.ts.instanceInfo,
icon: 'fas fa-info-circle',
bg: 'var(--bg)',
- },
+ tabs: [{
+ active: tab === 'overview',
+ title: i18n.ts.overview,
+ onClick: () => { tab = 'overview'; },
+ }, {
+ active: tab === 'charts',
+ title: i18n.ts.charts,
+ icon: 'fas fa-chart-bar',
+ onClick: () => { tab = 'charts'; },
+ },],
+ })),
});
</script>
diff --git a/packages/client/src/pages/admin/files.vue b/packages/client/src/pages/admin/files.vue
index 87dd12f489..c62f053092 100644
--- a/packages/client/src/pages/admin/files.vue
+++ b/packages/client/src/pages/admin/files.vue
@@ -28,7 +28,7 @@
<template #label>MIME type</template>
</MkInput>
</div>
- <MkPagination v-slot="{items}" ref="files" :pagination="pagination" class="urempief">
+ <MkPagination v-slot="{items}" :pagination="pagination" class="urempief">
<button v-for="file in items" :key="file.id" class="file _panel _button _gap" @click="show(file, $event)">
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
<div class="body">
@@ -54,8 +54,8 @@
</div>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed } from 'vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkSelect from '@/components/form/select.vue';
@@ -65,80 +65,63 @@ import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
import bytes from '@/filters/bytes';
import * as os from '@/os';
import * as symbols from '@/symbols';
+import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- MkButton,
- MkInput,
- MkSelect,
- MkPagination,
- MkContainer,
- MkDriveFileThumbnail,
- },
+let q = $ref(null);
+let origin = $ref('local');
+let type = $ref(null);
+let searchHost = $ref('');
+const pagination = {
+ endpoint: 'admin/drive/files' as const,
+ limit: 10,
+ params: computed(() => ({
+ type: (type && type !== '') ? type : null,
+ origin: origin,
+ hostname: (searchHost && searchHost !== '') ? searchHost : null,
+ })),
+};
- emits: ['info'],
+function clear() {
+ os.confirm({
+ type: 'warning',
+ text: i18n.ts.clearCachedFilesConfirm,
+ }).then(({ canceled }) => {
+ if (canceled) return;
- data() {
- return {
- [symbols.PAGE_INFO]: {
- title: this.$ts.files,
- icon: 'fas fa-cloud',
- bg: 'var(--bg)',
- actions: [{
- text: this.$ts.clearCachedFiles,
- icon: 'fas fa-trash-alt',
- handler: this.clear
- }]
- },
- q: null,
- origin: 'local',
- type: null,
- searchHost: '',
- pagination: {
- endpoint: 'admin/drive/files' as const,
- limit: 10,
- params: computed(() => ({
- type: (this.type && this.type !== '') ? this.type : null,
- origin: this.origin,
- hostname: (this.searchHost && this.searchHost !== '') ? this.searchHost : null,
- })),
- },
- }
- },
-
- methods: {
- clear() {
- os.confirm({
- type: 'warning',
- text: this.$ts.clearCachedFilesConfirm,
- }).then(({ canceled }) => {
- if (canceled) return;
-
- os.apiWithDialog('admin/drive/clean-remote-files', {});
- });
- },
+ os.apiWithDialog('admin/drive/clean-remote-files', {});
+ });
+}
- show(file, ev) {
- os.popup(import('./file-dialog.vue'), {
- fileId: file.id
- }, {}, 'closed');
- },
+function show(file) {
+ os.popup(import('./file-dialog.vue'), {
+ fileId: file.id
+ }, {}, 'closed');
+}
- find() {
- os.api('admin/drive/show-file', this.q.startsWith('http://') || this.q.startsWith('https://') ? { url: this.q.trim() } : { fileId: this.q.trim() }).then(file => {
- this.show(file);
- }).catch(e => {
- if (e.code === 'NO_SUCH_FILE') {
- os.alert({
- type: 'error',
- text: this.$ts.notFound
- });
- }
+function find() {
+ os.api('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => {
+ show(file);
+ }).catch(err => {
+ if (err.code === 'NO_SUCH_FILE') {
+ os.alert({
+ type: 'error',
+ text: i18n.ts.notFound
});
- },
+ }
+ });
+}
- bytes
- }
+defineExpose({
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: i18n.ts.files,
+ icon: 'fas fa-cloud',
+ bg: 'var(--bg)',
+ actions: [{
+ text: i18n.ts.clearCachedFiles,
+ icon: 'fas fa-trash-alt',
+ handler: clear,
+ }],
+ })),
});
</script>
diff --git a/packages/client/src/pages/admin/users.vue b/packages/client/src/pages/admin/users.vue
index 03e155ddcf..f05aa5ff45 100644
--- a/packages/client/src/pages/admin/users.vue
+++ b/packages/client/src/pages/admin/users.vue
@@ -36,7 +36,7 @@
</MkInput>
</div>
- <MkPagination v-slot="{items}" ref="users" :pagination="pagination" class="users">
+ <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users">
<button v-for="user in items" :key="user.id" class="user _panel _button _gap" @click="show(user)">
<MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
<div class="body">
@@ -61,9 +61,8 @@
</div>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
-import MkButton from '@/components/ui/button.vue';
+<script lang="ts" setup>
+import { computed } from 'vue';
import MkInput from '@/components/form/input.vue';
import MkSelect from '@/components/form/select.vue';
import MkPagination from '@/components/ui/pagination.vue';
@@ -71,94 +70,79 @@ import { acct } from '@/filters/user';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { lookupUser } from '@/scripts/lookup-user';
+import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- MkButton,
- MkInput,
- MkSelect,
- MkPagination,
- },
+let paginationComponent = $ref<InstanceType<typeof MkPagination>>();
- emits: ['info'],
+let sort = $ref('+createdAt');
+let state = $ref('all');
+let origin = $ref('local');
+let searchUsername = $ref('');
+let searchHost = $ref('');
+const pagination = {
+ endpoint: 'admin/show-users' as const,
+ limit: 10,
+ params: computed(() => ({
+ sort: sort,
+ state: state,
+ origin: origin,
+ username: searchUsername,
+ hostname: searchHost,
+ })),
+ offsetMode: true
+};
- data() {
- return {
- [symbols.PAGE_INFO]: {
- title: this.$ts.users,
- icon: 'fas fa-users',
- bg: 'var(--bg)',
- actions: [{
- icon: 'fas fa-search',
- text: this.$ts.search,
- handler: this.searchUser
- }, {
- asFullButton: true,
- icon: 'fas fa-plus',
- text: this.$ts.addUser,
- handler: this.addUser
- }, {
- asFullButton: true,
- icon: 'fas fa-search',
- text: this.$ts.lookup,
- handler: this.lookupUser
- }],
- },
- sort: '+createdAt',
- state: 'all',
- origin: 'local',
- searchUsername: '',
- searchHost: '',
- pagination: {
- endpoint: 'admin/show-users' as const,
- limit: 10,
- params: computed(() => ({
- sort: this.sort,
- state: this.state,
- origin: this.origin,
- username: this.searchUsername,
- hostname: this.searchHost,
- })),
- offsetMode: true
- },
- }
- },
-
- methods: {
- lookupUser,
-
- searchUser() {
- os.selectUser().then(user => {
- this.show(user);
- });
- },
+function searchUser() {
+ os.selectUser().then(user => {
+ show(user);
+ });
+}
- async addUser() {
- const { canceled: canceled1, result: username } = await os.inputText({
- title: this.$ts.username,
- });
- if (canceled1) return;
+async function addUser() {
+ const { canceled: canceled1, result: username } = await os.inputText({
+ title: i18n.ts.username,
+ });
+ if (canceled1) return;
- const { canceled: canceled2, result: password } = await os.inputText({
- title: this.$ts.password,
- type: 'password'
- });
- if (canceled2) return;
+ const { canceled: canceled2, result: password } = await os.inputText({
+ title: i18n.ts.password,
+ type: 'password'
+ });
+ if (canceled2) return;
- os.apiWithDialog('admin/accounts/create', {
- username: username,
- password: password,
- }).then(res => {
- this.$refs.users.reload();
- });
- },
+ os.apiWithDialog('admin/accounts/create', {
+ username: username,
+ password: password,
+ }).then(res => {
+ paginationComponent.reload();
+ });
+}
- show(user) {
- os.pageWindow(`/user-info/${user.id}`);
- },
+function show(user) {
+ os.pageWindow(`/user-info/${user.id}`);
+}
- acct
- }
+defineExpose({
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: i18n.ts.users,
+ icon: 'fas fa-users',
+ bg: 'var(--bg)',
+ actions: [{
+ icon: 'fas fa-search',
+ text: i18n.ts.search,
+ handler: searchUser
+ }, {
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: i18n.ts.addUser,
+ handler: addUser
+ }, {
+ asFullButton: true,
+ icon: 'fas fa-search',
+ text: i18n.ts.lookup,
+ handler: lookupUser
+ }],
+ })),
});
</script>
diff --git a/packages/client/src/pages/settings/drive.vue b/packages/client/src/pages/settings/drive.vue
index 134fa63308..9309eb5ec7 100644
--- a/packages/client/src/pages/settings/drive.vue
+++ b/packages/client/src/pages/settings/drive.vue
@@ -19,7 +19,7 @@
<FormSection>
<template #label>{{ $ts.statistics }}</template>
- <div ref="chart"></div>
+ <MkChart src="per-user-drive" :args="{ user: $i }" span="day" :limit="7 * 5" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="6"/>
</FormSection>
<FormSection>
@@ -45,8 +45,7 @@ import * as os from '@/os';
import bytes from '@/filters/bytes';
import * as symbols from '@/symbols';
import { defaultStore } from '@/store';
-
-// TODO: render chart
+import MkChart from '@/components/chart.vue';
export default defineComponent({
components: {
@@ -55,6 +54,7 @@ export default defineComponent({
FormSection,
MkKeyValue,
FormSplit,
+ MkChart,
},
emits: ['info'],
diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue
index 2e159e56a9..c8f6f58322 100644
--- a/packages/client/src/pages/settings/general.vue
+++ b/packages/client/src/pages/settings/general.vue
@@ -12,6 +12,14 @@
</template>
</FormSelect>
+ <FormRadios v-model="overridedDeviceKind" class="_formBlock">
+ <template #label>{{ $ts.overridedDeviceKind }}</template>
+ <option :value="null">{{ $ts.auto }}</option>
+ <option value="smartphone"><i class="fas fa-mobile-alt"/> {{ $ts.smartphone }}</option>
+ <option value="tablet"><i class="fas fa-tablet-alt"/> {{ $ts.tablet }}</option>
+ <option value="desktop"><i class="fas fa-desktop"/> {{ $ts.desktop }}</option>
+ </FormRadios>
+
<FormSwitch v-model="showFixedPostForm" class="_formBlock">{{ $ts.showFixedPostForm }}</FormSwitch>
<FormSection>
@@ -127,6 +135,7 @@ export default defineComponent({
},
computed: {
+ overridedDeviceKind: defaultStore.makeGetterSetter('overridedDeviceKind'),
serverDisconnectedBehavior: defaultStore.makeGetterSetter('serverDisconnectedBehavior'),
reduceAnimation: defaultStore.makeGetterSetter('animation', v => !v, v => !v),
useBlurEffectForModal: defaultStore.makeGetterSetter('useBlurEffectForModal'),
@@ -193,6 +202,10 @@ export default defineComponent({
instanceTicker() {
this.reloadAsk();
},
+
+ overridedDeviceKind() {
+ this.reloadAsk();
+ },
},
methods: {
diff --git a/packages/client/src/pages/settings/privacy.vue b/packages/client/src/pages/settings/privacy.vue
index cfae7e9ca8..a84d2f8786 100644
--- a/packages/client/src/pages/settings/privacy.vue
+++ b/packages/client/src/pages/settings/privacy.vue
@@ -8,7 +8,7 @@
<template #caption>{{ $ts.makeReactionsPublicDescription }}</template>
</FormSwitch>
- <FormSelect v-model="ffVisibility" class="_formBlock">
+ <FormSelect v-model="ffVisibility" class="_formBlock" @update:modelValue="save()">
<template #label>{{ $ts.ffVisibility }}</template>
<option value="public">{{ $ts._ffVisibility.public }}</option>
<option value="followers">{{ $ts._ffVisibility.followers }}</option>
diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue
index 66b654d87f..9b30d1c8e0 100644
--- a/packages/client/src/pages/settings/profile.vue
+++ b/packages/client/src/pages/settings/profile.vue
@@ -38,7 +38,7 @@
</FormSlot>
<FormSwitch v-model="profile.isCat" class="_formBlock">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></FormSwitch>
-
+ <FormSwitch v-model="profile.showTimelineReplies" class="_formBlock">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }}</template></FormSwitch>
<FormSwitch v-model="profile.isBot" class="_formBlock">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></FormSwitch>
<FormSwitch v-model="profile.alwaysMarkNsfw" class="_formBlock">{{ i18n.ts.alwaysMarkSensitive }}</FormSwitch>
@@ -68,6 +68,7 @@ const profile = reactive({
lang: $i.lang,
isBot: $i.isBot,
isCat: $i.isCat,
+ showTimelineReplies: $i.showTimelineReplies,
alwaysMarkNsfw: $i.alwaysMarkNsfw,
});
@@ -97,6 +98,7 @@ function save() {
lang: profile.lang || null,
isBot: !!profile.isBot,
isCat: !!profile.isCat,
+ showTimelineReplies: !!profile.showTimelineReplies,
alwaysMarkNsfw: !!profile.alwaysMarkNsfw,
});
}
diff --git a/packages/client/src/pages/timeline.vue b/packages/client/src/pages/timeline.vue
index b2266d22c3..79f00c4b44 100644
--- a/packages/client/src/pages/timeline.vue
+++ b/packages/client/src/pages/timeline.vue
@@ -46,8 +46,10 @@ const keymap = {
const tlComponent = $ref<InstanceType<typeof XTimeline>>();
const rootEl = $ref<HTMLElement>();
-let src = $ref<'home' | 'local' | 'social' | 'global'>(defaultStore.state.tl.src);
let queue = $ref(0);
+const src = $computed(() => defaultStore.reactiveState.tl.value.src);
+
+watch ($$(src), () => queue = 0);
function queueUpdated(q: number): void {
queue = q;
@@ -60,7 +62,7 @@ function top(): void {
async function chooseList(ev: MouseEvent): Promise<void> {
const lists = await os.api('users/lists/list');
const items = lists.map(list => ({
- type: 'link',
+ type: 'link' as const,
text: list.name,
to: `/timeline/list/${list.id}`,
}));
@@ -70,7 +72,7 @@ async function chooseList(ev: MouseEvent): Promise<void> {
async function chooseAntenna(ev: MouseEvent): Promise<void> {
const antennas = await os.api('antennas/list');
const items = antennas.map(antenna => ({
- type: 'link',
+ type: 'link' as const,
text: antenna.name,
indicate: antenna.hasUnreadNote,
to: `/timeline/antenna/${antenna.id}`,
@@ -81,7 +83,7 @@ async function chooseAntenna(ev: MouseEvent): Promise<void> {
async function chooseChannel(ev: MouseEvent): Promise<void> {
const channels = await os.api('channels/followed');
const items = channels.map(channel => ({
- type: 'link',
+ type: 'link' as const,
text: channel.name,
indicate: channel.hasUnreadNote,
to: `/channels/${channel.id}`,
@@ -89,9 +91,10 @@ async function chooseChannel(ev: MouseEvent): Promise<void> {
os.popupMenu(items, ev.currentTarget ?? ev.target);
}
-function saveSrc(): void {
+function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global'): void {
defaultStore.set('tl', {
- src: src,
+ ...defaultStore.state.tl,
+ src: newSrc,
});
}
@@ -135,25 +138,25 @@ defineExpose({
title: i18n.ts._timelines.home,
icon: 'fas fa-home',
iconOnly: true,
- onClick: () => { src = 'home'; saveSrc(); },
+ onClick: () => { saveSrc('home'); },
}, ...(isLocalTimelineAvailable ? [{
active: src === 'local',
title: i18n.ts._timelines.local,
icon: 'fas fa-comments',
iconOnly: true,
- onClick: () => { src = 'local'; saveSrc(); },
+ onClick: () => { saveSrc('local'); },
}, {
active: src === 'social',
title: i18n.ts._timelines.social,
icon: 'fas fa-share-alt',
iconOnly: true,
- onClick: () => { src = 'social'; saveSrc(); },
+ onClick: () => { saveSrc('social'); },
}] : []), ...(isGlobalTimelineAvailable ? [{
active: src === 'global',
title: i18n.ts._timelines.global,
icon: 'fas fa-globe',
iconOnly: true,
- onClick: () => { src = 'global'; saveSrc(); },
+ onClick: () => { saveSrc('global'); },
}] : [])],
})),
});
diff --git a/packages/client/src/pages/user/index.activity.vue b/packages/client/src/pages/user/index.activity.vue
index 43a4f476f1..ebb251d4cc 100644
--- a/packages/client/src/pages/user/index.activity.vue
+++ b/packages/client/src/pages/user/index.activity.vue
@@ -3,7 +3,7 @@
<template #header><i class="fas fa-chart-bar" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template>
<div style="padding: 8px;">
- <MkChart src="per-user-notes" :args="{ user, withoutAll: true }" span="day" :limit="limit" :stacked="true" :detailed="false" :aspect-ratio="6"/>
+ <MkChart src="per-user-notes" :args="{ user, withoutAll: true }" span="day" :limit="limit" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="5"/>
</div>
</MkContainer>
</template>
diff --git a/packages/client/src/scripts/device-kind.ts b/packages/client/src/scripts/device-kind.ts
new file mode 100644
index 0000000000..544cac0604
--- /dev/null
+++ b/packages/client/src/scripts/device-kind.ts
@@ -0,0 +1,10 @@
+import { defaultStore } from '@/store';
+
+const ua = navigator.userAgent.toLowerCase();
+const isTablet = /ipad/.test(ua) || (/mobile|iphone|android/.test(ua) && window.innerWidth > 700);
+const isSmartphone = !isTablet && /mobile|iphone|android/.test(ua);
+
+export const deviceKind = defaultStore.state.overridedDeviceKind ? defaultStore.state.overridedDeviceKind
+ : isSmartphone ? 'smartphone'
+ : isTablet ? 'tablet'
+ : 'desktop';
diff --git a/packages/client/src/scripts/is-mobile.ts b/packages/client/src/scripts/is-mobile.ts
deleted file mode 100644
index 60cb59f91e..0000000000
--- a/packages/client/src/scripts/is-mobile.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-const ua = navigator.userAgent.toLowerCase();
-export const isMobile = /mobile|iphone|ipad|android/.test(ua);
diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts
index b80fc8bbe3..0e71060cda 100644
--- a/packages/client/src/store.ts
+++ b/packages/client/src/store.ts
@@ -106,6 +106,10 @@ export const defaultStore = markRaw(new Storage('base', {
}
},
+ overridedDeviceKind: {
+ where: 'device',
+ default: null as null | 'smartphone' | 'tablet' | 'desktop',
+ },
serverDisconnectedBehavior: {
where: 'device',
default: 'quiet' as 'quiet' | 'reload' | 'dialog'