feat (client): pull-to-refresh timelines

based on https://github.com/misskey-dev/misskey/pull/12113

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
Co-authored-by: naskya <m@naskya.net>
Co-authored-by: Nanaka Hiira <hiira@hiira.dev>
This commit is contained in:
Fairy-Phy 2024-02-29 22:21:19 +09:00 committed by naskya
parent f8bc26bd6b
commit 4f72ade656
No known key found for this signature in database
GPG key ID: 712D413B3A9FED5C
36 changed files with 534 additions and 125 deletions

View file

@ -1176,6 +1176,12 @@ private: "Private"
privateDescription: "Make visible for you only"
makePrivate: "Make private"
makePrivateConfirm: "This operation will send a deletion request to remote servers and change the visibility to private. Proceed?"
enablePullToRefresh: "Enable \"Pull down to refresh\""
pullToRefreshThreshold: "Pull distance for reloading"
pullDownToReload: "Pull down to reload"
releaseToReload: "Release to reload"
reloading: "Reloading"
enableTimelineStreaming: "Update timelines automatically"
_emojiModPerm:
unauthorized: "None"

View file

@ -997,6 +997,12 @@ detectPostLanguage: "投稿の言語を自動検出し、外国語の投稿に
iconSet: "アイコンのスタイル"
useCdn: "CDNのアセットを利用する"
useCdnDescription: "このFirefishサーバーからではなくJSDelivr CDNからTwiemojiなどのアセットを読み込みます。"
enablePullToRefresh: "「下に引っ張って再読み込み」を有効にする"
pullToRefreshThreshold: "再読み込みするために引っ張る距離"
pullDownToReload: "下に引っ張って再読み込み"
releaseToReload: "離して再読み込み"
reloading: "読み込み中"
enableTimelineStreaming: "タイムラインを自動で更新する"
_sensitiveMediaDetection:
description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てられます。サーバーの負荷が少し増えます。"

View file

