add: pull to refresh

This commit is contained in:
Fairy-Phy 2023-10-22 18:12:49 +09:00
parent 97d543415d
commit 9e63971cff
No known key found for this signature in database
GPG key ID: 53E58673D5961DB5
10 changed files with 360 additions and 86 deletions

View file

@ -1139,6 +1139,10 @@ impressumDescription: "In some countries, like germany, the inclusion of operato
privacyPolicy: "Privacy Policy"
privacyPolicyUrl: "Privacy Policy URL"
tosAndPrivacyPolicy: "Terms of Service and Privacy Policy"
releaseToRefresh: "Release to reload"
refreshing: "Reloading"
pullDownToRefresh: "Pull down to reload"
disableWebSocket: "Disable realtime update on timeline"
_announcement:
forExistingUsers: "Existing users only"
forExistingUsersDescription: "This announcement will only be shown to users existing at the point of publishment if enabled. If disabled, those newly signing up after it has been posted will also see it."

4
locales/index.d.ts vendored
View file

@ -1142,6 +1142,10 @@ export interface Locale {
"privacyPolicy": string;
"privacyPolicyUrl": string;
"tosAndPrivacyPolicy": string;
"releaseToRefresh": string;
"refreshing": string;
"pullDownToRefresh": string;
"disableWebSocket": string;
"_announcement": {
"forExistingUsers": string;
"forExistingUsersDescription": string;

View file

@ -1139,6 +1139,10 @@ impressumDescription: "ドイツなどの一部の国と地域では表示が義
privacyPolicy: "プライバシーポリシー"
privacyPolicyUrl: "プライバシーポリシーURL"
tosAndPrivacyPolicy: "利用規約・プライバシーポリシー"
releaseToRefresh: "離してリロード"
refreshing: "リロード中"
pullDownToRefresh: "引っ張ってリロード"
disableWebSocket: "タイムラインのリアルタイム更新を無効にする"
_announcement:
forExistingUsers: "既存ユーザーのみ"

View file

@ -166,6 +166,8 @@ defineExpose({
<style lang="scss" module>
.root {
overscroll-behavior: none;
min-height: 100%;
background: var(--bg);

View file

@ -101,6 +101,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(ev: 'queue', count: number): void;
(ev: 'status', error: boolean): void;
}>();
let rootEl = $shallowRef<HTMLElement>();
@ -192,6 +193,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();

View file

@ -0,0 +1,235 @@
<template>
<div ref="rootEl" :class="$style.root">
<div v-if="isPullStart" :class="$style.frame" :style="`--frame-min-height: ${currentHeight}px;`">
<div :class="$style.contents">
<i v-if="!isRefreshing" :class="['ti', 'ti-arrow-bar-to-down', { [$style.refresh]: isPullEnd }]"></i>
<MkLoading v-else :em="true"/>
<p v-if="isPullEnd">{{ i18n.ts.releaseToRefresh }}</p>
<p v-else-if="isRefreshing">{{ i18n.ts.refreshing }}</p>
<p v-else>{{ i18n.ts.pullDownToRefresh }}</p>
</div>
</div>
<slot />
</div>
</template>
<script lang="ts" setup>
import { onMounted } from 'vue';
import { deviceKind } from '@/scripts/device-kind.js';
import { i18n } from '@/i18n.js';
let isPullStart = $ref(false);
let isPullEnd = $ref(false);
let isRefreshing = $ref(false);
const scrollStop = 10;
const maxFrameSize = 75;
const refreshFrameSize = 65;
const fixTimeMs = 200;
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<{
(e: 'refresh'): void;
}>();
const getScrollableParentElement = (node) => {
if (node == null) {
return null;
}
if (node.scrollHeight > node.clientHeight) {
return node;
}
else {
return getScrollableParentElement(node.parentNode);
}
};
const getScreenY = (event) => {
if (supportPointerDesktop) {
return event.screenY;
}
return event.touches[0].screenY;
};
const moveStart = (e) => {
if (!isPullStart && !isRefreshing && !disabled) {
isPullStart = true;
startScreenY = getScreenY(e);
currentHeight = 0;
}
};
const moveBySystem = (to: number): Promise<void> => 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 > fixTimeMs) {
currentHeight = to;
clearInterval(intervalId);
r();
return;
}
const nextHeight = startHeight - (overHeight / fixTimeMs) * time;
if (currentHeight < nextHeight) return;
currentHeight = nextHeight;
}, 1);
});
const fixOverContent = async () => {
if (currentHeight > refreshFrameSize) {
await moveBySystem(refreshFrameSize);
}
};
const closeContent = async () => {
if (currentHeight > 0) {
await moveBySystem(0);
}
};
const moveEnd = () => {
if (isPullStart && !isRefreshing) {
startScreenY = null;
if (isPullEnd) {
isPullEnd = false;
isRefreshing = true;
fixOverContent().then(() => emits('refresh'));
}
else {
closeContent().then(() => isPullStart = false);
}
}
};
const moving = (e) => {
if (!isPullStart || isRefreshing || disabled) return;
if (!scrollEl) {
scrollEl = getScrollableParentElement(rootEl);
}
if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? scrollStop : scrollStop + currentHeight)) {
currentHeight = 0;
isPullEnd = false;
moveEnd();
return;
}
if (startScreenY === null) {
startScreenY = getScreenY(e);
}
const moveScreenY = getScreenY(e);
const moveHeight = moveScreenY - startScreenY!;
if (moveHeight < 0) {
currentHeight = 0;
}
else if (moveHeight >= maxFrameSize) {
currentHeight = maxFrameSize;
}
else {
currentHeight = moveHeight;
}
isPullEnd = currentHeight >= refreshFrameSize;
};
onMounted(() => {
supportPointerDesktop = !!window.PointerEvent && deviceKind === 'desktop';
if (supportPointerDesktop) {
rootEl.addEventListener('pointerdown', moveStart);
rootEl.addEventListener('pointerup', moveEnd);
rootEl.addEventListener('pointermove', moving, {passive: true});
}
else {
rootEl.addEventListener('touchstart', moveStart);
rootEl.addEventListener('touchend', moveEnd);
rootEl.addEventListener('touchmove', moving, {passive: true});
}
});
/**
* emit(refresh)が完了したことを知らせる関数
*
* タイムアウトがないのでこれを最終的に実行しないと出たままになる
*/
const refreshFinished = () => {
closeContent().then(() => {
isPullStart = false;
isRefreshing = false;
});
};
const setDisabled = (value) => {
disabled = value;
};
defineExpose({
refreshFinished,
setDisabled,
});
</script>
<style lang="scss" module>
.frame {
position: relative;
overflow: clip;
width: 100%;
min-height: var(--frame-min-height, 0px);
box-shadow: inset 0px -7px 10px -10px rgba(0,0,0,.1);
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;
> .contents {
position: absolute;
bottom: 0;
width: 100%;
margin: 5px 0;
display: flex;
flex-direction: column;
align-items: center;
margin: 5px 0;
font-size: 14px;
> i, > div {
margin: 6px 0;
}
> i {
transition: transform .25s;
&.refresh {
transform: rotate(180deg);
}
}
> p {
margin: 5px 0;
}
}
}
</style>

