summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author_ <phy.public@gmail.com>2023-10-30 09:12:20 +0900
committerGitHub <noreply@github.com>2023-10-30 09:12:20 +0900
commitc239058624dcd880ec1c5f3c436f3a2a06fc22c3 (patch)
tree1ffe780569e2535337d3bebdff1d562b90922dcf
parentfix(backend): プロフィールの自己紹介欄のMFMを連合するよう... (diff)
downloadmisskey-c239058624dcd880ec1c5f3c436f3a2a06fc22c3.tar.gz
misskey-c239058624dcd880ec1c5f3c436f3a2a06fc22c3.tar.bz2
misskey-c239058624dcd880ec1c5f3c436f3a2a06fc22c3.zip
feat(frontend): スワイプやボタンでタイムラインを再読込する機能 (#12113)
* pc reloading * add: disable TL websocket option * fix: stream disconnect when reload * add: pull to refresh * fix: pull to refresh * add changelog * fact: change to disableStreamingTimeline * lint * remove: en-US text * refactor * refactor * add license identifier * tweak * Update MkPullToRefresh.vue * Update MkPullToRefresh.vue * change name timeoutHeartBeat * tweak * :art: --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
-rw-r--r--CHANGELOG.md3
-rw-r--r--locales/index.d.ts4
-rw-r--r--locales/ja-JP.yml4
-rw-r--r--packages/frontend/src/boot/main-boot.ts3
-rw-r--r--packages/frontend/src/components/MkPageWindow.vue2
-rw-r--r--packages/frontend/src/components/MkPagination.vue6
-rw-r--r--packages/frontend/src/components/MkPullToRefresh.vue238
-rw-r--r--packages/frontend/src/components/MkTimeline.vue136
-rw-r--r--packages/frontend/src/pages/settings/general.vue3
-rw-r--r--packages/frontend/src/pages/timeline.vue52
-rw-r--r--packages/frontend/src/store.ts4
-rw-r--r--packages/frontend/src/stream.ts20
-rw-r--r--packages/frontend/src/ui/_common_/stream-indicator.vue3
-rw-r--r--packages/frontend/src/ui/universal.vue2
14 files changed, 400 insertions, 80 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index abfb0692bd..c46d1c78bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,9 @@
- Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました
- 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください
https://misskey-hub.net/docs/advanced/publish-on-your-website.html
+- Enhance: スワイプしてタイムラインを再読込できるように
+ - PCの場合は右上のボタンからでも再読込できます
+- Enhance: タイムラインの自動更新を無効にできるように
- Enhance: コードのシンタックスハイライトエンジンをShikiに変更
- AiScriptのシンタックスハイライトに対応
- MFMでAiScriptをハイライトする場合、コードブロックの開始部分を ` ```is ` もしくは ` ```aiscript ` としてください
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 5f156b617a..e99b278c8c 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -1152,6 +1152,10 @@ export interface Locale {
"angle": string;
"flip": string;
"showAvatarDecorations": string;
+ "releaseToRefresh": string;
+ "refreshing": string;
+ "pullDownToRefresh": string;
+ "disableStreamingTimeline": string;
"_announcement": {
"forExistingUsers": string;
"forExistingUsersDescription": string;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index f7e73042b7..32877b806a 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1149,6 +1149,10 @@ detach: "外す"
angle: "角度"
flip: "反転"
showAvatarDecorations: "アイコンのデコレーションを表示"
+releaseToRefresh: "離してリロード"
+refreshing: "リロード中"
+pullDownToRefresh: "引っ張ってリロード"
+disableStreamingTimeline: "タイムラインのリアルタイム更新を無効にする"
_announcement:
forExistingUsers: "既存ユーザーのみ"
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index f2af951d63..800a3b079f 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -8,7 +8,7 @@ import { common } from './common.js';
import { version, ui, lang, updateLocale } from '@/config.js';
import { i18n, updateI18n } from '@/i18n.js';
import { confirm, alert, post, popup, toast } from '@/os.js';
-import { useStream } from '@/stream.js';
+import { useStream, isReloading } from '@/stream.js';
import * as sound from '@/scripts/sound.js';
import { $i, refreshAccount, login, updateAccount, signout } from '@/account.js';
import { defaultStore, ColdDeviceStorage } from '@/store.js';
@@ -39,6 +39,7 @@ export async function mainBoot() {
let reloadDialogShowing = false;
stream.on('_disconnected_', async () => {
+ if (isReloading) return;
if (defaultStore.state.serverDisconnectedBehavior === 'reload') {
location.reload();
} else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') {
diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue
index 3b273ac545..5edae1bc3c 100644
--- a/packages/frontend/src/components/MkPageWindow.vue
+++ b/packages/frontend/src/components/MkPageWindow.vue
@@ -166,6 +166,8 @@ defineExpose({
<style lang="scss" module>
.root {
+ overscroll-behavior: none;
+
min-height: 100%;
background: var(--bg);
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 80b469f632..5643de7683 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -102,6 +102,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(ev: 'queue', count: number): void;
+ (ev: 'status', error: boolean): void;
}>();
let rootEl = $shallowRef<HTMLElement>();
@@ -193,6 +194,11 @@ watch(queue, (a, b) => {
emit('queue', queue.value.size);
}, { deep: true });
+watch(error, (n, o) => {
+ if (n === o) return;
+ emit('status', n);
+});
+
async function init(): Promise<void> {
items.value = new Map();
queue.value = new Map();
diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue
new file mode 100644
index 0000000000..0b5ae8e826
--- /dev/null
+++ b/packages/frontend/src/components/MkPullToRefresh.vue
@@ -0,0 +1,238 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div ref="rootEl">
+ <div v-if="isPullStart" :class="$style.frame" :style="`--frame-min-height: ${currentHeight / 3}px;`">
+ <div :class="$style.frameContent">
+ <MkLoading v-if="isRefreshing" :class="$style.loader" :em="true"/>
+ <i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPullEnd }]"></i>
+ <div :class="$style.text">
+ <template v-if="isPullEnd">{{ i18n.ts.releaseToRefresh }}</template>
+ <template v-else-if="isRefreshing">{{ i18n.ts.refreshing }}</template>
+ <template v-else>{{ i18n.ts.pullDownToRefresh }}</template>
+ </div>
+ </div>
+ </div>
+ <div :class="{ [$style.slotClip]: isPullStart }">
+ <slot/>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted } from 'vue';
+import { deviceKind } from '@/scripts/device-kind.js';
+import { i18n } from '@/i18n.js';
+
+const SCROLL_STOP = 10;
+const MAX_PULL_DISTANCE = Infinity;
+const FIRE_THRESHOLD = 200;
+const RELEASE_TRANSITION_DURATION = 200;
+
+let isPullStart = $ref(false);
+let isPullEnd = $ref(false);
+let isRefreshing = $ref(false);
+let currentHeight = $ref(0);
+
+let supportPointerDesktop = false;
+let startScreenY: number | null = null;
+
+const rootEl = $shallowRef<HTMLDivElement>();
+let scrollEl: HTMLElement | null = null;
+
+let disabled = false;
+
+const emits = defineEmits<{
+ (ev: 'refresh'): void;
+}>();
+
+function getScrollableParentElement(node) {
+ if (node == null) {
+ return null;
+ }
+
+ if (node.scrollHeight > node.clientHeight) {
+ return node;
+ } else {
+ return getScrollableParentElement(node.parentNode);
+ }
+}
+
+function getScreenY(event) {
+ if (supportPointerDesktop) {
+ return event.screenY;
+ }
+ return event.touches[0].screenY;
+}
+
+function moveStart(event) {
+ if (!isPullStart && !isRefreshing && !disabled) {
+ isPullStart = true;
+ startScreenY = getScreenY(event);
+ currentHeight = 0;
+ }
+}
+
+function moveBySystem(to: number): Promise<void> {
+ return new Promise(r => {
+ const startHeight = currentHeight;
+ const overHeight = currentHeight - to;
+ if (overHeight < 1) {
+ r();
+ return;
+ }
+ const startTime = Date.now();
+ let intervalId = setInterval(() => {
+ const time = Date.now() - startTime;
+ if (time > RELEASE_TRANSITION_DURATION) {
+ currentHeight = to;
+ clearInterval(intervalId);
+ r();
+ return;
+ }
+ const nextHeight = startHeight - (overHeight / RELEASE_TRANSITION_DURATION) * time;
+ if (currentHeight < nextHeight) return;
+ currentHeight = nextHeight;
+ }, 1);
+ });
+}
+
+async function fixOverContent() {
+ if (currentHeight > FIRE_THRESHOLD) {
+ await moveBySystem(FIRE_THRESHOLD);
+ }
+}
+
+async function closeContent() {
+ if (currentHeight > 0) {
+ await moveBySystem(0);
+ }
+}
+
+function moveEnd() {
+ if (isPullStart && !isRefreshing) {
+ startScreenY = null;
+ if (isPullEnd) {
+ isPullEnd = false;
+ isRefreshing = true;
+ fixOverContent().then(() => emits('refresh'));
+ } else {
+ closeContent().then(() => isPullStart = false);
+ }
+ }
+}
+
+function moving(event) {
+ if (!isPullStart || isRefreshing || disabled) return;
+
+ if (!scrollEl) {
+ scrollEl = getScrollableParentElement(rootEl);
+ }
+ if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + currentHeight)) {
+ currentHeight = 0;
+ isPullEnd = false;
+ moveEnd();
+ return;
+ }
+
+ if (startScreenY === null) {
+ startScreenY = getScreenY(event);
+ }
+ const moveScreenY = getScreenY(event);
+
+ const moveHeight = moveScreenY - startScreenY!;
+ currentHeight = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE);
+
+ isPullEnd = currentHeight >= FIRE_THRESHOLD;
+}
+
+/**
+ * emit(refresh)が完了したことを知らせる関数
+ *
+ * タイムアウトがないのでこれを最終的に実行しないと出たままになる
+ */
+function refreshFinished() {
+ closeContent().then(() => {
+ isPullStart = false;
+ isRefreshing = false;
+ });
+}
+
+function setDisabled(value) {
+ disabled = value;
+}
+
+onMounted(() => {
+ // マウス操作でpull to refreshするのは不便そう
+ //supportPointerDesktop = !!window.PointerEvent && deviceKind === 'desktop';
+
+ if (supportPointerDesktop) {
+ rootEl.addEventListener('pointerdown', moveStart);
+ // ポインターの場合、ポップアップ系の動作をするとdownだけ発火されてupが発火されないため
+ window.addEventListener('pointerup', moveEnd);
+ rootEl.addEventListener('pointermove', moving, { passive: true });
+ } else {
+ rootEl.addEventListener('touchstart', moveStart);
+ rootEl.addEventListener('touchend', moveEnd);
+ rootEl.addEventListener('touchmove', moving, { passive: true });
+ }
+});
+
+onUnmounted(() => {
+ if (supportPointerDesktop) window.removeEventListener('pointerup', moveEnd);
+});
+
+defineExpose({
+ refreshFinished,
+ setDisabled,
+});
+</script>
+
+<style lang="scss" module>
+.frame {
+ position: relative;
+ overflow: clip;
+
+ width: 100%;
+ min-height: var(--frame-min-height, 0px);
+
+ mask-image: linear-gradient(90deg, #000 0%, #000 80%, transparent);
+ -webkit-mask-image: -webkit-linear-gradient(90deg, #000 0%, #000 80%, transparent);
+
+ pointer-events: none;
+}
+
+.frameContent {
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ margin: 5px 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ font-size: 14px;
+
+ > .icon, > .loader {
+ margin: 6px 0;
+ }
+
+ > .icon {
+ transition: transform .25s;
+
+ &.refresh {
+ transform: rotate(180deg);
+ }
+ }
+
+ > .text {
+ margin: 5px 0;
+ }
+}
+
+.slotClip {
+ overflow-y: clip;
+}
+</style>
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index cdd72febd1..a2ada35f91 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -4,13 +4,16 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
+<MkPullToRefresh ref="prComponent" @refresh="() => reloadTimeline(true)">
+ <MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)" @status="prComponent.setDisabled($event)"/>
+</MkPullToRefresh>
</template>
<script lang="ts" setup>
import { computed, provide, onUnmounted } from 'vue';
import MkNotes from '@/components/MkNotes.vue';
-import { useStream } from '@/stream.js';
+import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
+import { useStream, reloadStream } from '@/stream.js';
import * as sound from '@/scripts/sound.js';
import { $i } from '@/account.js';
import { instance } from '@/instance.js';
@@ -39,6 +42,7 @@ const emit = defineEmits<{
provide('inChannel', computed(() => props.src === 'channel'));
+const prComponent: InstanceType<typeof MkPullToRefresh> = $ref();
const tlComponent: InstanceType<typeof MkNotes> = $ref();
let tlNotesCount = 0;
@@ -65,29 +69,73 @@ let connection;
let connection2;
const stream = useStream();
+const connectChannel = () => {
+ if (props.src === 'antenna') {
+ connection = stream.useChannel('antenna', {
+ antennaId: props.antenna,
+ });
+ } else if (props.src === 'home') {
+ connection = stream.useChannel('homeTimeline', {
+ withRenotes: props.withRenotes,
+ withFiles: props.onlyFiles ? true : undefined,
+ });
+ connection2 = stream.useChannel('main');
+ } else if (props.src === 'local') {
+ connection = stream.useChannel('localTimeline', {
+ withRenotes: props.withRenotes,
+ withReplies: props.withReplies,
+ withFiles: props.onlyFiles ? true : undefined,
+ });
+ } else if (props.src === 'social') {
+ connection = stream.useChannel('hybridTimeline', {
+ withRenotes: props.withRenotes,
+ withReplies: props.withReplies,
+ withFiles: props.onlyFiles ? true : undefined,
+ });
+ } else if (props.src === 'global') {
+ connection = stream.useChannel('globalTimeline', {
+ withRenotes: props.withRenotes,
+ withFiles: props.onlyFiles ? true : undefined,
+ });
+ } else if (props.src === 'mentions') {
+ connection = stream.useChannel('main');
+ connection.on('mention', prepend);
+ } else if (props.src === 'directs') {
+ const onNote = note => {
+ if (note.visibility === 'specified') {
+ prepend(note);
+ }
+ };
+ connection = stream.useChannel('main');
+ connection.on('mention', onNote);
+ } else if (props.src === 'list') {
+ connection = stream.useChannel('userList', {
+ withFiles: props.onlyFiles ? true : undefined,
+ listId: props.list,
+ });
+ } else if (props.src === 'channel') {
+ connection = stream.useChannel('channel', {
+ channelId: props.channel,
+ });
+ } else if (props.src === 'role') {
+ connection = stream.useChannel('roleTimeline', {
+ roleId: props.role,
+ });
+ }
+ if (props.src !== 'directs' || props.src !== 'mentions') connection.on('note', prepend);
+};
if (props.src === 'antenna') {
endpoint = 'antennas/notes';
query = {
antennaId: props.antenna,
};
- connection = stream.useChannel('antenna', {
- antennaId: props.antenna,
- });
- connection.on('note', prepend);
} else if (props.src === 'home') {
endpoint = 'notes/timeline';
query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
};
- connection = stream.useChannel('homeTimeline', {
- withRenotes: props.withRenotes,
- withFiles: props.onlyFiles ? true : undefined,
- });
- connection.on('note', prepend);
-
- connection2 = stream.useChannel('main');
} else if (props.src === 'local') {
endpoint = 'notes/local-timeline';
query = {
@@ -95,12 +143,6 @@ if (props.src === 'antenna') {
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
};
- connection = stream.useChannel('localTimeline', {
- withRenotes: props.withRenotes,
- withReplies: props.withReplies,
- withFiles: props.onlyFiles ? true : undefined,
- });
- connection.on('note', prepend);
} else if (props.src === 'social') {
endpoint = 'notes/hybrid-timeline';
query = {
@@ -108,68 +150,44 @@ if (props.src === 'antenna') {
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
};
- connection = stream.useChannel('hybridTimeline', {
- withRenotes: props.withRenotes,
- withReplies: props.withReplies,
- withFiles: props.onlyFiles ? true : undefined,
- });
- connection.on('note', prepend);
} else if (props.src === 'global') {
endpoint = 'notes/global-timeline';
query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
};
- connection = stream.useChannel('globalTimeline', {
- withRenotes: props.withRenotes,
- withFiles: props.onlyFiles ? true : undefined,
- });
- connection.on('note', prepend);
} else if (props.src === 'mentions') {
endpoint = 'notes/mentions';
- connection = stream.useChannel('main');
- connection.on('mention', prepend);
} else if (props.src === 'directs') {
endpoint = 'notes/mentions';
query = {
visibility: 'specified',
};
- const onNote = note => {
- if (note.visibility === 'specified') {
- prepend(note);
- }
- };
- connection = stream.useChannel('main');
- connection.on('mention', onNote);
} else if (props.src === 'list') {
endpoint = 'notes/user-list-timeline';
query = {
withFiles: props.onlyFiles ? true : undefined,
listId: props.list,
};
- connection = stream.useChannel('userList', {
- withFiles: props.onlyFiles ? true : undefined,
- listId: props.list,
- });
- connection.on('note', prepend);
} else if (props.src === 'channel') {
endpoint = 'channels/timeline';
query = {
channelId: props.channel,
};
- connection = stream.useChannel('channel', {
- channelId: props.channel,
- });
- connection.on('note', prepend);
} else if (props.src === 'role') {
endpoint = 'roles/notes';
query = {
roleId: props.role,
};
- connection = stream.useChannel('roleTimeline', {
- roleId: props.role,
+}
+
+if (!defaultStore.state.disableStreamingTimeline) {
+ connectChannel();
+
+ onUnmounted(() => {
+ connection.dispose();
+ if (connection2) connection2.dispose();
});
- connection.on('note', prepend);
}
const pagination = {
@@ -178,9 +196,19 @@ const pagination = {
params: query,
};
-onUnmounted(() => {
- connection.dispose();
- if (connection2) connection2.dispose();
+const reloadTimeline = (fromPR = false) => {
+ tlNotesCount = 0;
+
+ tlComponent.pagingComponent?.reload().then(() => {
+ reloadStream();
+ if (fromPR) prComponent.refreshFinished();
+ });
+};
+
+//const pullRefresh = () => reloadTimeline(true);
+
+defineExpose({
+ reloadTimeline,
});
/* TODO
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index 323dfc0722..85d038e3d1 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -151,6 +151,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="imageNewTab">{{ i18n.ts.openImageInNewTab }}</MkSwitch>
<MkSwitch v-model="enableInfiniteScroll">{{ i18n.ts.enableInfiniteScroll }}</MkSwitch>
<MkSwitch v-model="keepScreenOn">{{ i18n.ts.keepScreenOn }}</MkSwitch>
+ <MkSwitch v-model="disableStreamingTimeline">{{ i18n.ts.disableStreamingTimeline }}</MkSwitch>
</div>
<MkSelect v-model="serverDisconnectedBehavior">
<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
@@ -253,6 +254,7 @@ const notificationPosition = computed(defaultStore.makeGetterSetter('notificatio
const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis'));
const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn'));
const defaultWithReplies = computed(defaultStore.makeGetterSetter('defaultWithReplies'));
+const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline'));
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
@@ -289,6 +291,7 @@ watch([
reactionsDisplaySize,
highlightSensitiveMedia,
keepScreenOn,
+ disableStreamingTimeline,
], async () => {
await reloadAsk();
});
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index 8cc540779b..f601bc8a85 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -44,6 +44,7 @@ import { $i } from '@/account.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { miLocalStorage } from '@/local-storage.js';
import { antennasCache, userListsCache } from '@/cache.js';
+import { deviceKind } from '@/scripts/device-kind.js';
provide('shouldOmitHeaderTitle', true);
@@ -139,27 +140,36 @@ function focus(): void {
tlComponent.focus();
}
-const headerActions = $computed(() => [{
- icon: 'ti ti-dots',
- text: i18n.ts.options,
- handler: (ev) => {
- os.popupMenu([{
- type: 'switch',
- text: i18n.ts.showRenotes,
- icon: 'ti ti-repeat',
- ref: $$(withRenotes),
- }, src === 'local' || src === 'social' ? {
- type: 'switch',
- text: i18n.ts.showRepliesToOthersInTimeline,
- ref: $$(withReplies),
- } : undefined, {
- type: 'switch',
- text: i18n.ts.fileAttachedOnly,
- icon: 'ti ti-photo',
- ref: $$(onlyFiles),
- }], ev.currentTarget ?? ev.target);
- },
-}]);
+const headerActions = $computed(() => [
+ ...[deviceKind === 'desktop' ? {
+ icon: 'ti ti-refresh',
+ text: i18n.ts.reload,
+ handler: (ev) => {
+ console.log('called');
+ tlComponent.reloadTimeline();
+ },
+ } : {}], {
+ icon: 'ti ti-dots',
+ text: i18n.ts.options,
+ handler: (ev) => {
+ os.popupMenu([{
+ type: 'switch',
+ text: i18n.ts.showRenotes,
+ icon: 'ti ti-repeat',
+ ref: $$(withRenotes),
+ }, src === 'local' || src === 'social' ? {
+ type: 'switch',
+ text: i18n.ts.showRepliesToOthersInTimeline,
+ ref: $$(withReplies),
+ } : undefined, {
+ type: 'switch',
+ text: i18n.ts.fileAttachedOnly,
+ icon: 'ti ti-photo',
+ ref: $$(onlyFiles),
+ }], ev.currentTarget ?? ev.target);
+ },
+ }
+]);
const headerTabs = $computed(() => [...(defaultStore.reactiveState.pinnedUserLists.value.map(l => ({
key: 'list:' + l.id,
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 6196e684e1..803f2f648d 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -369,6 +369,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account',
default: false,
},
+ disableStreamingTimeline: {
+ where: 'device',
+ default: false,
+ },
}));
// TODO: 他のタブと永続化されたstateを同期
diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts
index 27fce4d4b8..1e2d31480c 100644
--- a/packages/frontend/src/stream.ts
+++ b/packages/frontend/src/stream.ts
@@ -9,6 +9,9 @@ import { $i } from '@/account.js';
import { url } from '@/config.js';
let stream: Misskey.Stream | null = null;
+let timeoutHeartBeat: number | null = null;
+
+export let isReloading: boolean = false;
export function useStream(): Misskey.Stream {
if (stream) return stream;
@@ -17,7 +20,20 @@ export function useStream(): Misskey.Stream {
token: $i.token,
} : null));
- window.setTimeout(heartbeat, 1000 * 60);
+ timeoutHeartBeat = window.setTimeout(heartbeat, 1000 * 60);
+
+ return stream;
+}
+
+export function reloadStream() {
+ if (!stream) return useStream();
+ if (timeoutHeartBeat) window.clearTimeout(timeoutHeartBeat);
+ isReloading = true;
+
+ stream.close();
+ stream.once('_connected_', () => isReloading = false);
+ stream.stream.reconnect();
+ timeoutHeartBeat = window.setTimeout(heartbeat, 1000 * 60);
return stream;
}
@@ -26,5 +42,5 @@ function heartbeat(): void {
if (stream != null && document.visibilityState === 'visible') {
stream.heartbeat();
}
- window.setTimeout(heartbeat, 1000 * 60);
+ timeoutHeartBeat = window.setTimeout(heartbeat, 1000 * 60);
}
diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue
index b09221f5d2..c3107b4e40 100644
--- a/packages/frontend/src/ui/_common_/stream-indicator.vue
+++ b/packages/frontend/src/ui/_common_/stream-indicator.vue
@@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onUnmounted } from 'vue';
-import { useStream } from '@/stream.js';
+import { useStream, isReloading } from '@/stream.js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
@@ -26,6 +26,7 @@ const zIndex = os.claimZIndex('high');
let hasDisconnected = $ref(false);
function onDisconnected() {
+ if (isReloading) return;
hasDisconnected = true;
}
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue
index cc66bb47a4..c9fb8a931d 100644
--- a/packages/frontend/src/ui/universal.vue
+++ b/packages/frontend/src/ui/universal.vue
@@ -319,7 +319,7 @@ $widgets-hide-threshold: 1090px;
min-width: 0;
overflow: auto;
overflow-y: scroll;
- overscroll-behavior: contain;
+ overscroll-behavior: none;
background: var(--bg);
}