Compare commits

...

69 Commits

Author SHA1 Message Date
tamaina
312e2a022c Merge branch 'develop' into fix-msg-room 2022-06-11 07:19:05 +00:00
tamaina
e2e0b153b8 Merge branch 'develop' into fix-msg-room 2022-05-28 01:17:13 +09:00
tamaina
58509ee84a asobi → tolerance 2022-05-08 20:56:50 +09:00
tamaina
a0bf73b01b fix 2022-05-08 20:53:25 +09:00
tamaina
31563c289e https://github.com/misskey-dev/misskey/pull/8209#discussion_r867386077 2022-05-08 20:44:12 +09:00
tamaina
b2c610c991 Merge branch 'fix-msg-room' of https://github.com/misskey-dev/misskey into fix-msg-room 2022-05-08 20:27:53 +09:00
tamaina
ce4f9679a3 clean up: single line comment 2022-05-08 20:27:50 +09:00
tamaina
27cc8cd7df
Update packages/client/src/pages/messaging/messaging-room.form.vue
Co-authored-by: Johann150 <johann.galle@protonmail.com>
2022-05-08 20:25:06 +09:00
tamaina
1cf01679c5 Merge branch 'fix-msg-room' of https://github.com/misskey-dev/misskey into fix-msg-room 2022-05-08 20:24:02 +09:00
tamaina
7afbaa1e26
Update packages/client/src/components/ui/pagination.vue
Co-authored-by: Johann150 <johann.galle@protonmail.com>
2022-05-08 20:23:07 +09:00
tamaina
09e515aec9 Merge branch 'fix-msg-room' of https://github.com/misskey-dev/misskey into fix-msg-room 2022-05-08 20:14:52 +09:00
tamaina
1756d74039
Update packages/client/src/components/global/sticky-container.vue
Co-authored-by: Johann150 <johann.galle@protonmail.com>
2022-05-08 20:13:41 +09:00
tamaina
c2c7a06729 fix lint 2022-05-07 15:21:33 +00:00
tamaina
dbbc75008d Merge branch 'develop' into fix-msg-room 2022-05-07 15:07:30 +00:00
tamaina
7ef48fadec Merge branch 'develop' into fix-msg-room 2022-03-27 22:45:28 +09:00
tamaina
33ab9f1fe9 Merge branch 'develop' into fix-msg-room 2022-03-12 00:39:40 +09:00
tamaina
e41047de6a clean up 2022-03-05 20:12:33 +09:00
tamaina
6dc9a6ccc3 ✌️ 2022-03-05 20:04:48 +09:00
tamaina
17d71fa0ad wip 2022-03-05 19:16:35 +09:00
tamaina
5e9d36e9c5 fix 2022-03-05 01:45:23 +09:00
tamaina
3f73244ddd Merge branch 'spacer-safe' into fix-msg-room 2022-03-05 01:45:11 +09:00
tamaina
b3f36bc7be fix 2022-03-05 01:45:02 +09:00
tamaina
fbb4ebe705 🎨 2022-03-05 01:39:02 +09:00
tamaina
a3fc001925 Merge branch 'spacer-safe' into fix-msg-room 2022-03-05 01:38:42 +09:00
tamaina
3677570977 ✌️ 2022-03-05 01:38:36 +09:00
tamaina
11977eadce Merge branch 'spacer-safe' into fix-msg-room 2022-03-05 01:35:07 +09:00
tamaina
3cabae133d fix 2022-03-05 01:34:57 +09:00
tamaina
cc91ee9683 Merge branch 'spacer-safe' into fix-msg-room 2022-03-05 01:22:00 +09:00
tamaina
b9c2b6dae0 Merge branch 'develop' into fix-msg-room 2022-03-05 01:21:32 +09:00
tamaina
e6c031349a add safe-area-inset-bottom to spacer 2022-03-05 01:21:01 +09:00
tamaina
af2603ef8a fix once 2022-02-05 02:50:53 +09:00
tamaina
8913c751f0 fix 2022-02-05 01:37:09 +09:00
tamaina
7a4d617699 scroll event once or not 2022-02-04 01:42:05 +09:00
tamaina
b96e98b0f1 Merge branch 'develop' into fix-msg-room 2022-02-03 22:36:18 +09:00
tamaina
ddbf8c7873 refactor 2022-02-03 22:34:58 +09:00
tamaina
1056380733 clena up 2022-02-03 05:17:06 +09:00
tamaina
37f78378d5 clean up 2022-02-03 04:57:13 +09:00
tamaina
a7dd3e9324 ✌️ 2022-02-03 04:55:51 +09:00
tamaina
a2f16c1364 ✌️ 2022-02-03 04:28:04 +09:00
tamaina
de90b25561 fix? 2022-02-03 04:05:21 +09:00
tamaina
08ae09a871 fix 2022-02-03 03:58:31 +09:00
tamaina
ba21a298ff rename 2022-02-03 03:40:12 +09:00
tamaina
fa809bb50c Merge branch 'develop' into fix-msg-room 2022-02-03 03:37:17 +09:00
tamaina
90c6f15a72 aaaaaaaaaaa 2022-02-03 03:37:05 +09:00
tamaina
99d8172ae5 wip 2022-02-02 21:13:19 +09:00
tamaina
b88fc1fb4e Fix scroll bottom detect 2022-02-02 14:54:02 +09:00
tamaina
9061d33405 Merge branch 'develop' into fix-msg-room 2022-02-02 14:07:17 +09:00
tamaina
8eaf1423c1 ✌️ 2022-02-02 00:47:57 +09:00
tamaina
edb36d73f8 Merge branch 'develop' into fix-msg-room 2022-02-02 00:25:05 +09:00
tamaina
927317b5bb Merge branch 'develop' into fix-msg-room 2022-02-01 23:35:51 +09:00
tamaina
eaaccb52da FIX 2022-01-29 15:11:54 +00:00
tamaina
9825d7ce87 fix 2022-01-29 00:07:27 +09:00
tamaina
6fc1043e1b Merge branch 'fix-msg-room' of https://github.com/misskey-dev/misskey into fix-msg-room 2022-01-29 00:05:41 +09:00
tamaina
1f4d211ff7 fix scroll container find function 2022-01-29 00:05:38 +09:00
tamaina
f0bb08de15 i18n.ts 2022-01-28 15:44:02 +09:00
tamaina
7af5562b5a Merge branch 'develop' into fix-msg-room 2022-01-28 15:42:25 +09:00
tamaina
60ad28cbc7 🎨 2022-01-28 07:07:22 +09:00
tamaina
f660782a44 🎨 2022-01-28 06:21:37 +09:00
tamaina
410939a524 関心事でないのでとりあえず置いておく 2022-01-28 05:56:24 +09:00
tamaina
4a7f968741 refactor 2022-01-28 05:50:15 +09:00
tamaina
19af8e845f messaaging-room.form.vue rewrite to compositon api 2022-01-28 05:43:57 +09:00
tamaina
9923cfaf50 ✌️ 2022-01-28 05:05:34 +09:00
tamaina
dd0d86cbb6 Merge branch 'develop' into fix-msg-room 2022-01-28 02:56:16 +09:00
tamaina
a8af328e5b wip? 2022-01-28 02:56:01 +09:00
tamaina
364ac37c0a wip??? 2022-01-28 00:38:33 +09:00
tamaina
6f9ccf6b02 Merge branch 'develop' into fix-msg-room 2022-01-27 19:08:34 +09:00
tamaina
c61d6bd89a wip 2022-01-27 19:07:14 +09:00
tamaina
8a7264835e wip 2022-01-27 18:58:41 +09:00
tamaina
a1f346a549 pages/messaging/messaging-room.vue 2022-01-27 18:15:49 +09:00
12 changed files with 906 additions and 820 deletions

