feat: pull-to-refresh

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
Co-authored-by: u1-liquid <17376330+u1-liquid@users.noreply.github.com>
Co-authored-by: naskya <m@naskya.net>
This commit is contained in:
Fairy-Phy 2023-11-30 04:28:32 +09:00 committed by naskya
parent b0cc6cd332
commit b980171c0b
Signed by: naskya
GPG key ID: 164DFF24E2D40139
38 changed files with 596 additions and 197 deletions

View file

@ -129,6 +129,7 @@
うまく動いていそうだったら本家に push されます
- 画面を下に引いてタイムラインなどを更新する機能を追加Misskey から取り込み)
- Docker ユーザーも PWA のアイコンを変更できるように
- 以下の画像ファイルを追加して Docker コンテナを再起動し、ブラウザのキャッシュを削除して PWA をインストールしてみてください
- `custom/assets/favicon.png`

View file

@ -1165,6 +1165,10 @@ detectPostLanguage: "Automatically detect the language and show a translate butt
vibrate: "Play vibrations"
openServerInfo: "Show server information by clicking the server ticker on a post"
iconSet: "Icon set"
releaseToReload: "Release to reload"
reloading: "Reloading"
pullDownToReload: "Pull down to reload"
enableTimelineStreaming: "Update timelines automatically"
_sensitiveMediaDetection:
description: "Reduces the effort of server moderation through automatically recognizing

View file

@ -2032,3 +2032,7 @@ _iconSets:
duotone: "2色"
moreUrls: "固定するページ"
moreUrlsDescription: "左下のヘルプメニューに固定したいページを以下の形式で、改行区切りで入力してください:\n\"表示名\": https://example.com/"
releaseToReload: "離して再読み込み"
reloading: "読み込み中"
pullDownToReload: "下に引っ張って再読み込み"
enableTimelineStreaming: "タイムラインを自動で更新する"

View file

@ -145,7 +145,7 @@ import XNavFolder from "@/components/MkDrive.navFolder.vue";
import XFolder from "@/components/MkDrive.folder.vue";
import XFile from "@/components/MkDrive.file.vue";
import * as os from "@/os";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import { defaultStore } from "@/store";
import { i18n } from "@/i18n";
import { uploadFile, uploads } from "@/scripts/upload";
@ -178,6 +178,8 @@ const emit = defineEmits<{
(ev: "open-folder", v: firefish.entities.DriveFolder): void;
}>();
const stream = useStream();
const loadMoreFiles = ref<InstanceType<typeof MkButton>>();
const fileInput = ref<HTMLInputElement>();

View file

