diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-02-09 14:50:38 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2022-02-09 14:50:38 +0900 |
| commit | 5f985ee832eed61e31ceb51eaa1c51810ad6de39 (patch) | |
| tree | 60cd65d92e377ea0a0f0d2bc3b767f8f26196310 /packages/client/src | |
| parent | Merge branch 'develop' (diff) | |
| parent | 12.104.0 (diff) | |
| download | misskey-5f985ee832eed61e31ceb51eaa1c51810ad6de39.tar.gz misskey-5f985ee832eed61e31ceb51eaa1c51810ad6de39.tar.bz2 misskey-5f985ee832eed61e31ceb51eaa1c51810ad6de39.zip | |
Merge branch 'develop'
Diffstat (limited to 'packages/client/src')
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' |