View File

@ -1,13 +1,14 @@
<script lang="ts">
import { defineComponent, h, PropType, TransitionGroup } from 'vue';
import { defineComponent, getCurrentInstance, h, markRaw, onMounted, PropType, TransitionGroup } from 'vue';
import MkAd from '@/components/global/ad.vue';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
import { MisskeyEntity } from '@/types/date-separated-list';
export default defineComponent({
props: {
items: {
type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>,
type: Array as PropType<MisskeyEntity[]>,
required: true,
},
direction: {
@ -90,17 +91,31 @@ export default defineComponent({
}
});
function onBeforeLeave(el: HTMLElement) {
el.style.top = `${el.offsetTop}px`;
el.style.left = `${el.offsetLeft}px`;
}
function onLeaveCanceled(el: HTMLElement) {
el.style.top = '';
el.style.left = '';
}
return () => h(
defaultStore.state.animation ? TransitionGroup : 'div',
defaultStore.state.animation ? {
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
name: 'list',
tag: 'div',
{
class: {
'sqadhkmv': true,
'noGap': props.noGap
},
'data-direction': props.direction,
'data-reversed': props.reversed ? 'true' : 'false',
} : {
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
},
...(defaultStore.state.animation ? {
name: 'list',
tag: 'div',
onBeforeLeave,
onLeaveCanceled,
} : {}),
},
{ default: renderChildren });
}
});
@ -108,6 +123,8 @@ export default defineComponent({
<style lang="scss">
.sqadhkmv {
display: flex;
> *:empty {
display: none;
}
@ -120,24 +137,46 @@ export default defineComponent({
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
&.deny-move-transition > .list-move {
transition: none !important;
}
> .list-leave-active,
> .list-enter-active {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
> .list-leave-from,
> .list-leave-to,
> .list-leave-active {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
position: absolute !important;
}
&[data-direction="up"] {
> .list-enter-from {
> .list-enter-from,
> .list-leave-to {
opacity: 0;
transform: translateY(64px);
}
}
&[data-direction="down"] {
> .list-enter-from {
> .list-enter-from,
> .list-leave-to {
opacity: 0;
transform: translateY(-64px);
}
}
&[data-reversed="true"] {
flex-direction: column-reverse;
}
&[data-reversed="false"] {
flex-direction: column;
}
> .separator {
text-align: center;

View File

@ -1,71 +1,63 @@
<template>
<div ref="rootEl">
<slot name="header"></slot>
<div ref="bodyEl">
<div ref="bodyEl" :data-sticky-container-header-height="headerHeight">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
<script lang="ts" setup>
import { onMounted, onUnmounted } from 'vue';
export default defineComponent({
props: {
autoSticky: {
type: Boolean,
required: false,
default: false,
},
},
const props = withDefaults(defineProps<{
autoSticky?: boolean;
}>(), {
autoSticky: false,
})
setup(props, context) {
const rootEl = ref<HTMLElement>(null);
const bodyEl = ref<HTMLElement>(null);
const rootEl = $ref<HTMLElement>();
const bodyEl = $ref<HTMLElement>();
const calc = () => {
const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px';
let headerHeight = $ref<string | undefined>();
const header = rootEl.value.children[0];
if (header === bodyEl.value) {
bodyEl.value.style.setProperty('--stickyTop', currentStickyTop);
} else {
bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
const calc = () => {
const currentStickyTop = getComputedStyle(rootEl).getPropertyValue('--stickyTop') || '0px';
if (props.autoSticky) {
header.style.setProperty('--stickyTop', currentStickyTop);
header.style.position = 'sticky';
header.style.top = 'var(--stickyTop)';
header.style.zIndex = '1';
}
}
};
const header = rootEl.children[0] as HTMLElement;
if (header === bodyEl) {
bodyEl.style.setProperty('--stickyTop', currentStickyTop);
} else {
bodyEl.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
headerHeight = header.offsetHeight.toString();
onMounted(() => {
calc();
if (props.autoSticky) {
header.style.setProperty('--stickyTop', currentStickyTop);
header.style.position = 'sticky';
header.style.top = 'var(--stickyTop)';
header.style.zIndex = '1';
}
}
};
const observer = new MutationObserver(() => {
window.setTimeout(() => {
calc();
}, 100);
});
const observer = new MutationObserver(() => {
window.setTimeout(() => {
calc();
}, 100);
});
observer.observe(rootEl.value, {
attributes: false,
childList: true,
subtree: false,
});
onMounted(() => {
calc();
onUnmounted(() => {
observer.disconnect();
});
});
observer.observe(rootEl, {
attributes: false,
childList: true,
subtree: false,
});
});
return {
rootEl,
bodyEl,
};
},
onUnmounted(() => {
observer.disconnect();
});
</script>

View File

@ -9,7 +9,16 @@
<template #default="{ items: notes }">
<div class="giivymft" :class="{ noGap }">
<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes">
<XList
ref="notes"
v-slot="{ item: note }"
:items="notes"
:direction="pagination.reversed ? 'up' : 'down'"
:reversed="pagination.reversed"
:no-gap="noGap"
:ad="true"
class="notes"
>
<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note"/>
</XList>
</div>

View File

@ -14,15 +14,15 @@
</div>
<div v-else ref="rootEl">
<div v-show="pagination.reversed && more" key="_more_" class="cxiknjgy _gap">
<MkButton v-if="!moreFetching" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMoreAhead">
<div v-if="pagination.reversed" v-show="more" key="_more_" class="cxiknjgy _gap">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
{{ $ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading"/>
</div>
<slot :items="items"></slot>
<div v-show="!pagination.reversed && more" key="_more_" class="cxiknjgy _gap">
<MkButton v-if="!moreFetching" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
<slot :items="items" :fetching="fetching || moreFetching"></slot>
<div v-if="!pagination.reversed" v-show="more" key="_more_" class="cxiknjgy _gap">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
{{ $ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading"/>
@ -31,14 +31,17 @@
</transition>
</template>
<script lang="ts" setup>
import { computed, ComputedRef, isRef, markRaw, onActivated, onDeactivated, Ref, ref, watch } from 'vue';
<script lang="ts">
import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
import * as misskey from 'misskey-js';
import * as os from '@/os';
import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll';
import MkButton from '@/components/ui/button.vue';
import { defaultStore } from '@/store';
import { MisskeyEntity } from '@/types/date-separated-list';
const SECOND_FETCH_LIMIT = 30;
const TOLERANCE = 16;
export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = {
endpoint: E;
@ -57,8 +60,11 @@ export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints>
reversed?: boolean;
offsetMode?: boolean;
};
pageEl?: HTMLElement;
};
</script>
<script lang="ts" setup>
const props = withDefaults(defineProps<{
pagination: Paging;
disableAutoLoad?: boolean;
@ -71,21 +77,67 @@ const emit = defineEmits<{
(ev: 'queue', count: number): void;
}>();
type Item = { id: string; [another: string]: unknown; };
let rootEl = $ref<HTMLElement>();
const rootEl = ref<HTMLElement>();
const items = ref<Item[]>([]);
const queue = ref<Item[]>([]);
//
let backed = $ref(false);
let scrollRemove = $ref<(() => void) | null>(null);
const items = ref<MisskeyEntity[]>([]);
const queue = ref<MisskeyEntity[]>([]);
const offset = ref(0);
const fetching = ref(true);
const moreFetching = ref(false);
const more = ref(false);
const backed = ref(false); //
const isBackTop = ref(false);
const empty = computed(() => items.value.length === 0);
const error = ref(false);
const {
enableInfiniteScroll
} = defaultStore.reactiveState;
const init = async (): Promise<void> => {
const contentEl = $computed(() => props.pagination.pageEl || rootEl);
const scrollableElement = $computed(() => getScrollContainer(contentEl));
//
// https://qiita.com/mkataigi/items/0154aefd2223ce23398e
const scrollObserver = $computed(() => new IntersectionObserver(entries => {
backed = entries[0].isIntersecting;
}, {
root: scrollableElement,
rootMargin: props.pagination.reversed ? "-100% 0px 100% 0px" : "100% 0px -100% 0px",
threshold: 0.01,
}));
watch($$(rootEl), () => {
scrollObserver.disconnect();
nextTick(() => {
if (rootEl) scrollObserver.observe(rootEl);
});
});
watch([$$(backed), $$(contentEl)], () => {
if (!backed) {
if (!contentEl) return;
scrollRemove = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl, executeQueue, TOLERANCE);
} else {
if (scrollRemove) scrollRemove();
scrollRemove = null;
}
});
if (props.pagination.params && isRef(props.pagination.params)) {
watch(props.pagination.params, init, { deep: true });
}
watch(queue, (a, b) => {
if (a.length === 0 && b.length === 0) return;
emit('queue', queue.value.length);
}, { deep: true });
async function init(): Promise<void> {
queue.value = [];
fetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
@ -95,18 +147,15 @@ const init = async (): Promise<void> => {
}).then(res => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (props.pagination.reversed) {
if (i === res.length - 2) item._shouldInsertAd_ = true;
} else {
if (i === 3) item._shouldInsertAd_ = true;
}
if (i === 3) item._shouldInsertAd_ = true;
}
if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) {
res.pop();
items.value = props.pagination.reversed ? [...res].reverse() : res;
if (props.pagination.reversed) moreFetching.value = true;
items.value = res;
more.value = true;
} else {
items.value = props.pagination.reversed ? [...res].reverse() : res;
items.value = res;
more.value = false;
}
offset.value = res.length;
@ -118,15 +167,14 @@ const init = async (): Promise<void> => {
});
};
const reload = (): void => {
const reload = (): Promise<void> => {
items.value = [];
init();
return init();
};
const fetchMore = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
moreFetching.value = true;
backed.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await os.api(props.pagination.endpoint, {
...params,
@ -134,27 +182,57 @@ const fetchMore = async (): Promise<void> => {
...(props.pagination.offsetMode ? {
offset: offset.value,
} : {
untilId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id,
untilId: items.value[items.value.length - 1].id,
}),
}).then(res => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (props.pagination.reversed) {
if (i === res.length - 9) item._shouldInsertAd_ = true;
} else {
if (i === 10) item._shouldInsertAd_ = true;
}
if (i === 10) item._shouldInsertAd_ = true;
}
const reverseConcat = _res => {
const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight();
const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY;
items.value = items.value.concat(_res);
return nextTick(() => {
if (scrollableElement) {
scroll(scrollableElement, { top: oldScroll + (scrollableElement.scrollHeight - oldHeight), behavior: 'instant' });
} else {
window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' });
}
return nextTick();
});
};
if (res.length > SECOND_FETCH_LIMIT) {
res.pop();
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
more.value = true;
if (props.pagination.reversed) {
reverseConcat(res).then(() => {
more.value = true;
moreFetching.value = false;
});
} else {
items.value = items.value.concat(res);
more.value = true;
moreFetching.value = false;
}
} else {
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
more.value = false;
if (props.pagination.reversed) {
reverseConcat(res).then(() => {
more.value = false;
moreFetching.value = false;
});
} else {
items.value = items.value.concat(res);
more.value = false;
moreFetching.value = false;
}
}
offset.value += res.length;
moreFetching.value = false;
}, err => {
moreFetching.value = false;
});
@ -170,15 +248,15 @@ const fetchMoreAhead = async (): Promise<void> => {
...(props.pagination.offsetMode ? {
offset: offset.value,
} : {
sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id,
sinceId: items.value[items.value.length - 1].id,
}),
}).then(res => {
if (res.length > SECOND_FETCH_LIMIT) {
res.pop();
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
items.value = items.value.concat(res);
more.value = true;
} else {
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
items.value = items.value.concat(res) ;
more.value = false;
}
offset.value += res.length;
@ -188,104 +266,96 @@ const fetchMoreAhead = async (): Promise<void> => {
});
};
const prepend = (item: Item): void => {
if (props.pagination.reversed) {
if (rootEl.value) {
const container = getScrollContainer(rootEl.value);
if (container == null) return; // TODO?
const pos = getScrollPosition(rootEl.value);
const viewHeight = container.clientHeight;
const height = container.scrollHeight;
const isBottom = (pos + viewHeight > height - 32);
if (isBottom) {
//
if (items.value.length >= props.displayLimit) {
// Vue 3.2
//items.value = items.value.slice(-props.displayLimit);
while (items.value.length >= props.displayLimit) {
items.value.shift();
}
more.value = true;
}
}
}
items.value.push(item);
// TODO
} else {
// unshiftOK
if (!rootEl.value) {
items.value.unshift(item);
return;
}
const isTop = isBackTop.value || (document.body.contains(rootEl.value) && isTopVisible(rootEl.value));
if (isTop) {
// Prepend the item
items.value.unshift(item);
//
if (items.value.length >= props.displayLimit) {
// Vue 3.2
//this.items = items.value.slice(0, props.displayLimit);
while (items.value.length >= props.displayLimit) {
items.value.pop();
}
more.value = true;
}
} else {
queue.value.push(item);
onScrollTop(rootEl.value, () => {
for (const item of queue.value) {
prepend(item);
}
queue.value = [];
});
}
const prepend = (item: MisskeyEntity): void => {
// unshiftOK
if (!rootEl) {
items.value.unshift(item);
return;
}
const isTop = isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE);
if (isTop) unshiftItems([item]);
else prependQueue(item);
};
const append = (item: Item): void => {
function unshiftItems(newItems: MisskeyEntity[]) {
const length = newItems.length + items.value.length;
items.value = [ ...newItems, ...items.value ].slice(0, props.displayLimit);
if (length >= props.displayLimit) more.value = true;
}
function executeQueue() {
if (queue.value.length === 0) return;
unshiftItems(queue.value);
queue.value = [];
}
function prependQueue(newItem: MisskeyEntity) {
queue.value.unshift(newItem);
if (queue.value.length >= props.displayLimit) {
queue.value.pop();
}
}
const appendItem = (item: MisskeyEntity): void => {
items.value.push(item);
};
const removeItem = (finder: (item: Item) => boolean) => {
const removeItem = (finder: (item: MisskeyEntity) => boolean) => {
const i = items.value.findIndex(finder);
items.value.splice(i, 1);
};
const updateItem = (id: Item['id'], replacer: (old: Item) => Item): void => {
const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => {
const i = items.value.findIndex(item => item.id === id);
items.value[i] = replacer(items.value[i]);
};
if (props.pagination.params && isRef(props.pagination.params)) {
watch(props.pagination.params, init, { deep: true });
}
watch(queue, (a, b) => {
if (a.length === 0 && b.length === 0) return;
emit('queue', queue.value.length);
}, { deep: true });
init();
const inited = init();
onActivated(() => {
isBackTop.value = false;
});
onDeactivated(() => {
isBackTop.value = window.scrollY === 0;
isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl ? rootEl?.scrollHeight - window.innerHeight : 0) : window.scrollY === 0;
});
function toBottom() {
scrollToBottom(contentEl);
}
onMounted(() => {
inited.then(() => {
if (props.pagination.reversed) {
nextTick(() => {
setTimeout(toBottom, 800);
// scrollToBottommoreFetching
// more = true
setTimeout(() => {
moreFetching.value = false;
}, 2000);
});
}
});
});
onBeforeUnmount(() => {
scrollObserver.disconnect();
});
defineExpose({
items,
queue,
backed,
more,
inited,
reload,
prepend,
append,
append: appendItem,
removeItem,
updateItem,
});

View File

@ -62,10 +62,6 @@ function dragClear(fn) {
}
export default defineComponent({
provide: {
inWindow: true
},
props: {
padding: {
type: Boolean,

View File

@ -5,8 +5,10 @@ export default {
//const query = binding.value;
const header = src.children[0];
const body = src.children[1];
const currentStickyTop = getComputedStyle(src).getPropertyValue('--stickyTop') || '0px';
src.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
if (body) body.dataset.stickyContainerHeaderHeight = header.offsetHeight.toString();
header.style.setProperty('--stickyTop', currentStickyTop);
header.style.position = 'sticky';
header.style.top = 'var(--stickyTop)';

View File

@ -1,222 +1,222 @@
<template>
<div class="pemppnzi _block"
<div
class="pemppnzi _block"
@dragover.stop="onDragover"
@drop.stop="onDrop"
>
<textarea
ref="text"
ref="textEl"
v-model="text"
:placeholder="$ts.inputMessageHere"
:placeholder="i18n.ts.inputMessageHere"
@keydown="onKeydown"
@compositionupdate="onCompositionUpdate"
@paste="onPaste"
></textarea>
<div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
<button class="send _button" :disabled="!canSend || sending" :title="$ts.send" @click="send">
<template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>
</button>
<button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button>
<button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button>
<input ref="file" type="file" @change="onChangeFile"/>
<footer>
<div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
<div class="buttons">
<button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button>
<button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button>
<button class="send _button" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send">
<template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>
</button>
</div>
</footer>
<input ref="fileEl" type="file" @change="onChangeFile"/>
</div>
</template>
<script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue';
import insertTextAtCursor from 'insert-text-at-cursor';
<script lang="ts" setup>
import { onMounted, watch } from 'vue';
import * as Misskey from 'misskey-js';
import autosize from 'autosize';
//import insertTextAtCursor from 'insert-text-at-cursor';
import { formatTimeString } from '@/scripts/format-time-string';
import { selectFile } from '@/scripts/select-file';
import * as os from '@/os';
import { stream } from '@/stream';
import { Autocomplete } from '@/scripts/autocomplete';
import { throttle } from 'throttle-debounce';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
//import { Autocomplete } from '@/scripts/autocomplete';
import { uploadFile } from '@/scripts/upload';
export default defineComponent({
props: {
user: {
type: Object,
requird: false,
},
group: {
type: Object,
requird: false,
},
},
data() {
return {
text: null,
file: null,
sending: false,
typing: throttle(3000, () => {
stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id });
}),
};
},
computed: {
draftKey(): string {
return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
},
canSend(): boolean {
return (this.text != null && this.text !== '') || this.file != null;
},
room(): any {
return this.$parent;
const props = defineProps<{
user?: Misskey.entities.UserDetailed | null;
group?: Misskey.entities.UserGroup | null;
}>();
let textEl = $ref<HTMLTextAreaElement>();
let fileEl = $ref<HTMLInputElement>();
let text = $ref<string>('');
let file = $ref<Misskey.entities.DriveFile | null>(null);
let sending = $ref(false);
const typing = throttle(3000, () => {
stream.send('typingOnMessaging', props.user ? { partner: props.user.id } : { group: props.group?.id });
});
let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id);
let canSend = $computed(() => (text != null && text !== '') || file != null);
watch([$$(text), $$(file)], saveDraft);
async function onPaste(ev: ClipboardEvent) {
if (!ev.clipboardData) return;
const clipboardData = ev.clipboardData;
const items = clipboardData.items;
if (items.length === 1) {
if (items[0].kind === 'file') {
const pastedFile = items[0].getAsFile();
if (!pastedFile) return;
const lio = pastedFile.name.lastIndexOf('.');
const ext = lio >= 0 ? pastedFile.name.slice(lio) : '';
const formatted = formatTimeString(new Date(pastedFile.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, '1') + ext;
if (formatted) upload(pastedFile, formatted);
}
},
watch: {
text() {
this.saveDraft();
},
file() {
this.saveDraft();
}
},
mounted() {
autosize(this.$refs.text);
// TODO: detach when unmount
// TODO
//new Autocomplete(this.$refs.text, this, { model: 'text' });
// 稿
const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey];
if (draft) {
this.text = draft.data.text;
this.file = draft.data.file;
}
},
methods: {
async onPaste(evt: ClipboardEvent) {
const items = evt.clipboardData.items;
if (items.length === 1) {
if (items[0].kind === 'file') {
const file = items[0].getAsFile();
const lio = file.name.lastIndexOf('.');
const ext = lio >= 0 ? file.name.slice(lio) : '';
const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, '1')}${ext}`;
if (formatted) this.upload(file, formatted);
}
} else {
if (items[0].kind === 'file') {
os.alert({
type: 'error',
text: this.$ts.onlyOneFileCanBeAttached
});
}
}
},
onDragover(evt) {
const isFile = evt.dataTransfer.items[0].kind === 'file';
const isDriveFile = evt.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
if (isFile || isDriveFile) {
evt.preventDefault();
evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
}
},
onDrop(evt): void {
//
if (evt.dataTransfer.files.length === 1) {
evt.preventDefault();
this.upload(evt.dataTransfer.files[0]);
return;
} else if (evt.dataTransfer.files.length > 1) {
evt.preventDefault();
os.alert({
type: 'error',
text: this.$ts.onlyOneFileCanBeAttached
});
return;
}
//#region
const driveFile = evt.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') {
this.file = JSON.parse(driveFile);
evt.preventDefault();
}
//#endregion
},
onKeydown(evt) {
this.typing();
if ((evt.which === 10 || evt.which === 13) && (evt.ctrlKey || evt.metaKey) && this.canSend) {
this.send();
}
},
onCompositionUpdate() {
this.typing();
},
chooseFile(evt) {
selectFile(evt.currentTarget ?? evt.target, this.$ts.selectFile).then(file => {
this.file = file;
} else {
if (items[0].kind === 'file') {
os.alert({
type: 'error',
text: i18n.ts.onlyOneFileCanBeAttached
});
},
onChangeFile() {
this.upload((this.$refs.file as any).files[0]);
},
upload(file: File, name?: string) {
uploadFile(file, this.$store.state.uploadFolder, name).then(res => {
this.file = res;
});
},
send() {
this.sending = true;
os.api('messaging/messages/create', {
userId: this.user ? this.user.id : undefined,
groupId: this.group ? this.group.id : undefined,
text: this.text ? this.text : undefined,
fileId: this.file ? this.file.id : undefined
}).then(message => {
this.clear();
}).catch(err => {
console.error(err);
}).then(() => {
this.sending = false;
});
},
clear() {
this.text = '';
this.file = null;
this.deleteDraft();
},
saveDraft() {
const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
drafts[this.draftKey] = {
updatedAt: new Date(),
data: {
text: this.text,
file: this.file
}
};
localStorage.setItem('message_drafts', JSON.stringify(drafts));
},
deleteDraft() {
const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
delete drafts[this.draftKey];
localStorage.setItem('message_drafts', JSON.stringify(drafts));
},
async insertEmoji(ev) {
os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, this.$refs.text);
}
}
}
function onDragover(ev: DragEvent) {
if (!ev.dataTransfer) return;
const isFile = ev.dataTransfer.items[0].kind === 'file';
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
if (isFile || isDriveFile) {
ev.preventDefault();
ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
}
}
function onDrop(ev: DragEvent): void {
if (!ev.dataTransfer) return;
//
if (ev.dataTransfer.files.length === 1) {
ev.preventDefault();
upload(ev.dataTransfer.files[0]);
return;
} else if (ev.dataTransfer.files.length > 1) {
ev.preventDefault();
os.alert({
type: 'error',
text: i18n.ts.onlyOneFileCanBeAttached
});
return;
}
//#region
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') {
file = JSON.parse(driveFile);
ev.preventDefault();
}
//#endregion
}
function onKeydown(ev: KeyboardEvent) {
typing();
if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey) && canSend) {
send();
}
}
function onCompositionUpdate() {
typing();
}
function chooseFile(ev: MouseEvent) {
selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => {
file = selectedFile;
});
}
function onChangeFile() {
if (fileEl?.files![0]) upload(fileEl.files[0]);
}
function upload(fileToUpload: File, name?: string) {
uploadFile(fileToUpload, defaultStore.state.uploadFolder, name).then(res => {
file = res;
});
}
function send() {
sending = true;
os.api('messaging/messages/create', {
userId: props.user ? props.user.id : undefined,
groupId: props.group ? props.group.id : undefined,
text: text ? text : undefined,
fileId: file ? file.id : undefined
}).then(message => {
clear();
}).catch(err => {
console.error(err);
}).then(() => {
sending = false;
});
}
function clear() {
text = '';
file = null;
deleteDraft();
}
function saveDraft() {
const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
drafts[draftKey] = {
updatedAt: new Date(),
// eslint-disable-next-line id-denylist
data: {
text: text,
file: file
}
}
localStorage.setItem('message_drafts', JSON.stringify(drafts));
}
function deleteDraft() {
const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
delete drafts[draftKey];
localStorage.setItem('message_drafts', JSON.stringify(drafts));
}
async function insertEmoji(ev: MouseEvent) {
os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textEl);
}
onMounted(() => {
autosize(textEl);
// TODO: detach when unmount
// TODO
//new Autocomplete(textEl, this, { model: 'text' });
// 稿
const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[draftKey];
if (draft) {
text = draft.data.text;
file = draft.data.file;
}
});
defineExpose({
file,
upload,
});
</script>
@ -230,7 +230,7 @@ export default defineComponent({
width: 100%;
min-width: 100%;
max-width: 100%;
height: 80px;
min-height: 80px;
margin: 0;
padding: 16px 16px 0 16px;
resize: none;
@ -245,26 +245,16 @@ export default defineComponent({
color: var(--fg);
}
> .file {
padding: 8px;
color: #444;
background: #eee;
cursor: pointer;
}
> .send {
position: absolute;
footer {
position: sticky;
bottom: 0;
right: 0;
margin: 0;
padding: 16px;
font-size: 1em;
transition: color 0.1s ease;
color: var(--accent);
background: var(--panel);
&:active {
color: var(--accentDarken);
transition: color 0s ease;
> .file {
padding: 8px;
color: var(--fg);
background: transparent;
cursor: pointer;
}
}
@ -316,21 +306,39 @@ export default defineComponent({
}
}
._button {
margin: 0;
padding: 16px;
font-size: 1em;
font-weight: normal;
text-decoration: none;
transition: color 0.1s ease;
.buttons {
display: flex;
&:hover {
color: var(--accent);
._button {
margin: 0;
padding: 16px;
font-size: 1em;
font-weight: normal;
text-decoration: none;
transition: color 0.1s ease;
&:hover {
color: var(--accent);
}
&:active {
color: var(--accentDarken);
transition: color 0s ease;
}
}
&:active {
color: var(--accentDarken);
transition: color 0s ease;
> .send {
margin-left: auto;
color: var(--accent);
&:hover {
color: var(--accentLighten);
}
&:active {
color: var(--accentDarken);
transition: color 0s ease;
}
}
}

View File

@ -35,45 +35,28 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
import MkUrlPreview from '@/components/url-preview.vue';
import * as os from '@/os';
import { $i } from '@/account';
export default defineComponent({
components: {
MkUrlPreview
},
props: {
message: {
required: true
},
isGroup: {
required: false
}
},
computed: {
isMe(): boolean {
return this.message.userId === this.$i.id;
},
urls(): string[] {
if (this.message.text) {
return extractUrlFromMfm(mfm.parse(this.message.text));
} else {
return [];
}
}
},
methods: {
del() {
os.api('messaging/messages/delete', {
messageId: this.message.id
});
}
}
});
const props = defineProps<{
message: Misskey.entities.MessagingMessage;
isGroup?: boolean;
}>();
const isMe = $computed(() => props.message.userId === $i?.id);
const urls = $computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
function del() {
os.api('messaging/messages/delete', {
messageId: props.message.id
});
}
</script>
<style lang="scss" scoped>
@ -266,6 +249,7 @@ export default defineComponent({
&.isMe {
flex-direction: row-reverse;
padding-right: var(--margin);
right: var(--margin); // position: absolute使
> .content {
padding-right: 16px;

View File

@ -1,379 +1,305 @@
<template>
<div class="_section"
<div
ref="rootEl"
class="_section"
@dragover.prevent.stop="onDragover"
@drop.prevent.stop="onDrop"
>
<div class="_content mk-messaging-room">
<div class="body">
<MkLoading v-if="fetching"/>
<p v-if="!fetching && messages.length == 0" class="empty"><i class="fas fa-info-circle"></i>{{ $ts.noMessagesYet }}</p>
<p v-if="!fetching && messages.length > 0 && !existMoreMessages" class="no-history"><i class="fas fa-flag"></i>{{ $ts.noMoreHistory }}</p>
<button v-show="existMoreMessages" ref="loadMore" class="more _button" :class="{ fetching: fetchingMoreMessages }" :disabled="fetchingMoreMessages" @click="fetchMoreMessages">
<template v-if="fetchingMoreMessages"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ fetchingMoreMessages ? $ts.loading : $ts.loadMore }}
</button>
<XList v-if="messages.length > 0" v-slot="{ item: message }" class="messages" :items="messages" direction="up" reversed>
<XMessage :key="message.id" :message="message" :is-group="group != null"/>
</XList>
<MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ i18n.ts.noMessagesYet }}</div>
</div>
</template>
<template #default="{ items: messages, fetching: pFetching }">
<XList
v-if="messages.length > 0"
v-slot="{ item: message }"
:class="{ messages: true, 'deny-move-transition': pFetching }"
:items="messages"
direction="up"
reversed
>
<XMessage :key="message.id" :message="message" :is-group="group != null"/>
</XList>
</template>
</MkPagination>
</div>
<footer>
<div v-if="typers.length > 0" class="typers">
<I18n :src="$ts.typingUsers" text-tag="span" class="users">
<I18n :src="i18n.ts.typingUsers" text-tag="span" class="users">
<template #users>
<b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b>
<b v-for="typer in typers" :key="typer.id" class="user">{{ typer.username }}</b>
</template>
</I18n>
<MkEllipsis/>
</div>
<transition :name="$store.state.animation ? 'fade' : ''">
<transition :name="animation ? 'fade' : ''">
<div v-show="showIndicator" class="new-message">
<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-arrow-circle-down"></i>{{ $ts.newMessageExists }}</button>
<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button>
</div>
</transition>
<XForm v-if="!fetching" ref="form" :user="user" :group="group" class="form"/>
<XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/>
</footer>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, markRaw } from 'vue';
<script lang="ts" setup>
import { computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue';
import * as Misskey from 'misskey-js';
import * as Acct from 'misskey-js/built/acct';
import XList from '@/components/date-separated-list.vue';
import MkPagination, { Paging } from '@/components/ui/pagination.vue';
import XMessage from './messaging-room.message.vue';
import XForm from './messaging-room.form.vue';
import * as Acct from 'misskey-js/built/acct';
import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll';
import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll';
import * as os from '@/os';
import { stream } from '@/stream';
import { popout } from '@/scripts/popout';
import * as sound from '@/scripts/sound';
import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
import { $i } from '@/account';
import { defaultStore } from '@/store';
const Component = defineComponent({
components: {
XMessage,
XForm,
XList,
},
const props = defineProps<{
userAcct?: string;
groupId?: string;
}>();
inject: ['inWindow'],
let rootEl = $ref<HTMLDivElement>();
let formEl = $ref<InstanceType<typeof XForm>>();
let pagingComponent = $ref<InstanceType<typeof MkPagination>>();
props: {
userAcct: {
type: String,
required: false,
},
groupId: {
type: String,
required: false,
},
},
let fetching = $ref(true);
let user: Misskey.entities.UserDetailed | null = $ref(null);
let group: Misskey.entities.UserGroup | null = $ref(null);
let typers: Misskey.entities.User[] = $ref([]);
let connection: Misskey.ChannelConnection<Misskey.Channels['messaging']> | null = $ref(null);
let showIndicator = $ref(false);
const {
animation
} = defaultStore.reactiveState;
data() {
return {
[symbols.PAGE_INFO]: computed(() => !this.fetching ? this.user ? {
userName: this.user,
avatar: this.user,
action: {
icon: 'fas fa-ellipsis-h',
handler: this.menu,
},
} : {
title: this.group.name,
icon: 'fas fa-users',
action: {
icon: 'fas fa-ellipsis-h',
handler: this.menu,
},
} : null),
fetching: true,
user: null,
group: null,
fetchingMoreMessages: false,
messages: [],
existMoreMessages: false,
connection: null,
showIndicator: false,
timer: null,
typers: [],
ilObserver: new IntersectionObserver(
(entries) => entries.some((entry) => entry.isIntersecting)
&& !this.fetching
&& !this.fetchingMoreMessages
&& this.existMoreMessages
&& this.fetchMoreMessages()
),
};
},
let pagination: Paging | null = $ref(null);
computed: {
form(): any {
return this.$refs.form;
}
},
watch: {
userAcct: 'fetch',
groupId: 'fetch',
},
mounted() {
this.fetch();
if (this.$store.state.enableInfiniteScroll) {
this.$nextTick(() => this.ilObserver.observe(this.$refs.loadMore as Element));
}
},
beforeUnmount() {
this.connection.dispose();
document.removeEventListener('visibilitychange', this.onVisibilitychange);
this.ilObserver.disconnect();
},
methods: {
async fetch() {
this.fetching = true;
if (this.userAcct) {
const user = await os.api('users/show', Acct.parse(this.userAcct));
this.user = user;
} else {
const group = await os.api('users/groups/show', { groupId: this.groupId });
this.group = group;
}
this.connection = markRaw(stream.useChannel('messaging', {
otherparty: this.user ? this.user.id : undefined,
group: this.group ? this.group.id : undefined,
}));
this.connection.on('message', this.onMessage);
this.connection.on('read', this.onRead);
this.connection.on('deleted', this.onDeleted);
this.connection.on('typers', typers => {
this.typers = typers.filter(u => u.id !== this.$i.id);
});
document.addEventListener('visibilitychange', this.onVisibilitychange);
this.fetchMessages().then(() => {
this.scrollToBottom();
// fetch
// false
// scrollendsetTimeout
window.setTimeout(() => this.fetching = false, 300);
});
},
onDragover(evt) {
const isFile = evt.dataTransfer.items[0].kind === 'file';
const isDriveFile = evt.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
if (isFile || isDriveFile) {
evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
} else {
evt.dataTransfer.dropEffect = 'none';
}
},
onDrop(evt): void {
//
if (evt.dataTransfer.files.length === 1) {
this.form.upload(evt.dataTransfer.files[0]);
return;
} else if (evt.dataTransfer.files.length > 1) {
os.alert({
type: 'error',
text: this.$ts.onlyOneFileCanBeAttached
});
return;
}
//#region
const driveFile = evt.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') {
const file = JSON.parse(driveFile);
this.form.file = file;
}
//#endregion
},
fetchMessages() {
return new Promise((resolve, reject) => {
const max = this.existMoreMessages ? 20 : 10;
os.api('messaging/messages', {
userId: this.user ? this.user.id : undefined,
groupId: this.group ? this.group.id : undefined,
limit: max + 1,
untilId: this.existMoreMessages ? this.messages[0].id : undefined
}).then(messages => {
if (messages.length === max + 1) {
this.existMoreMessages = true;
messages.pop();
} else {
this.existMoreMessages = false;
}
this.messages.unshift.apply(this.messages, messages.reverse());
resolve();
});
});
},
fetchMoreMessages() {
this.fetchingMoreMessages = true;
this.fetchMessages().then(() => {
this.fetchingMoreMessages = false;
});
},
onMessage(message) {
sound.play('chat');
const _isBottom = isBottom(this.$el, 64);
this.messages.push(message);
if (message.userId !== this.$i.id && !document.hidden) {
this.connection.send('read', {
id: message.id
});
}
if (_isBottom) {
// Scroll to bottom
this.$nextTick(() => {
this.scrollToBottom();
});
} else if (message.userId !== this.$i.id) {
// Notify
this.notifyNewMessage();
}
},
onRead(x) {
if (this.user) {
if (!Array.isArray(x)) x = [x];
for (const id of x) {
if (this.messages.some(x => x.id === id)) {
const exist = this.messages.map(x => x.id).indexOf(id);
this.messages[exist] = {
...this.messages[exist],
isRead: true,
};
}
}
} else if (this.group) {
for (const id of x.ids) {
if (this.messages.some(x => x.id === id)) {
const exist = this.messages.map(x => x.id).indexOf(id);
this.messages[exist] = {
...this.messages[exist],
reads: [...this.messages[exist].reads, x.userId]
};
}
}
}
},
onDeleted(id) {
const msg = this.messages.find(m => m.id === id);
if (msg) {
this.messages = this.messages.filter(m => m.id !== msg.id);
}
},
scrollToBottom() {
scroll(this.$el, { top: this.$el.offsetHeight });
},
onIndicatorClick() {
this.showIndicator = false;
this.scrollToBottom();
},
notifyNewMessage() {
this.showIndicator = true;
onScrollBottom(this.$el, () => {
this.showIndicator = false;
});
if (this.timer) window.clearTimeout(this.timer);
this.timer = window.setTimeout(() => {
this.showIndicator = false;
}, 4000);
},
onVisibilitychange() {
if (document.hidden) return;
for (const message of this.messages) {
if (message.userId !== this.$i.id && !message.isRead) {
this.connection.send('read', {
id: message.id
});
}
}
},
menu(ev) {
const path = this.groupId ? `/my/messaging/group/${this.groupId}` : `/my/messaging/${this.userAcct}`;
os.popupMenu([this.inWindow ? undefined : {
text: this.$ts.openInWindow,
icon: 'fas fa-window-maximize',
action: () => {
os.pageWindow(path);
this.$router.back();
},
}, this.inWindow ? undefined : {
text: this.$ts.popout,
icon: 'fas fa-external-link-alt',
action: () => {
popout(path);
this.$router.back();
},
}], ev.currentTarget ?? ev.target);
}
}
watch([() => props.userAcct, () => props.groupId], () => {
if (connection) connection.dispose();
fetch();
});
export default Component;
async function fetch() {
fetching = true;
if (props.userAcct) {
const acct = Acct.parse(props.userAcct);
user = await os.api('users/show', { username: acct.username, host: acct.host || undefined });
group = null;
pagination = {
endpoint: 'messaging/messages',
limit: 20,
params: {
userId: user.id,
},
reversed: true,
pageEl: $$(rootEl).value,
};
connection = stream.useChannel('messaging', {
otherparty: user.id,
});
} else {
user = null;
group = await os.api('users/groups/show', { groupId: props.groupId });
pagination = {
endpoint: 'messaging/messages',
limit: 20,
params: {
groupId: group?.id,
},
reversed: true,
pageEl: $$(rootEl).value,
};
connection = stream.useChannel('messaging', {
group: group?.id,
});
}
connection.on('message', onMessage);
connection.on('read', onRead);
connection.on('deleted', onDeleted);
connection.on('typers', _typers => {
typers = _typers.filter(u => u.id !== $i?.id);
});
document.addEventListener('visibilitychange', onVisibilitychange);
nextTick(() => {
pagingComponent.inited.then(() => {
thisScrollToBottom();
});
window.setTimeout(() => {
fetching = false
}, 300);
});
}
function onDragover(ev: DragEvent) {
if (!ev.dataTransfer) return;
const isFile = ev.dataTransfer.items[0].kind === 'file';
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
if (isFile || isDriveFile) {
ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
} else {
ev.dataTransfer.dropEffect = 'none';
}
}
function onDrop(ev: DragEvent): void {
if (!ev.dataTransfer) return;
//
if (ev.dataTransfer.files.length === 1) {
formEl.upload(ev.dataTransfer.files[0]);
return;
} else if (ev.dataTransfer.files.length > 1) {
os.alert({
type: 'error',
text: i18n.ts.onlyOneFileCanBeAttached
});
return;
}
//#region
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') {
const file = JSON.parse(driveFile);
formEl.file = file;
}
//#endregion
}
function onMessage(message) {
sound.play('chat');
const _isBottom = isBottomVisible(rootEl, 64);
pagingComponent.prepend(message);
if (message.userId !== $i?.id && !document.hidden) {
connection?.send('read', {
id: message.id
});
}
if (_isBottom) {
// Scroll to bottom
nextTick(() => {
thisScrollToBottom();
});
} else if (message.userId !== $i?.id) {
// Notify
notifyNewMessage();
}
}
function onRead(x) {
if (user) {
if (!Array.isArray(x)) x = [x];
for (const id of x) {
if (pagingComponent.items.some(y => y.id === id)) {
const exist = pagingComponent.items.map(y => y.id).indexOf(id);
pagingComponent.items[exist] = {
...pagingComponent.items[exist],
isRead: true,
};
}
}
} else if (group) {
for (const id of x.ids) {
if (pagingComponent.items.some(y => y.id === id)) {
const exist = pagingComponent.items.map(y => y.id).indexOf(id);
pagingComponent.items[exist] = {
...pagingComponent.items[exist],
reads: [...pagingComponent.items[exist].reads, x.userId]
};
}
}
}
}
function onDeleted(id) {
const msg = pagingComponent.items.find(m => m.id === id);
if (msg) {
pagingComponent.items = pagingComponent.items.filter(m => m.id !== msg.id);
}
}
function thisScrollToBottom() {
scrollToBottom($$(rootEl).value, { behavior: "smooth" });
}
function onIndicatorClick() {
showIndicator = false;
thisScrollToBottom();
}
let scrollRemove: (() => void) | null = $ref(null);
function notifyNewMessage() {
showIndicator = true;
scrollRemove = onScrollBottom(rootEl, () => {
showIndicator = false;
scrollRemove = null;
});
}
function onVisibilitychange() {
if (document.hidden) return;
for (const message of pagingComponent.items) {
if (message.userId !== $i?.id && !message.isRead) {
connection?.send('read', {
id: message.id
});
}
}
}
onMounted(() => {
fetch();
});
onBeforeUnmount(() => {
connection?.dispose();
document.removeEventListener('visibilitychange', onVisibilitychange);
if (scrollRemove) scrollRemove();
});
defineExpose({
[symbols.PAGE_INFO]: computed(() => !fetching ? user ? {
userName: user,
avatar: user,
} : {
title: group?.name,
icon: 'fas fa-users',
} : null),
});
</script>
<style lang="scss" scoped>
.mk-messaging-room {
position: relative;
> .body {
> .empty {
width: 100%;
margin: 0;
padding: 16px 8px 8px 8px;
text-align: center;
font-size: 0.8em;
opacity: 0.5;
i {
margin-right: 4px;
}
}
> .no-history {
display: block;
margin: 0;
padding: 16px;
text-align: center;
font-size: 0.8em;
color: var(--messagingRoomInfo);
opacity: 0.5;
i {
margin-right: 4px;
}
}
> .more {
.more {
display: block;
margin: 16px auto;
padding: 0 12px;
@ -399,7 +325,9 @@ export default Component;
}
}
> .messages {
.messages {
padding-top: 8px;
> ::v-deep(*) {
margin-bottom: 16px;
}
@ -408,29 +336,31 @@ export default Component;
> footer {
width: 100%;
position: relative;
position: sticky;
z-index: 2;
bottom: 0;
padding-top: 8px;
@media (max-width: 500px) {
bottom: calc(env(safe-area-inset-bottom, 0px) + 92px);
}
> .new-message {
position: absolute;
top: -48px;
width: 100%;
padding: 8px 0;
padding-bottom: 8px;
text-align: center;
> button {
display: inline-block;
margin: 0;
padding: 0 12px 0 30px;
padding: 0 12px;
line-height: 32px;
font-size: 12px;
border-radius: 16px;
> i {
position: absolute;
top: 0;
left: 10px;
line-height: 32px;
font-size: 16px;
display: inline-block;
margin-right: 8px;
}
}
}
@ -455,6 +385,8 @@ export default Component;
}
> .form {
max-height: 12em;
overflow-y: scroll;
border-top: solid 0.5px var(--divider);
}
}

View File

@ -1,57 +1,76 @@
type ScrollBehavior = 'auto' | 'smooth' | 'instant';
export function getScrollContainer(el: Element | null): Element | null {
if (el == null || el.tagName === 'BODY') return null;
export function getScrollContainer(el: HTMLElement | null): HTMLElement | null {
if (el == null || el.tagName === 'HTML') return null;
const overflow = window.getComputedStyle(el).getPropertyValue('overflow');
if (overflow.endsWith('auto')) { // xとyを個別に指定している場合、hidden auto みたいな値になる
if (
// xとyを個別に指定している場合、`hidden scroll`みたいな値になる
overflow.endsWith('scroll') ||
overflow.endsWith('auto')
) {
return el;
} else {
return getScrollContainer(el.parentElement);
}
}
export function getScrollPosition(el: Element | null): number {
export function getStickyTop(el: HTMLElement, container: HTMLElement | null = null, top: number = 0) {
if (!el.parentElement) return top;
const data = el.dataset.stickyContainerHeaderHeight;
const newTop = data ? Number(data) + top : top;
if (el === container) return newTop;
return getStickyTop(el.parentElement, container, newTop);
}
export function getScrollPosition(el: HTMLElement | null): number {
const container = getScrollContainer(el);
return container == null ? window.scrollY : container.scrollTop;
}
export function isTopVisible(el: Element | null): boolean {
const scrollTop = getScrollPosition(el);
const topPosition = el.offsetTop; // TODO: container内でのelの相対位置を取得できればより正確になる
export function onScrollTop(el: HTMLElement, cb: Function, tolerance: number = 1, once: boolean = false) {
// とりあえず評価してみる
if (isTopVisible(el)) {
cb();
if (once) return null;
}
return scrollTop <= topPosition;
}
export function onScrollTop(el: Element, cb) {
const container = getScrollContainer(el) || window;
const onScroll = ev => {
if (!document.body.contains(el)) return;
if (isTopVisible(el)) {
if (isTopVisible(el, tolerance)) {
cb();
container.removeEventListener('scroll', onScroll);
if (once) removeListener();
}
};
function removeListener() { container.removeEventListener('scroll', onScroll) }
container.addEventListener('scroll', onScroll, { passive: true });
return removeListener;
}
export function onScrollBottom(el: Element, cb) {
const container = getScrollContainer(el) || window;
export function onScrollBottom(el: HTMLElement, cb: Function, tolerance: number = 1, once: boolean = false) {
const container = getScrollContainer(el);
// とりあえず評価してみる
if (isBottomVisible(el, tolerance, container)) {
cb();
if (once) return null;
}
const containerOrWindow = container || window;
const onScroll = ev => {
if (!document.body.contains(el)) return;
const pos = getScrollPosition(el);
if (pos + el.clientHeight > el.scrollHeight - 1) {
if (isBottomVisible(el, 1, container)) {
cb();
container.removeEventListener('scroll', onScroll);
if (once) removeListener();
}
};
container.addEventListener('scroll', onScroll, { passive: true });
function removeListener() { containerOrWindow.removeEventListener('scroll', onScroll) }
containerOrWindow.addEventListener('scroll', onScroll, { passive: true });
return removeListener;
}
export function scroll(el: Element, options: {
top?: number;
left?: number;
behavior?: ScrollBehavior;
}) {
export function scroll(el: HTMLElement, options: ScrollToOptions | undefined) {
const container = getScrollContainer(el);
if (container == null) {
window.scroll(options);
@ -60,21 +79,51 @@ export function scroll(el: Element, options: {
}
}
export function scrollToTop(el: Element, options: { behavior?: ScrollBehavior; } = {}) {
/**
* Scroll to Top
* @param el Scroll container element
* @param options Scroll options
*/
export function scrollToTop(el: HTMLElement, options: { behavior?: ScrollBehavior; } = {}) {
scroll(el, { top: 0, ...options });
}
export function scrollToBottom(el: Element, options: { behavior?: ScrollBehavior; } = {}) {
scroll(el, { top: 99999, ...options }); // TODO: ちゃんと計算する
/**
* Scroll to Bottom
* @param el Content element
* @param options Scroll options
* @param container Scroll container element
*/
export function scrollToBottom(
el: HTMLElement,
options: ScrollToOptions = {},
container = getScrollContainer(el),
) {
if (container) {
container.scroll({ top: el.scrollHeight - container.clientHeight + getStickyTop(el, container) || 0, ...options });
} else {
window.scroll({
top: (el.scrollHeight - window.innerHeight + getStickyTop(el, container) + (window.innerWidth <= 500 ? 96 : 0)) || 0,
...options
});
}
}
export function isBottom(el: Element, asobi = 0) {
const container = getScrollContainer(el);
const current = container
? el.scrollTop + el.offsetHeight
: window.scrollY + window.innerHeight;
const max = container
? el.scrollHeight
: document.body.offsetHeight;
return current >= (max - asobi);
export function isTopVisible(el: HTMLElement, tolerance: number = 1): boolean {
const scrollTop = getScrollPosition(el);
return scrollTop <= tolerance;
}
export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) {
if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance;
return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance;
}
// https://ja.javascript.info/size-and-scroll-window#ref-932
export function getBodyScrollHeight() {
return Math.max(
document.body.scrollHeight, document.documentElement.scrollHeight,
document.body.offsetHeight, document.documentElement.offsetHeight,
document.body.clientHeight, document.documentElement.clientHeight
);
}

View File

@ -0,0 +1,7 @@
export type MisskeyEntity = {
id: string;
createdAt: string;
_shouldInsertAd_?:
boolean;
[x: string]: any;
};

View File

@ -44,7 +44,6 @@ import { defineComponent, defineAsyncComponent } from 'vue';
import { host, instanceName } from '@/config';
import { search } from '@/scripts/search';
import * as os from '@/os';
import MkPagination from '@/components/ui/pagination.vue';
import MkButton from '@/components/ui/button.vue';
import XHeader from './header.vue';
import { ColdDeviceStorage } from '@/store';
@ -55,7 +54,6 @@ const DESKTOP_THRESHOLD = 1100;
export default defineComponent({
components: {
XHeader,
MkPagination,
MkButton,
},