@ -64,7 +64,7 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import type * as firefish from "firefish-js";
import * as os from "@/os";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import { i18n } from "@/i18n";
import { $i } from "@/reactiveAccount";
import { getUserMenu } from "@/scripts/get-user-menu";
@ -73,6 +73,7 @@ import { vibrate } from "@/scripts/vibrate";
import icon from "@/scripts/icon";
const router = useRouter();
const stream = useStream();
const emit = defineEmits(["refresh"]);
const props = withDefaults(

View file

@ -186,7 +186,7 @@ import { i18n } from "@/i18n";
import { getNoteMenu } from "@/scripts/get-note-menu";
import { useNoteCapture } from "@/scripts/use-note-capture";
import { deepClone } from "@/scripts/clone";
import { stream } from "@/stream";
import { useStream } from "@/stream";
// import icon from "@/scripts/icon";
const props = defineProps<{
@ -194,6 +194,8 @@ const props = defineProps<{
pinned?: boolean;
}>();
const stream = useStream();
const tab = ref("replies");
const note = ref(deepClone(props.note));

View file

@ -1,5 +1,5 @@
<template>
<MkPagination ref="pagingComponent" :pagination="pagination">
<MkPagination ref="pagingComponent" :pagination="pagination" :disable-auto-load="disableAutoLoad">
<template #empty>
<div class="_fullinfo">
<img
@ -48,6 +48,7 @@ const tlEl = ref<HTMLElement>();
defineProps<{
pagination: Paging;
noGap?: boolean;
disableAutoLoad?: boolean;
}>();
const pagingComponent = ref<InstanceType<typeof MkPagination>>();

View file

@ -272,7 +272,7 @@ import { notePage } from "@/filters/note";
import { userPage } from "@/filters/user";
import { i18n } from "@/i18n";
import * as os from "@/os";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import { useTooltip } from "@/scripts/use-tooltip";
import { defaultStore } from "@/store";
import { instance } from "@/instance";
@ -290,6 +290,8 @@ const props = withDefaults(
},
);
const stream = useStream();
const elRef = ref<HTMLElement>(null);
const reactionRef = ref(null);

View file

@ -1,59 +1,62 @@
<template>
<MkPagination ref="pagingComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img
src="/static-assets/badges/info.webp"
class="_ghost"
alt="Info"
/>
<div>{{ i18n.ts.noNotifications }}</div>
</div>
</template>
<MkPullToRefresh :refresher="() => reload()">
<MkPagination ref="pagingComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img
src="/static-assets/badges/info.webp"
class="_ghost"
alt="Info"
/>
<div>{{ i18n.ts.noNotifications }}</div>
</div>
</template>
<template #default="{ items: notifications }">
<XList
v-slot="{ item: notification }"
class="elsfgstc"
:items="notifications"
:no-gap="true"
>
<XNote
v-if="
['reply', 'quote', 'mention'].includes(
notification.type,
)
"
:key="notification.id"
:note="notification.note"
:collapsed-reply="
notification.type === 'reply' ||
(notification.type === 'mention' &&
notification.note.replyId != null)
"
/>
<XNotification
v-else
:key="notification.id"
:notification="notification"
:with-time="true"
:full="true"
class="_panel notification"
/>
</XList>
</template>
</MkPagination>
<template #default="{ items: notifications }">
<XList
v-slot="{ item: notification }"
class="elsfgstc"
:items="notifications"
:no-gap="true"
>
<XNote
v-if="
['reply', 'quote', 'mention'].includes(
notification.type,
)
"
:key="notification.id"
:note="notification.note"
:collapsed-reply="
notification.type === 'reply' ||
(notification.type === 'mention' &&
notification.note.replyId != null)
"
/>
<XNotification
v-else
:key="notification.id"
:notification="notification"
:with-time="true"
:full="true"
class="_panel notification"
/>
</XList>
</template>
</MkPagination>
</MkPullToRefresh>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from "vue";
import { computed, onActivated, onMounted, onUnmounted, ref } from "vue";
import type { notificationTypes } from "firefish-js";
import type { Paging } from "@/components/MkPagination.vue";
import MkPagination from "@/components/MkPagination.vue";
import XNotification from "@/components/MkNotification.vue";
import MkPullToRefresh from "@/components/MkPullToRefresh.vue";
import XList from "@/components/MkDateSeparatedList.vue";
import XNote from "@/components/MkNote.vue";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import { $i } from "@/reactiveAccount";
import { i18n } from "@/i18n";
@ -62,6 +65,8 @@ const props = defineProps<{
unreadOnly?: boolean;
}>();
const stream = useStream();
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
const pagination: Paging = {
@ -87,13 +92,21 @@ const onNotification = (notification) => {
}
if (!isMuted) {
pagingComponent.value.prepend({
pagingComponent.value?.prepend({
...notification,
isRead: document.visibilityState === "visible",
});
}
};
function reload(): Promise<void> {
return new Promise<void>((res) => {
pagingComponent.value?.reload().then(() => {
res();
});
});
};
let connection;
onMounted(() => {
@ -133,6 +146,12 @@ onMounted(() => {
});
});
onActivated(() => {
pagingComponent.value?.reload();
connection = useStream().useChannel("main");
connection.on("notification", onNotification);
});
onUnmounted(() => {
if (connection) connection.dispose();
});

View file

@ -155,6 +155,7 @@ defineExpose({
<style lang="scss" scoped>
.yrolvcoq {
overscroll-behavior: none;
min-height: 100%;
background: var(--bg);
}

View file

@ -119,6 +119,7 @@ const props = withDefaults(
const emit = defineEmits<{
(ev: "queue", count: number): void;
(ev: "status", error: boolean): void;
}>();
interface Item {
@ -189,12 +190,12 @@ const init = async (): Promise<void> => {
);
};
const reload = (): void => {
const reload = (): Promise<void> => {
items.value = [];
init();
return init();
};
const refresh = async (): void => {
const refresh = async (): Promise<void> => {
const params = props.pagination.params
? isRef(props.pagination.params)
? props.pagination.params.value
@ -259,12 +260,12 @@ const fetchMore = async (): Promise<void> => {
offset: offset.value,
}
: props.pagination.reversed
? {
sinceId: items.value[0].id,
}
: {
untilId: items.value[items.value.length - 1].id,
}),
? {
sinceId: items.value[0].id,
}
: {
untilId: items.value[items.value.length - 1].id,
}),
})
.then(
(res) => {
@ -320,12 +321,12 @@ const fetchMoreAhead = async (): Promise<void> => {
offset: offset.value,
}
: props.pagination.reversed
? {
untilId: items.value[0].id,
}
: {
sinceId: items.value[items.value.length - 1].id,
}),
? {
untilId: items.value[0].id,
}
: {
sinceId: items.value[items.value.length - 1].id,
}),
})
.then(
(res) => {
@ -450,6 +451,11 @@ watch(
{ deep: true },
);
watch(error, (n, o) => {
if (n === o) return;
emit("status", n);
});
init();
onActivated(() => {

View file

@ -279,7 +279,7 @@ import { extractMentions } from "@/scripts/extract-mentions";
import { formatTimeString } from "@/scripts/format-time-string";
import { Autocomplete } from "@/scripts/autocomplete";
import * as os from "@/os";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import { selectFiles } from "@/scripts/select-file";
import { defaultStore, notePostInterruptors, postFormActions } from "@/store";
import MkInfo from "@/components/MkInfo.vue";
@ -325,6 +325,8 @@ const emit = defineEmits<{
(ev: "esc"): void;
}>();
const stream = useStream();
const textareaEl = ref<HTMLTextAreaElement | null>(null);
const cwInputEl = ref<HTMLInputElement | null>(null);
const hashtagsInputEl = ref<HTMLInputElement | null>(null);

View file

@ -0,0 +1,252 @@
<template>
<div ref="rootEl">
<div
v-if="pullStarted"
:class="$style.frame"
:style="`--frame-min-height: ${
pullDistance /
(PULL_BRAKE_BASE + pullDistance / PULL_BRAKE_FACTOR)
}px;`"
>
<div :class="$style.frameContent">
<MkLoading
v-if="isRefreshing"
:class="$style.loader"
:em="true"
/>
<i
v-else
:class="[
icon('ph-arrow-down'),
$style.icon,
{ [$style.refresh]: pullEnded },
]"
></i>
<div :class="$style.text">
<template v-if="pullEnded">{{
i18n.ts.releaseToReload
}}</template>
<template v-else-if="isRefreshing">{{
i18n.ts.reloading
}}</template>
<template v-else>{{ i18n.ts.pullDownToReload }}</template>
</div>
</div>
</div>
<div :class="{ [$style.slotClip]: pullStarted }">
<slot />
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref, shallowRef } from "vue";
import { deviceKind } from "@/scripts/device-kind";
import { i18n } from "@/i18n";
import icon from "@/scripts/icon";
const SCROLL_STOP = 10;
const MAX_PULL_DISTANCE = Infinity;
const FIRE_THRESHOLD = 230;
const RELEASE_TRANSITION_DURATION = 200;
const PULL_BRAKE_BASE = 1.5;
const PULL_BRAKE_FACTOR = 170;
const pullStarted = ref(false);
const pullEnded = ref(false);
const isRefreshing = ref(false);
const pullDistance = ref(0);
let disabled = false;
let supportPointerDesktop = false;
let startScreenY: number | null = null;
const rootEl = shallowRef<HTMLDivElement>();
let scrollEl: HTMLElement | null = null;
const props = withDefaults(
defineProps<{
refresher: () => Promise<void>;
}>(),
{
refresher: () => Promise.resolve(),
},
);
const emits = defineEmits<{
(ev: "refresh"): void;
}>();
function getScrollableParentElement(node) {
if (node == null) return null;
if (node.scrollHeight > node.clientHeight) return node;
return getScrollableParentElement(node.parentNode);
}
function getScreenY(event) {
if (supportPointerDesktop) return event.screenY;
return event.touches[0].screenY;
}
function moveStart(event) {
if (!pullStarted.value && !isRefreshing.value && !disabled) {
pullStarted.value = true;
startScreenY = getScreenY(event);
pullDistance.value = 0;
}
}
function moveBySystem(to: number): Promise<void> {
return new Promise((r) => {
const initialHeight = pullDistance.value;
const overHeight = pullDistance.value - to;
if (overHeight < 1) {
r();
return;
}
const startTime = Date.now();
let intervalId = setInterval(() => {
const time = Date.now() - startTime;
if (time > RELEASE_TRANSITION_DURATION) {
pullDistance.value = to;
clearInterval(intervalId);
r();
return;
}
const nextHeight =
initialHeight -
(overHeight / RELEASE_TRANSITION_DURATION) * time;
if (pullDistance.value < nextHeight) return;
pullDistance.value = nextHeight;
}, 1);
});
}
async function fixOverContent() {
if (pullDistance.value > FIRE_THRESHOLD) await moveBySystem(FIRE_THRESHOLD);
}
async function closeContent() {
if (pullDistance.value > 0) await moveBySystem(0);
}
function moveEnd() {
if (pullStarted.value && !isRefreshing.value) {
startScreenY = null;
if (pullEnded.value) {
pullEnded.value = false;
isRefreshing.value = true;
fixOverContent().then(() => {
emits("refresh");
props.refresher().then(() => {
refreshFinished();
});
});
} else {
closeContent().then(() => (pullStarted.value = false));
}
}
}
function moving(event) {
if (!pullStarted.value || isRefreshing.value || disabled) return;
if (scrollEl == null) scrollEl = getScrollableParentElement(rootEl);
if (
(scrollEl?.scrollTop ?? 0) >
(supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value)
) {
pullDistance.value = 0;
pullEnded.value = false;
moveEnd();
return;
}
if (startScreenY === null) {
startScreenY = getScreenY(event);
}
const moveScreenY = getScreenY(event);
const moveHeight = moveScreenY - startScreenY!;
pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE);
pullEnded.value = pullDistance.value >= FIRE_THRESHOLD;
}
function refreshFinished() {
closeContent().then(() => {
pullStarted.value = false;
isRefreshing.value = false;
});
}
function setDisabled(value) {
disabled = value;
}
onMounted(() => {
// supportPointerDesktop = !!window.PointerEvent && deviceKind === "desktop";
if (supportPointerDesktop) {
rootEl.value?.addEventListener("pointerdown", moveStart);
// "up" event won't be emmitted by mouse pointer on desktop
window.addEventListener("pointerup", moveEnd);
rootEl.value?.addEventListener("pointermove", moving, {
passive: true,
});
} else {
rootEl.value?.addEventListener("touchstart", moveStart);
rootEl.value?.addEventListener("touchend", moveEnd);
rootEl.value?.addEventListener("touchmove", moving, { passive: true });
}
});
onUnmounted(() => {
if (supportPointerDesktop) window.removeEventListener("pointerup", moveEnd);
});
defineExpose({
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 0.25s;
&.refresh {
transform: rotate(180deg);
}
}
> .text {
margin: 5px 0;
}
}
.slotClip {
overflow-y: clip;
}
</style>

View file

@ -13,30 +13,39 @@
<button
class="_buttonPrimary _shadow"
:class="{ instant: !defaultStore.state.animation }"
@click="tlComponent.scrollTop()"
@click="tlComponent?.scrollTop()"
>
{{ i18n.ts.newNoteRecived }}
<i :class="icon('ph-arrow-up', false)"></i>
</button>
</div>
<XNotes
ref="tlComponent"
:no-gap="!defaultStore.state.showGapBetweenNotesInTimeline"
:pagination="pagination"
@queue="(x) => (queue = x)"
/>
<MkPullToRefresh
ref="pullToRefreshComponent"
:refresher="() => reloadTimeline()"
>
<XNotes
ref="tlComponent"
:no-gap="!defaultStore.state.showGapBetweenNotesInTimeline"
:pagination="pagination"
@queue="(x) => (queue = x)"
@status="pullToRefreshComponent?.setDisabled($event)"
/>
</MkPullToRefresh>
</template>
<script lang="ts" setup>
import { computed, onUnmounted, provide, ref } from "vue";
import XNotes from "@/components/MkNotes.vue";
import MkPullToRefresh from "@/components/MkPullToRefresh.vue";
import MkInfo from "@/components/MkInfo.vue";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import * as sound from "@/scripts/sound";
import { $i } from "@/reactiveAccount";
import { i18n } from "@/i18n";
import { defaultStore } from "@/store";
import icon from "@/scripts/icon";
import type { Paging } from "@/components/MkPagination.vue";
import { Endpoints } from "firefish-js";
const props = defineProps<{
src: string;
@ -47,69 +56,37 @@ const props = defineProps<{
fileId?: string;
}>();
const queue = ref(0);
const emit = defineEmits<{
(ev: "note"): void;
(ev: "queue", count: number): void;
}>();
provide(
"inChannel",
computed(() => props.src === "channel"),
);
const tlComponent: InstanceType<typeof XNotes> = ref();
let endpoint = ""; // keyof Endpoints
let query, connection, connection2;
let tlHint: string;
let tlHintClosed: boolean;
let tlNotesCount = 0;
const queue = ref(0);
const prepend = (note) => {
tlComponent.value.pagingComponent?.prepend(note);
tlNotesCount++;
tlComponent.value?.pagingComponent?.prepend(note);
emit("note");
if (props.sound) {
sound.play($i && note.userId === $i.id ? "noteMy" : "note");
}
};
const onUserAdded = () => {
tlComponent.value.pagingComponent?.reload();
};
const onUserRemoved = () => {
tlComponent.value.pagingComponent?.reload();
};
const onChangeFollowing = () => {
if (!tlComponent.value.pagingComponent?.backed) {
tlComponent.value.pagingComponent?.reload();
}
};
let endpoint, query, connection, connection2, tlHint, tlHintClosed;
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 = {
withReplies: defaultStore.state.showTimelineReplies,
};
connection = stream.useChannel("homeTimeline", {
withReplies: defaultStore.state.showTimelineReplies,
});
connection.on("note", prepend);
connection2 = stream.useChannel("main");
connection2.on("follow", onChangeFollowing);
connection2.on("unfollow", onChangeFollowing);
tlHint = i18n.ts._tutorial.step5_3;
tlHintClosed = defaultStore.state.tlHomeHintClosed;
} else if (props.src === "local") {
@ -117,11 +94,6 @@ if (props.src === "antenna") {
query = {
withReplies: defaultStore.state.showTimelineReplies,
};
connection = stream.useChannel("localTimeline", {
withReplies: defaultStore.state.showTimelineReplies,
});
connection.on("note", prepend);
tlHint = i18n.ts._tutorial.step5_4;
tlHintClosed = defaultStore.state.tlLocalHintClosed;
} else if (props.src === "recommended") {
@ -129,11 +101,6 @@ if (props.src === "antenna") {
query = {
withReplies: defaultStore.state.showTimelineReplies,
};
connection = stream.useChannel("recommendedTimeline", {
withReplies: defaultStore.state.showTimelineReplies,
});
connection.on("note", prepend);
tlHint = i18n.ts._tutorial.step5_6;
tlHintClosed = defaultStore.state.tlRecommendedHintClosed;
} else if (props.src === "social") {
@ -141,11 +108,6 @@ if (props.src === "antenna") {
query = {
withReplies: defaultStore.state.showTimelineReplies,
};
connection = stream.useChannel("hybridTimeline", {
withReplies: defaultStore.state.showTimelineReplies,
});
connection.on("note", prepend);
tlHint = i18n.ts._tutorial.step5_5;
tlHintClosed = defaultStore.state.tlSocialHintClosed;
} else if (props.src === "global") {
@ -153,49 +115,25 @@ if (props.src === "antenna") {
query = {
withReplies: defaultStore.state.showTimelineReplies,
};
connection = stream.useChannel("globalTimeline", {
withReplies: defaultStore.state.showTimelineReplies,
});
connection.on("note", prepend);
tlHint = i18n.ts._tutorial.step5_7;
tlHintClosed = defaultStore.state.tlGlobalHintClosed;
} 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 = {
listId: props.list,
};
connection = stream.useChannel("userList", {
listId: props.list,
});
connection.on("note", prepend);
connection.on("userAdded", onUserAdded);
connection.on("userRemoved", onUserRemoved);
} 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 === "file") {
endpoint = "drive/files/attached-notes";
query = {
@ -203,6 +141,66 @@ if (props.src === "antenna") {
};
}
const stream = useStream();
function connectChannel() {
if (props.src === "antenna") {
connection = stream.useChannel("antenna", {
antennaId: props.antenna!,
});
} else if (props.src === "home") {
connection = stream.useChannel("homeTimeline", {
withReplies: defaultStore.state.showTimelineReplies,
});
connection2 = stream.useChannel("main");
} else if (props.src === "local") {
connection = stream.useChannel("localTimeline", {
withReplies: defaultStore.state.showTimelineReplies,
});
} else if (props.src === "recommended") {
connection = stream.useChannel("recommendedTimeline", {
withReplies: defaultStore.state.showTimelineReplies,
});
} else if (props.src === "social") {
connection = stream.useChannel("hybridTimeline", {
withReplies: defaultStore.state.showTimelineReplies,
});
} else if (props.src === "global") {
connection = stream.useChannel("globalTimeline", {
withReplies: defaultStore.state.showTimelineReplies,
});
} 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", {
listId: props.list,
});
} else if (props.src === "channel") {
connection = stream.useChannel("channel", {
channelId: props.channel,
});
}
if (props.src !== "directs" && props.src !== "mentions")
connection.on("note", prepend);
};
provide(
"inChannel",
computed(() => props.src === "channel"),
);
const pullToRefreshComponent = ref<InstanceType<typeof MkPullToRefresh>>();
const tlComponent = ref<InstanceType<typeof XNotes>>();
function closeHint() {
switch (props.src) {
case "home":
@ -223,15 +221,34 @@ function closeHint() {
}
}
const pagination = {
endpoint,
if (defaultStore.state.enableTimelineStreaming) {
connectChannel();
onUnmounted(() => {
connection.dispose();
if (connection2) connection2.dispose();
});
}
function reloadTimeline() {
return new Promise<void>((res) => {
tlNotesCount = 0;
tlComponent.value?.pagingComponent?.reload().then(() => {
res();
});
});
}
const pagination: Paging = {
endpoint: endpoint as keyof Endpoints,
limit: 10,
params: query,
};
onUnmounted(() => {
connection.dispose();
if (connection2) connection2.dispose();
if (connection2 != null) connection2.dispose();
});
/* TODO
@ -240,7 +257,12 @@ const timetravel = (date?: Date) => {
this.$refs.tl.reload();
};
*/
defineExpose({
reloadTimeline,
});
</script>
<style lang="scss" scoped>
@keyframes slideUp {
to {

View file

@ -53,7 +53,7 @@ import * as sound from "@/scripts/sound";
import { applyTheme } from "@/scripts/theme";
import { reloadChannel } from "@/scripts/unison-reload";
import { ColdDeviceStorage, defaultStore } from "@/store";
import { stream } from "@/stream";
import { useStream, isReloading } from "@/stream";
import widgets from "@/widgets";
function checkForSplash() {
@ -376,7 +376,9 @@ function checkForSplash() {
);
let reloadDialogShowing = false;
const stream = useStream();
stream.on("_disconnected_", async () => {
if (isReloading) return;
if (defaultStore.state.serverDisconnectedBehavior === "reload") {
location.reload();
} else if (defaultStore.state.serverDisconnectedBehavior === "dialog") {

View file

@ -55,11 +55,13 @@
import { computed, onMounted, onUnmounted, ref } from "vue";
import XPie from "../../widgets/server-metric/pie.vue";
import bytes from "@/filters/bytes";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import * as os from "@/os";
import { i18n } from "@/i18n";
import icon from "@/scripts/icon";
const stream = useStream();
const meta = await os.api("server-info", {});
const serverStats = await os.api("stats");

View file

@ -45,8 +45,9 @@
import { markRaw, onMounted, onUnmounted, ref, shallowRef } from "vue";
import XChart from "./overview.queue.chart.vue";
import number from "@/filters/number";
import { stream } from "@/stream";
import { useStream } from "@/stream";
const strema = useStream();
const connection = markRaw(stream.useChannel("queueStats"));
const activeSincePrevTick = ref(0);

View file

@ -69,12 +69,14 @@ import XModerators from "./overview.moderators.vue";
import XHeatmap from "./overview.heatmap.vue";
// import XMetrics from "./overview.metrics.vue";
import * as os from "@/os";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import { i18n } from "@/i18n";
import { definePageMetadata } from "@/scripts/page-metadata";
import MkFolder from "@/components/MkFolder.vue";
import icon from "@/scripts/icon";
const stream = useStream();
const rootEl = shallowRef<HTMLElement>();
const serverInfo = ref<any>(null);
const topSubInstancesForPie = ref<any>(null);

View file

@ -62,9 +62,10 @@ import { markRaw, onMounted, onUnmounted, ref } from "vue";
import XChart from "./queue.chart.chart.vue";
import number from "@/filters/number";
import * as os from "@/os";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import { i18n } from "@/i18n";
const stream = useStream();
const connection = markRaw(stream.useChannel("queueStats"));
const activeSincePrevTick = ref(0);

View file

@ -96,7 +96,7 @@ import MkButton from "@/components/MkButton.vue";
import MkChatPreview from "@/components/MkChatPreview.vue";
import MkPagination from "@/components/MkPagination.vue";
import * as os from "@/os";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import { useRouter } from "@/router";
import { i18n } from "@/i18n";
import { definePageMetadata } from "@/scripts/page-metadata";
@ -107,6 +107,7 @@ import icon from "@/scripts/icon";
import "swiper/scss";
import "swiper/scss/virtual";
const stream = useStream();
const router = useRouter();
const messages = ref([]);

View file

@ -60,7 +60,7 @@ import { Autocomplete } from "@/scripts/autocomplete";
import { formatTimeString } from "@/scripts/format-time-string";
import { selectFile } from "@/scripts/select-file";
import * as os from "@/os";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import { defaultStore } from "@/store";
import { i18n } from "@/i18n";
import { uploadFile } from "@/scripts/upload";
@ -71,6 +71,8 @@ const props = defineProps<{
group?: firefish.entities.UserGroup | null;
}>();
const stream = useStream();
const textEl = ref<HTMLTextAreaElement>();
const fileEl = ref<HTMLInputElement>();

View file

@ -118,7 +118,7 @@ import {
scrollToBottom,
} from "@/scripts/scroll";
import * as os from "@/os";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import * as sound from "@/scripts/sound";
import { vibrate } from "@/scripts/vibrate";
import { i18n } from "@/i18n";
@ -132,6 +132,8 @@ const props = defineProps<{
groupId?: string;
}>();
const stream = useStream();
const rootEl = ref<HTMLDivElement>();
const formEl = ref<InstanceType<typeof XForm>>();
const pagingComponent = ref<InstanceType<typeof MkPagination>>();

View file

@ -6,9 +6,13 @@
:actions="headerActions"
:tabs="headerTabs"
:display-my-avatar="true"
class="xytnxiau"
/>
</template>
<MkSpacer :content-max="800">
<MkSpacer
:content-max="800"
:style="`padding-top: ${isMobile ? 67 : 79}px`"
>
<swiper
:round-lengths="true"
:touch-angle="25"
@ -187,3 +191,11 @@ function syncSlide(index) {
swiperRef.slideTo(index);
}
</script>
<style lang="scss" scoped>
.xytnxiau {
overflow-y: hidden;
position: fixed;
top: 0;
}
</style>

View file

@ -93,6 +93,9 @@
class="_formBlock"
>{{ i18n.ts.swipeOnDesktop }}</FormSwitch
>
<FormSwitch v-model="enableTimelineStreaming" class="_formBlock">{{
i18n.ts.enableTimelineStreaming
}}</FormSwitch>
<FormSwitch v-model="enterSendsMessage" class="_formBlock">{{
i18n.ts.enterSendsMessage
}}</FormSwitch>
@ -539,6 +542,9 @@ const openServerInfo = computed(
);
const searchURL = computed(defaultStore.makeGetterSetter("searchURL"));
const iconSet = computed(defaultStore.makeGetterSetter("iconSet"));
const enableTimelineStreaming = computed(
defaultStore.makeGetterSetter("enableTimelineStreaming"),
);
// This feature (along with injectPromo) is currently disabled
// function onChangeInjectFeaturedNote(v) {
@ -603,6 +609,7 @@ watch(
autoplayMfm,
expandOnNoteClick,
iconSet,
enableTimelineStreaming,
],
async () => {
await reloadAsk();

View file

@ -64,13 +64,14 @@ import MkInfo from "@/components/MkInfo.vue";
import * as os from "@/os";
import { ColdDeviceStorage, defaultStore } from "@/store";
import { unisonReload } from "@/scripts/unison-reload";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import { $i } from "@/reactiveAccount";
import { i18n } from "@/i18n";
import { host, version } from "@/config";
import { definePageMetadata } from "@/scripts/page-metadata";
import icon from "@/scripts/icon";
const stream = useStream();
useCssModule();
const defaultStoreSaveKeys: (keyof (typeof defaultStore)["state"])[] = [

View file

@ -6,9 +6,13 @@
:actions="headerActions"
:tabs="headerTabs"
:display-my-avatar="true"
class="xytnxiau"
/>
</template>
<MkSpacer :content-max="800">
<MkSpacer
:content-max="800"
:style="`padding-top: ${isMobile ? 67 : 79}px`"
>
<div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf">
<XPostForm
v-if="defaultStore.reactiveState.showFixedPostForm.value"
@ -270,12 +274,12 @@ definePageMetadata(
src.value === "local"
? "ph-users ph-lg"
: src.value === "social"
? "ph-handshake ph-lg"
: src.value === "recommended"
? "ph-thumbs-up ph-lg"
: src.value === "global"
? "ph-planet ph-lg"
: "ph-house ph-lg",
? "ph-handshake ph-lg"
: src.value === "recommended"
? "ph-thumbs-up ph-lg"
: src.value === "global"
? "ph-planet ph-lg"
: "ph-house ph-lg",
})),
);
@ -300,6 +304,12 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
.xytnxiau {
overflow-y: hidden;
position: fixed;
top: 0;
}
.cmuxhskf {
--swiper-theme-color: var(--accent);
> .tl {

View file

@ -4,7 +4,7 @@ import { $i } from "@/reactiveAccount";
import type { Ref } from "vue";
import { onUnmounted, ref, watch } from "vue";
import { api } from "./os";
import { stream } from "./stream";
import { useStream } from "./stream";
type StateDef = Record<
string,
@ -16,6 +16,7 @@ type StateDef = Record<
type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
const stream = useStream();
const connection = $i && stream.useChannel("main");
export class Storage<T extends StateDef> {

View file

@ -3,10 +3,12 @@ import * as os from "@/os";
import icon from "@/scripts/icon";
import { uploadFile } from "@/scripts/upload";
import { defaultStore } from "@/store";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import type { DriveFile } from "firefish-js/built/entities";
import { ref } from "vue";
const stream = useStream();
function select(
src: any,
label: string | null,

View file

@ -1,6 +1,6 @@
import * as os from "@/os";
import { $i } from "@/reactiveAccount";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import type * as firefish from "firefish-js";
import type { Ref } from "vue";
import { onUnmounted } from "vue";
@ -11,6 +11,7 @@ export function useNoteCapture(props: {
isDeletedRef: Ref<boolean>;
}) {
const note = props.note;
const stream = useStream();
const connection = $i ? stream : null;
async function onStreamNoteUpdated(noteData): Promise<void> {

View file

@ -367,7 +367,7 @@ export const defaultStore = markRaw(
},
replaceWidgetsButtonWithReloadButton: {
where: "device",
default: true,
default: false,
},
addRe: {
where: "account",
@ -414,6 +414,10 @@ export const defaultStore = markRaw(
| "ph" // this is ph-regular
| "ph-fill",
},
enableTimelineStreaming: {
where: "deviceAccount",
default: true,
},
}),
);

View file

@ -3,22 +3,44 @@ import { $i } from "@/reactiveAccount";
import * as firefish from "firefish-js";
import { markRaw } from "vue";
export const stream = markRaw(
new firefish.Stream(
url,
$i
? {
token: $i.token,
}
: null,
),
);
let stream: firefish.Stream | null = null;
let timeoutHeartBeat: number | null = null;
export let isReloading: boolean = false;
window.setTimeout(heartbeat, 1000 * 60);
export function useStream() {
if (stream != null) return stream;
stream = markRaw(
new firefish.Stream(
url,
$i
? {
token: $i.token,
}
: null,
),
);
timeoutHeartBeat = window.setTimeout(heartbeat, 1000 * 60);
return stream;
}
export function reloadStream() {
if (stream == null) return useStream();
if (timeoutHeartBeat != null) window.clearTimeout(timeoutHeartBeat);
isReloading = true;
stream.close();
stream.once("_connected_", () => (isReloading = false));
stream.stream.reconnect();
isReloading = false;
return stream;
}
function heartbeat(): void {
if (stream != null && document.visibilityState === "visible") {
stream.send("ping");
}
window.setTimeout(heartbeat, 1000 * 60);
timeoutHeartBeat = window.setTimeout(heartbeat, 1000 * 60);
}

View file

@ -23,7 +23,9 @@ import { popup, popups } from "@/os";
import { uploads } from "@/scripts/upload";
import * as sound from "@/scripts/sound";
import { $i } from "@/reactiveAccount";
import { stream } from "@/stream";
import { useStream } from "@/stream";
const stream = useStream();
const XStreamIndicator = defineAsyncComponent(
() => import("./stream-indicator.vue"),

View file

@ -19,13 +19,14 @@
<script lang="ts" setup>
import { onUnmounted, ref } from "vue";
import { stream } from "@/stream";
import { useStream, isReloading } from "@/stream";
import { i18n } from "@/i18n";
import { defaultStore } from "@/store";
const hasDisconnected = ref(false);
function onDisconnected() {
if (isReloading) return;
hasDisconnected.value = true;
}
@ -37,6 +38,7 @@ function reload() {
location.reload();
}
const stream = useStream();
stream.on("_disconnected_", onDisconnected);
onUnmounted(() => {

View file

@ -684,7 +684,7 @@ console.log(mainRouter.currentRoute.value.name);
padding: var(--margin);
box-sizing: border-box;
overflow: auto;
overscroll-behavior: contain;
overscroll-behavior: none;
background: var(--bg);
}

View file

@ -127,7 +127,7 @@ import { onUnmounted, reactive } from "vue";
import type { Widget, WidgetComponentExpose } from "./widget";
import { useWidgetPropsManager } from "./widget";
import type { GetFormResultType } from "@/scripts/form";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import number from "@/filters/number";
import * as sound from "@/scripts/sound";
import { deepClone } from "@/scripts/clone";
@ -161,6 +161,7 @@ const { widgetProps, configure } = useWidgetPropsManager(
emit,
);
const stream = useStream();
const connection = stream.useChannel("queueStats");
const current = reactive({
inbox: {

View file

@ -30,7 +30,7 @@ import { onUnmounted, ref } from "vue";
import type { Widget, WidgetComponentExpose } from "./widget";
import { useWidgetPropsManager } from "./widget";
import type { GetFormResultType } from "@/scripts/form";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import { getStaticImageUrl } from "@/scripts/get-static-image-url";
import * as os from "@/os";
import MkContainer from "@/components/MkContainer.vue";
@ -66,6 +66,7 @@ const { widgetProps, configure } = useWidgetPropsManager(
emit,
);
const stream = useStream();
const connection = stream.useChannel("main");
const images = ref([]);
const fetching = ref(true);

View file

@ -70,7 +70,7 @@ import XMeili from "./meilisearch.vue";
import MkContainer from "@/components/MkContainer.vue";
import type { GetFormResultType } from "@/scripts/form";
import * as os from "@/os";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import { i18n } from "@/i18n";
import { instance } from "@/instance";
import icon from "@/scripts/icon";
@ -126,6 +126,7 @@ const toggleView = () => {
save();
};
const stream = useStream();
const connection = stream.useChannel("serverStats");
onUnmounted(() => {
connection.dispose();

View file

@ -50,7 +50,7 @@ type StreamEvents = {
* Firefish stream connection
*/
export default class Stream extends EventEmitter<StreamEvents> {
private stream: ReconnectingWebsocket;
public stream: ReconnectingWebsocket;
public state: "initializing" | "reconnecting" | "connected" = "initializing";
private sharedConnectionPools: Pool[] = [];
private sharedConnections: SharedConnection[] = [];