@ -151,7 +151,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";
@ -183,6 +183,8 @@ const emit = defineEmits<{
(ev: "open-folder", v: 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 { entities } from "firefish-js";
import * as os from "@/os";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import { i18n } from "@/i18n";
import { $i, isSignedIn } from "@/reactiveAccount";
import { getUserMenu } from "@/scripts/get-user-menu";
@ -72,6 +72,7 @@ import { useRouter } from "@/router";
import { vibrate } from "@/scripts/vibrate";
import icon from "@/scripts/icon";
const stream = useStream();
const router = useRouter();
const emit = defineEmits(["refresh"]);

View file

@ -185,7 +185,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<{
@ -193,6 +193,8 @@ const props = defineProps<{
pinned?: boolean;
}>();
const stream = useStream();
const tab = ref("replies");
const note = ref(deepClone(props.note));

View file

@ -1,5 +1,9 @@
<template>
<MkPagination ref="pagingComponent" :pagination="pagination">
<MkPagination
ref="pagingComponent"
:pagination="pagination"
:disable-auto-load="disableAutoLoad"
>
<template #empty>
<div class="_fullinfo">
<img
@ -48,6 +52,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

@ -53,7 +53,7 @@ import MkPagination from "@/components/MkPagination.vue";
import XNotification from "@/components/MkNotification.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 +62,8 @@ const props = defineProps<{
unreadOnly?: boolean;
}>();
const stream = useStream();
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
const pagination: Paging = {
@ -87,7 +89,7 @@ const onNotification = (notification) => {
}
if (!isMuted) {
pagingComponent.value.prepend({
pagingComponent.value?.prepend({
...notification,
isRead: document.visibilityState === "visible",
});

View file

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

View file

@ -115,6 +115,7 @@ const props = withDefaults(
const emit = defineEmits<{
(ev: "queue", count: number): void;
(ev: "status", error: boolean): void;
}>();
interface Item {
@ -185,12 +186,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
@ -446,6 +447,11 @@ watch(
{ deep: true },
);
watch(error, (n, o) => {
if (n === o) return;
emit("status", n);
});
init();
onActivated(() => {

View file

@ -301,7 +301,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";
@ -354,6 +354,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,239 @@
<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">
<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 { defaultStore } from "@/store";
const SCROLL_STOP = 10;
const MAX_PULL_DISTANCE = Infinity;
const FIRE_THRESHOLD = defaultStore.state.pullToRefreshThreshold;
const RELEASE_TRANSITION_DURATION = 120;
const PULL_BRAKE_BASE = 1.5;
const PULL_BRAKE_FACTOR = 100;
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,48 @@
<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>
<MkPullToRefresh
v-if="defaultStore.state.enablePullToRefresh"
ref="pullToRefreshComponent"
:refresher="() => reloadTimeline()"
>
<XNotes
ref="tlComponent"
:no-gap="!defaultStore.state.showGapBetweenNotesInTimeline"
:pagination="pagination"
@queue="(x) => (queue = x)"
@status="pullToRefreshComponent?.setDisabled($event)"
/>
</MkPullToRefresh>
<XNotes
v-else
ref="tlComponent"
:no-gap="!defaultStore.state.showGapBetweenNotesInTimeline"
:pagination="pagination"
@queue="(x) => (queue = x)"
@status="pullToRefreshComponent?.setDisabled($event)"
/>
</template>
<script lang="ts" setup>
import { computed, onUnmounted, provide, ref } from "vue";
import MkPullToRefresh from "@/components/MkPullToRefresh.vue";
import XNotes from "@/components/MkNotes.vue";
import MkInfo from "@/components/MkInfo.vue";
import { stream } from "@/stream";
import { useStream } from "@/stream";
import * as sound from "@/scripts/sound";
import { $i, isSignedIn } from "@/reactiveAccount";
import { i18n } from "@/i18n";
import { defaultStore } from "@/store";
import icon from "@/scripts/icon";
import type { Paging } from "@/components/MkPagination.vue";
import type { Endpoints } from "firefish-js";
const props = defineProps<{
src: string;
@ -47,22 +65,24 @@ 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 = ref<InstanceType<typeof XNotes>>();
const pullToRefreshComponent = ref<InstanceType<typeof MkPullToRefresh>>();
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");
@ -71,45 +91,16 @@ const prepend = (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 +108,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 +115,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 +122,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 +129,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 +155,63 @@ 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"),
);
function closeHint() {
switch (props.src) {
case "home":
@ -223,15 +232,32 @@ 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,6 +266,10 @@ const timetravel = (date?: Date) => {
this.$refs.tl.reload();
};
*/
defineExpose({
reloadTimeline,
});
</script>
<style lang="scss" scoped>
@keyframes slideUp {

View file

@ -52,7 +52,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() {
@ -399,7 +399,10 @@ 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 stream = 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?: 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

@ -163,6 +163,26 @@
{{ i18n.ts.postSearch }}
</option>
</FormSelect>
<FormSwitch v-model="enableTimelineStreaming" class="_formBlock">{{
i18n.ts.enableTimelineStreaming
}}</FormSwitch>
<FormSwitch v-model="enablePullToRefresh" class="_formBlock">{{
i18n.ts.enablePullToRefresh
}}</FormSwitch>
<FormRange
v-if="enablePullToRefresh"
v-model="pullToRefreshThreshold"
:min="100"
:max="300"
:step="10"
easing
class="_formBlock"
>
<template #label>{{ i18n.ts.pullToRefreshThreshold }}</template>
<template #caption>{{
i18n.ts.pullToRefreshThreshold
}}</template>
</FormRange>
</FormSection>
<FormSection>
@ -502,6 +522,15 @@ const searchURL = computed(defaultStore.makeGetterSetter("searchURL"));
const showBigPostButton = computed(
defaultStore.makeGetterSetter("showBigPostButton"),
);
const enableTimelineStreaming = computed(
defaultStore.makeGetterSetter("enableTimelineStreaming"),
);
const enablePullToRefresh = computed(
defaultStore.makeGetterSetter("enablePullToRefresh"),
);
const pullToRefreshThreshold = computed(
defaultStore.makeGetterSetter("pullToRefreshThreshold"),
);
// This feature (along with injectPromo) is currently disabled
// function onChangeInjectFeaturedNote(v) {
@ -567,6 +596,9 @@ watch(
expandOnNoteClick,
iconSet,
useEmojiCdn,
enableTimelineStreaming,
enablePullToRefresh,
pullToRefreshThreshold,
],
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 { isSignedIn } 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"])[] = [
@ -120,6 +121,9 @@ const defaultStoreSaveKeys: (keyof (typeof defaultStore)["state"])[] = [
"detectPostLanguage",
"openServerInfo",
"iconSet",
"enableTimelineStreaming",
"enablePullToRefresh",
"pullToRefreshThreshold",
];
const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
"lightTheme",

View file

@ -6,9 +6,10 @@
:actions="headerActions"
:tabs="headerTabs"
:display-my-avatar="true"
:class="{ isMobile: 'xytnxiau' }"
/>
</template>
<MkSpacer :content-max="800">
<MkSpacer :content-max="800" :class="{ isMobile: 'upsvvhaz' }">
<div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf">
<XPostForm
v-if="defaultStore.reactiveState.showFixedPostForm.value"
@ -304,6 +305,16 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
.xytnxiau {
overflow-y: hidden;
position: absolute;
top: 0;
}
.upsvvhaz {
padding-top: 67px;
}
.cmuxhskf {
--swiper-theme-color: var(--accent);
> .tl {

View file

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

View file

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

View file

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

View file

@ -410,6 +410,18 @@ export const defaultStore = markRaw(
where: "device",
default: false,
},
enableTimelineStreaming: {
where: "deviceAccount",
default: true,
},
enablePullToRefresh: {
where: "deviceAccount",
default: true,
},
pullToRefreshThreshold: {
where: "device",
default: 150,
},
}),
);

View file

@ -3,22 +3,44 @@ import { markRaw } from "vue";
import { url } from "@/config";
import { $i } from "@/reactiveAccount";
export const stream = markRaw(
new Stream(
url,
$i
? {
token: $i.token,
}
: null,
),
);
let stream: 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 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, isSignedIn } 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

@ -676,7 +676,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[] = [];