View file

@ -4,12 +4,15 @@ 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 MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { useStream, reloadStream } from '@/stream.js';
import * as sound from '@/scripts/sound.js';
import { $i } from '@/account.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.disableWebSocket) {
connectChannel();
onUnmounted(() => {
connection.dispose();
if (connection2) connection2.dispose();
});
connection.on('note', prepend);
}
const pagination = {
@ -178,19 +196,17 @@ const pagination = {
params: query,
};
onUnmounted(() => {
connection.dispose();
if (connection2) connection2.dispose();
});
const reloadTimeline = () => {
const reloadTimeline = (fromPR = false) => {
tlNotesCount = 0;
tlComponent.pagingComponent?.reload().then(() => {
reloadStream();
if (fromPR) prComponent.refreshFinished();
});
};
//const pullRefresh = () => reloadTimeline(true);
defineExpose({
reloadTimeline
});

View file

@ -150,7 +150,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="disableWebSocket">{{ i18n.ts.disableWebSocket ?? 'タイムラインのリアルタイム更新を無効にする' }}</MkSwitch>
<MkSwitch v-model="disableWebSocket">{{ i18n.ts.disableWebSocket }}</MkSwitch>
</div>
<MkSelect v-model="serverDisconnectedBehavior">
<template #label>{{ i18n.ts.whenServerDisconnected }}</template>

View file

@ -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,34 +140,36 @@ function focus(): void {
tlComponent.focus();
}
const headerActions = $computed(() => [{
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 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,

View file

@ -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);
}