feat: collepse renotes and replies
This commit is contained in:
parent
9138c3726a
commit
160e7f26a6
13 changed files with 560 additions and 205 deletions
|
@ -2241,4 +2241,5 @@ incorrectLanguageWarning: "It looks like your post is in {detected}, but you sel
|
|||
noteEditHistory: "Post edit history"
|
||||
slashQuote: "Chain quote"
|
||||
foldNotification: "Group similar notifications"
|
||||
collapseReplyInTimeline: "Collapse replied posts in timeline"
|
||||
mergeThreadInTimeline: "Merge posts in timeline into threads if possible"
|
||||
mergeRenotesInTimeline: "Merge boosts"
|
||||
|
|
|
@ -2068,4 +2068,5 @@ noteEditHistory: "帖子编辑历史"
|
|||
media: 媒体
|
||||
slashQuote: "斜杠引用"
|
||||
foldNotification: "将通知按同类型分组"
|
||||
collapseReplyInTimeline: "折叠显示时间线里被回复的帖子"
|
||||
mergeThreadInTimeline: "将时间线内的连续回复合并成一串"
|
||||
mergeRenotesInTimeline: "合并转发"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="!muted.muted"
|
||||
v-show="!isDeleted"
|
||||
v-show="!isDeleted && renotes?.length !== 0"
|
||||
:id="appearNote.historyId || appearNote.id"
|
||||
ref="el"
|
||||
v-hotkey="keymap"
|
||||
|
@ -10,13 +10,20 @@
|
|||
:aria-label="accessibleLabel"
|
||||
class="tkcbzcuz note-container"
|
||||
:tabindex="!isDeleted ? '-1' : undefined"
|
||||
:class="{ renote: isRenote }"
|
||||
:class="{ renote: isRenote || (renotesSliced && renotesSliced.length > 0) }"
|
||||
>
|
||||
<MkNoteSub
|
||||
v-if="appearNote.reply && !detailedView && !collapsedReply"
|
||||
v-if="appearNote.reply && !detailedView && !collapsedReply && !parents"
|
||||
:note="appearNote.reply"
|
||||
class="reply-to"
|
||||
/>
|
||||
<MkNoteSub
|
||||
v-else-if="!detailedView && !collapsedReply && parents"
|
||||
v-for="n of parents"
|
||||
:key="n.id"
|
||||
:note="n"
|
||||
class="reply-to"
|
||||
/>
|
||||
<div
|
||||
v-if="!detailedView"
|
||||
class="note-context"
|
||||
|
@ -41,35 +48,6 @@
|
|||
<div v-if="pinned" class="info">
|
||||
<i :class="icon('ph-push-pin')"></i>{{ i18n.ts.pinnedNote }}
|
||||
</div>
|
||||
<div v-if="isRenote" class="renote">
|
||||
<i :class="icon('ph-rocket-launch')"></i>
|
||||
<I18n :src="i18n.ts.renotedBy" tag="span">
|
||||
<template #user>
|
||||
<MkA
|
||||
v-user-preview="note.userId"
|
||||
class="name"
|
||||
:to="userPage(note.user)"
|
||||
@click.stop
|
||||
>
|
||||
<MkUserName :user="note.user" />
|
||||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
<div class="info">
|
||||
<button
|
||||
ref="renoteTime"
|
||||
class="_button time"
|
||||
@click.stop="showRenoteMenu()"
|
||||
>
|
||||
<i
|
||||
v-if="isMyRenote"
|
||||
:class="icon('ph-dots-three-outline dropdownIcon')"
|
||||
></i>
|
||||
<MkTime :time="note.createdAt" />
|
||||
</button>
|
||||
<MkVisibility :note="note" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="collapsedReply && appearNote.reply" class="info">
|
||||
<MkAvatar class="avatar" :user="appearNote.reply.user" />
|
||||
<MkUserName
|
||||
|
@ -85,6 +63,71 @@
|
|||
:custom-emojis="note.emojis"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isRenote || (renotesSliced && renotesSliced.length > 0)" class="renote">
|
||||
<i :class="icon('ph-rocket-launch')"></i>
|
||||
<I18n
|
||||
v-if="renotesSliced == null"
|
||||
:src="i18n.ts.renotedBy"
|
||||
tag="span"
|
||||
>
|
||||
<template #user>
|
||||
<MkAvatar class="avatar" :user="note.user" />
|
||||
<MkA
|
||||
v-user-preview="note.userId"
|
||||
class="name"
|
||||
:to="userPage(note.user)"
|
||||
@click.stop
|
||||
>
|
||||
<MkUserName :user="note.user" />
|
||||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n
|
||||
v-else
|
||||
:src="i18n.ts.renotedBy"
|
||||
tag="span"
|
||||
>
|
||||
<template #user>
|
||||
<template
|
||||
v-for="(renote, index) in renotesSliced"
|
||||
>
|
||||
<MkAvatar
|
||||
class="avatar"
|
||||
:user="renote.user"
|
||||
/>
|
||||
<MkA
|
||||
v-user-preview="renote.userId"
|
||||
class="name"
|
||||
:to="userPage(renote.user)"
|
||||
@click.stop
|
||||
>
|
||||
<MkUserName :user="renote.user" />
|
||||
</MkA>
|
||||
{{
|
||||
index !== renotesSliced.length - 1
|
||||
? ", "
|
||||
: renotesSliced.length < renotes!.length
|
||||
? "..."
|
||||
: ""
|
||||
}}
|
||||
</template>
|
||||
</template>
|
||||
</I18n>
|
||||
<div class="info">
|
||||
<button
|
||||
ref="renoteTime"
|
||||
class="_button time"
|
||||
@click.stop="showRenoteMenu()"
|
||||
>
|
||||
<i
|
||||
v-if="isMyNote"
|
||||
:class="icon('ph-dots-three-outline dropdownIcon')"
|
||||
></i>
|
||||
<MkTime :time="note.createdAt" />
|
||||
</button>
|
||||
<MkVisibility :note="note" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<article
|
||||
class="article"
|
||||
|
@ -279,7 +322,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, onMounted, ref } from "vue";
|
||||
import { computed, inject, onMounted, ref, watch } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import type { entities } from "firefish-js";
|
||||
import MkSubNoteContent from "./MkSubNoteContent.vue";
|
||||
|
@ -310,17 +353,13 @@ import { notePage } from "@/filters/note";
|
|||
import { deepClone } from "@/scripts/clone";
|
||||
import { getNoteSummary } from "@/scripts/get-note-summary";
|
||||
import icon from "@/scripts/icon";
|
||||
import type { NoteTranslation } from "@/types/note";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
type NoteType = entities.Note & {
|
||||
_featuredId_?: string;
|
||||
_prId_?: string;
|
||||
};
|
||||
import type { NoteTranslation, NoteType } from "@/types/note";
|
||||
import { isRenote as _isRenote, isDeleted as _isDeleted } from "@/scripts/note";
|
||||
|
||||
const props = defineProps<{
|
||||
note: NoteType;
|
||||
parents?: NoteType[];
|
||||
renotes?: entities.Note[];
|
||||
pinned?: boolean;
|
||||
detailedView?: boolean;
|
||||
collapsedReply?: boolean;
|
||||
|
@ -329,37 +368,20 @@ const props = defineProps<{
|
|||
isLongJudger?: (note: entities.Note) => boolean;
|
||||
}>();
|
||||
|
||||
//#region Constants
|
||||
const router = useRouter();
|
||||
const inChannel = inject("inChannel", null);
|
||||
|
||||
const note = ref(deepClone(props.note));
|
||||
|
||||
const softMuteReasonI18nSrc = (what?: string) => {
|
||||
if (what === "note") return i18n.ts.userSaysSomethingReason;
|
||||
if (what === "reply") return i18n.ts.userSaysSomethingReasonReply;
|
||||
if (what === "renote") return i18n.ts.userSaysSomethingReasonRenote;
|
||||
if (what === "quote") return i18n.ts.userSaysSomethingReasonQuote;
|
||||
|
||||
// I don't think here is reachable, but just in case
|
||||
return i18n.ts.userSaysSomething;
|
||||
const keymap = {
|
||||
r: () => reply(true),
|
||||
"e|a|plus": () => react(true),
|
||||
q: () => renoteButton.value!.renote(true),
|
||||
"up|k": focusBefore,
|
||||
"down|j": focusAfter,
|
||||
esc: blur,
|
||||
"m|o": () => menu(true),
|
||||
// FIXME: What's this?
|
||||
// s: () => showContent.value !== showContent.value,
|
||||
};
|
||||
|
||||
// plugin
|
||||
if (noteViewInterruptors.length > 0) {
|
||||
onMounted(async () => {
|
||||
let result = deepClone(note.value);
|
||||
for (const interruptor of noteViewInterruptors) {
|
||||
result = await interruptor.handler(result);
|
||||
}
|
||||
note.value = result;
|
||||
});
|
||||
}
|
||||
|
||||
const isRenote =
|
||||
note.value.renote != null &&
|
||||
note.value.text == null &&
|
||||
note.value.fileIds.length === 0 &&
|
||||
note.value.poll == null;
|
||||
|
||||
const el = ref<HTMLElement | null>(null);
|
||||
const footerEl = ref<HTMLElement>();
|
||||
const menuButton = ref<HTMLElement>();
|
||||
|
@ -367,42 +389,179 @@ const starButton = ref<InstanceType<typeof XStarButton>>();
|
|||
const renoteButton = ref<InstanceType<typeof XRenoteButton> | null>(null);
|
||||
const renoteTime = ref<HTMLElement>();
|
||||
const reactButton = ref<HTMLElement | null>(null);
|
||||
const appearNote = computed(() =>
|
||||
isRenote ? (note.value.renote as NoteType) : note.value,
|
||||
);
|
||||
const isMyRenote = isSignedIn(me) && me.id === note.value.userId;
|
||||
// const showContent = ref(false);
|
||||
const isDeleted = ref(false);
|
||||
const muted = ref(
|
||||
getWordSoftMute(
|
||||
note.value,
|
||||
me?.id,
|
||||
defaultStore.state.mutedWords,
|
||||
defaultStore.state.mutedLangs,
|
||||
),
|
||||
);
|
||||
const translation = ref<NoteTranslation | null>(null);
|
||||
const translating = ref(false);
|
||||
const enableEmojiReactions = defaultStore.state.enableEmojiReactions;
|
||||
const expandOnNoteClick = defaultStore.state.expandOnNoteClick;
|
||||
const enableEmojiReactions = defaultStore.reactiveState.enableEmojiReactions;
|
||||
const expandOnNoteClick = defaultStore.reactiveState.expandOnNoteClick;
|
||||
const lang = localStorage.getItem("lang");
|
||||
const translateLang = localStorage.getItem("translateLang");
|
||||
const targetLang = (translateLang || lang || navigator.language)?.slice(0, 2);
|
||||
const currentClipPage = inject<Ref<entities.Clip> | null>(
|
||||
"currentClipPage",
|
||||
null,
|
||||
);
|
||||
//#endregion
|
||||
|
||||
const isForeignLanguage: boolean =
|
||||
defaultStore.state.detectPostLanguage &&
|
||||
appearNote.value.text != null &&
|
||||
(() => {
|
||||
const postLang = detectLanguage(appearNote.value.text);
|
||||
return postLang !== "" && postLang !== targetLang;
|
||||
})();
|
||||
//#region Variables bound to Notes
|
||||
let capture: ReturnType<typeof useNoteCapture> | undefined;
|
||||
const note = ref(deepClone(props.note));
|
||||
const postIsExpanded = ref(false);
|
||||
const translation = ref<NoteTranslation | null>(null);
|
||||
const translating = ref(false);
|
||||
const isDeleted = ref(false);
|
||||
const renotes = ref(props.renotes?.filter((rn) => !_isDeleted(rn.id)));
|
||||
//#endregion
|
||||
|
||||
//#region computed
|
||||
|
||||
const renotesSliced = computed(() => renotes.value?.slice(0, 5));
|
||||
|
||||
const isRenote = computed(() => _isRenote(note.value));
|
||||
const appearNote = computed(() =>
|
||||
isRenote.value ? (note.value.renote as NoteType) : note.value,
|
||||
);
|
||||
const isMyNote = computed(
|
||||
() => isSignedIn(me) && me.id === note.value.userId && props.renotes == null,
|
||||
);
|
||||
const muted = computed(() =>
|
||||
getWordSoftMute(
|
||||
note.value,
|
||||
me?.id,
|
||||
defaultStore.reactiveState.mutedWords.value,
|
||||
defaultStore.reactiveState.mutedLangs.value,
|
||||
),
|
||||
);
|
||||
const isForeignLanguage = computed(
|
||||
() =>
|
||||
defaultStore.state.detectPostLanguage &&
|
||||
appearNote.value.text != null &&
|
||||
(() => {
|
||||
const postLang = detectLanguage(appearNote.value.text);
|
||||
return postLang !== "" && postLang !== targetLang;
|
||||
})(),
|
||||
);
|
||||
const reactionCount = computed(() =>
|
||||
Object.values(appearNote.value.reactions).reduce(
|
||||
(partialSum, val) => partialSum + val,
|
||||
0,
|
||||
),
|
||||
);
|
||||
const accessibleLabel = computed(() => {
|
||||
let label = `${appearNote.value.user.username}; `;
|
||||
if (appearNote.value.renote) {
|
||||
label += `${i18n.ts.renoted} ${appearNote.value.renote.user.username}; `;
|
||||
if (appearNote.value.renote.cw) {
|
||||
label += `${i18n.ts.cw}: ${appearNote.value.renote.cw}; `;
|
||||
if (postIsExpanded.value) {
|
||||
label += `${appearNote.value.renote.text}; `;
|
||||
}
|
||||
} else {
|
||||
label += `${appearNote.value.renote.text}; `;
|
||||
}
|
||||
} else {
|
||||
if (appearNote.value.cw) {
|
||||
label += `${i18n.ts.cw}: ${appearNote.value.cw}; `;
|
||||
if (postIsExpanded.value) {
|
||||
label += `${appearNote.value.text}; `;
|
||||
}
|
||||
} else {
|
||||
label += `${appearNote.value.text}; `;
|
||||
}
|
||||
}
|
||||
const date = new Date(appearNote.value.createdAt);
|
||||
label += `${date.toLocaleTimeString()}`;
|
||||
return label;
|
||||
});
|
||||
//#endregion
|
||||
|
||||
async function pluginInit(newNote: NoteType) {
|
||||
// plugin
|
||||
if (noteViewInterruptors.length > 0) {
|
||||
let result = deepClone(newNote);
|
||||
for (const interruptor of noteViewInterruptors) {
|
||||
result = await interruptor.handler(result);
|
||||
}
|
||||
note.value = result;
|
||||
}
|
||||
}
|
||||
|
||||
function recalculateRenotes() {
|
||||
renotes.value = props.renotes?.filter((rn) => !_isDeleted(rn.id));
|
||||
}
|
||||
|
||||
async function init(newNote: NoteType, first = false) {
|
||||
if (!first) {
|
||||
// plugin
|
||||
if (noteViewInterruptors.length > 0) {
|
||||
await pluginInit(newNote);
|
||||
} else {
|
||||
note.value = deepClone(newNote);
|
||||
}
|
||||
}
|
||||
|
||||
translation.value = null;
|
||||
translating.value = false;
|
||||
postIsExpanded.value = false;
|
||||
isDeleted.value = _isDeleted(note.value.id);
|
||||
if (appearNote.value.historyId == null) {
|
||||
capture?.close();
|
||||
capture = useNoteCapture({
|
||||
rootEl: el,
|
||||
note: appearNote,
|
||||
isDeletedRef: isDeleted,
|
||||
});
|
||||
if (isRenote.value === true) {
|
||||
useNoteCapture({
|
||||
rootEl: el,
|
||||
note,
|
||||
isDeletedRef: isDeleted,
|
||||
});
|
||||
}
|
||||
if (props.renotes) {
|
||||
const renoteDeletedTrigger = ref(false);
|
||||
for (const renote of props.renotes) {
|
||||
useNoteCapture({
|
||||
rootEl: el,
|
||||
note: ref(renote),
|
||||
isDeletedRef: renoteDeletedTrigger,
|
||||
});
|
||||
}
|
||||
watch(renoteDeletedTrigger, recalculateRenotes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(props.note, true);
|
||||
|
||||
onMounted(() => {
|
||||
pluginInit(note.value);
|
||||
});
|
||||
|
||||
watch(isDeleted, () => {
|
||||
if (isDeleted.value === true) {
|
||||
if (props.parents && props.parents.length > 0) {
|
||||
let noteTakePlace: NoteType | null = null;
|
||||
while (noteTakePlace == null || _isDeleted(noteTakePlace.id)) {
|
||||
if (props.parents.length === 0) {
|
||||
return;
|
||||
}
|
||||
noteTakePlace = props.parents[props.parents.length - 1];
|
||||
props.parents.pop();
|
||||
}
|
||||
noteTakePlace.repliesCount -= 1;
|
||||
init(noteTakePlace);
|
||||
isDeleted.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.note.id,
|
||||
(o, n) => {
|
||||
if (o !== n && _isDeleted(note.value.id) !== true) {
|
||||
init(props.note);
|
||||
}
|
||||
},
|
||||
);
|
||||
watch(() => props.renotes?.length, recalculateRenotes);
|
||||
|
||||
async function translate_(noteId: string, targetLang: string) {
|
||||
return await os.api("notes/translate", {
|
||||
|
@ -431,24 +590,14 @@ async function translate() {
|
|||
translating.value = false;
|
||||
}
|
||||
|
||||
const keymap = {
|
||||
r: () => reply(true),
|
||||
"e|a|plus": () => react(true),
|
||||
q: () => renoteButton.value!.renote(true),
|
||||
"up|k": focusBefore,
|
||||
"down|j": focusAfter,
|
||||
esc: blur,
|
||||
"m|o": () => menu(true),
|
||||
// FIXME: What's this?
|
||||
// s: () => showContent.value !== showContent.value,
|
||||
};
|
||||
function softMuteReasonI18nSrc(what?: string) {
|
||||
if (what === "note") return i18n.ts.userSaysSomethingReason;
|
||||
if (what === "reply") return i18n.ts.userSaysSomethingReasonReply;
|
||||
if (what === "renote") return i18n.ts.userSaysSomethingReasonRenote;
|
||||
if (what === "quote") return i18n.ts.userSaysSomethingReasonQuote;
|
||||
|
||||
if (appearNote.value.historyId == null) {
|
||||
useNoteCapture({
|
||||
rootEl: el,
|
||||
note: appearNote,
|
||||
isDeletedRef: isDeleted,
|
||||
});
|
||||
// I don't think here is reachable, but just in case
|
||||
return i18n.ts.userSaysSomething;
|
||||
}
|
||||
|
||||
function reply(_viaKeyboard = false): void {
|
||||
|
@ -489,11 +638,6 @@ function undoReact(note: NoteType): void {
|
|||
});
|
||||
}
|
||||
|
||||
const currentClipPage = inject<Ref<entities.Clip> | null>(
|
||||
"currentClipPage",
|
||||
null,
|
||||
);
|
||||
|
||||
function onContextmenu(ev: MouseEvent): void {
|
||||
const isLink = (el: HTMLElement): boolean => {
|
||||
if (el.tagName === "A") return true;
|
||||
|
@ -582,7 +726,7 @@ function menu(viaKeyboard = false): void {
|
|||
}
|
||||
|
||||
function showRenoteMenu(viaKeyboard = false): void {
|
||||
if (!isMyRenote) return;
|
||||
if (!isMyNote.value) return;
|
||||
os.popupMenu(
|
||||
[
|
||||
{
|
||||
|
@ -643,39 +787,10 @@ function readPromo() {
|
|||
isDeleted.value = true;
|
||||
}
|
||||
|
||||
const postIsExpanded = ref(false);
|
||||
|
||||
function setPostExpanded(val: boolean) {
|
||||
postIsExpanded.value = val;
|
||||
}
|
||||
|
||||
const accessibleLabel = computed(() => {
|
||||
let label = `${appearNote.value.user.username}; `;
|
||||
if (appearNote.value.renote) {
|
||||
label += `${i18n.ts.renoted} ${appearNote.value.renote.user.username}; `;
|
||||
if (appearNote.value.renote.cw) {
|
||||
label += `${i18n.ts.cw}: ${appearNote.value.renote.cw}; `;
|
||||
if (postIsExpanded.value) {
|
||||
label += `${appearNote.value.renote.text}; `;
|
||||
}
|
||||
} else {
|
||||
label += `${appearNote.value.renote.text}; `;
|
||||
}
|
||||
} else {
|
||||
if (appearNote.value.cw) {
|
||||
label += `${i18n.ts.cw}: ${appearNote.value.cw}; `;
|
||||
if (postIsExpanded.value) {
|
||||
label += `${appearNote.value.text}; `;
|
||||
}
|
||||
} else {
|
||||
label += `${appearNote.value.text}; `;
|
||||
}
|
||||
}
|
||||
const date = new Date(appearNote.value.createdAt);
|
||||
label += `${date.toLocaleTimeString()}`;
|
||||
return label;
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
focus,
|
||||
blur,
|
||||
|
@ -749,6 +864,7 @@ defineExpose({
|
|||
position: relative;
|
||||
padding: 0 32px 0 32px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
z-index: 1;
|
||||
&:first-child {
|
||||
margin-top: 20px;
|
||||
|
@ -801,6 +917,16 @@ defineExpose({
|
|||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
border-radius: 2em;
|
||||
overflow: hidden;
|
||||
margin-right: 0.4em;
|
||||
background: var(--panelHighlight);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
flex-shrink: 1;
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
ref="pagingComponent"
|
||||
:pagination="pagination"
|
||||
:disable-auto-load="disableAutoLoad"
|
||||
:folder
|
||||
>
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
|
@ -15,7 +16,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ items: notes }">
|
||||
<template #default="{ foldedItems: notes }">
|
||||
<div ref="tlEl" class="giivymft" :class="{ noGap }">
|
||||
<XList
|
||||
ref="notes"
|
||||
|
@ -28,10 +29,24 @@
|
|||
class="notes"
|
||||
>
|
||||
<XNote
|
||||
v-if="'folded' in note && note.folded === 'thread'"
|
||||
:key="note.id"
|
||||
class="qtqtichx"
|
||||
:note="note.note"
|
||||
:parents="note.parents"
|
||||
/>
|
||||
<XNote
|
||||
v-else-if="'folded' in note && note.folded === 'renote'"
|
||||
:key="note.key"
|
||||
class="qtqtichx"
|
||||
:note="note.note"
|
||||
:renotes="note.renotesArr"
|
||||
/>
|
||||
<XNote
|
||||
v-else
|
||||
:key="note._featuredId_ || note._prId_ || note.id"
|
||||
class="qtqtichx"
|
||||
:note="note"
|
||||
:collapsed-reply="collapsedReply"
|
||||
/>
|
||||
</XList>
|
||||
</div>
|
||||
|
@ -52,15 +67,21 @@ import XList from "@/components/MkDateSeparatedList.vue";
|
|||
import MkPagination from "@/components/MkPagination.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
import { scroll } from "@/scripts/scroll";
|
||||
import type { NoteFolded, NoteThread, NoteType } from "@/types/note";
|
||||
|
||||
const tlEl = ref<HTMLElement>();
|
||||
|
||||
defineProps<{
|
||||
pagination: PagingOf<entities.Note>;
|
||||
noGap?: boolean;
|
||||
disableAutoLoad?: boolean;
|
||||
collapsedReply?: boolean;
|
||||
}>();
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
pagination: PagingOf<entities.Note>;
|
||||
noGap?: boolean;
|
||||
disableAutoLoad?: boolean;
|
||||
folder?: (ns: entities.Note[]) => (NoteType | NoteThread | NoteFolded)[];
|
||||
}>(),
|
||||
{
|
||||
folder: (ns: entities.Note[]) => ns,
|
||||
},
|
||||
);
|
||||
|
||||
const pagingComponent = ref<MkPaginationType<
|
||||
PagingKeyOf<entities.Note>
|
||||
|
|
|
@ -81,30 +81,34 @@ const pagingComponent = ref<MkPaginationType<"i/notifications"> | null>(null);
|
|||
|
||||
const shouldFold = defaultStore.reactiveState.foldNotification;
|
||||
|
||||
const convertNotification = computed(() => shouldFold.value ? foldNotifications : (ns: entities.Notification[]) => ns);
|
||||
const convertNotification = computed(() =>
|
||||
shouldFold.value ? foldNotifications : (ns: entities.Notification[]) => ns,
|
||||
);
|
||||
|
||||
const FETCH_LIMIT = 90;
|
||||
|
||||
const pagination = computed(() => Object.assign(
|
||||
{
|
||||
endpoint: "i/notifications" as const,
|
||||
params: computed(() => ({
|
||||
includeTypes: props.includeTypes ?? undefined,
|
||||
excludeTypes: props.includeTypes
|
||||
? undefined
|
||||
: me?.mutingNotificationTypes,
|
||||
unreadOnly: props.unreadOnly,
|
||||
})),
|
||||
},
|
||||
shouldFold.value
|
||||
? {
|
||||
limit: 50,
|
||||
secondFetchLimit: FETCH_LIMIT,
|
||||
}
|
||||
: {
|
||||
limit: 30,
|
||||
},
|
||||
));
|
||||
const pagination = computed(() =>
|
||||
Object.assign(
|
||||
{
|
||||
endpoint: "i/notifications" as const,
|
||||
params: computed(() => ({
|
||||
includeTypes: props.includeTypes ?? undefined,
|
||||
excludeTypes: props.includeTypes
|
||||
? undefined
|
||||
: me?.mutingNotificationTypes,
|
||||
unreadOnly: props.unreadOnly,
|
||||
})),
|
||||
},
|
||||
shouldFold.value
|
||||
? {
|
||||
limit: 50,
|
||||
secondFetchLimit: FETCH_LIMIT,
|
||||
}
|
||||
: {
|
||||
limit: 30,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function isNoteNotification(
|
||||
n: entities.Notification,
|
||||
|
|
|
@ -365,9 +365,9 @@ async function fetch(firstFetching?: boolean) {
|
|||
}
|
||||
|
||||
// biome-ignore lint/style/noParameterAssign: assign it intentially
|
||||
res = res.filter((item) => {
|
||||
if (idMap.has(item)) return false;
|
||||
idMap.set(item, true);
|
||||
res = res.filter((it) => {
|
||||
if (idMap.has(it.id)) return false;
|
||||
idMap.set(it.id, true);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
@ -435,8 +435,20 @@ const prepend = (...item: Item[]): void => {
|
|||
}
|
||||
};
|
||||
|
||||
const append = (...items: Item[]): void => {
|
||||
appended.value.push(...items);
|
||||
const append = (...it: Item[]): void => {
|
||||
// If there are too many appended, merge them into arrItems
|
||||
if (
|
||||
appended.value.length >
|
||||
(props.pagination.secondFetchLimit || SECOND_FETCH_LIMIT_DEFAULT)
|
||||
) {
|
||||
for (const item of appended.value) {
|
||||
idMap.set(item.id, true);
|
||||
}
|
||||
arrItems.value.push(appended.value);
|
||||
appended.value = [];
|
||||
// We don't need to calculate here because it won't cause any changes in items
|
||||
}
|
||||
appended.value.push(...it);
|
||||
calculateItems();
|
||||
};
|
||||
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
:pagination="pagination"
|
||||
@queue="(x) => (queue = x)"
|
||||
@status="pullToRefreshComponent?.setDisabled($event)"
|
||||
:collapsed-reply
|
||||
:folder
|
||||
/>
|
||||
</MkPullToRefresh>
|
||||
<XNotes
|
||||
|
@ -40,7 +40,7 @@
|
|||
:pagination="pagination"
|
||||
@queue="(x) => (queue = x)"
|
||||
@status="pullToRefreshComponent?.setDisabled($event)"
|
||||
:collapsed-reply
|
||||
:folder
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
@ -56,6 +56,8 @@ import { isSignedIn, me } from "@/me";
|
|||
import { i18n } from "@/i18n";
|
||||
import { defaultStore } from "@/store";
|
||||
import icon from "@/scripts/icon";
|
||||
import { foldNotes } from "@/scripts/fold";
|
||||
import type { NoteType } from "@/types/note";
|
||||
|
||||
export type TimelineSource =
|
||||
| "antenna"
|
||||
|
@ -87,7 +89,11 @@ const emit = defineEmits<{
|
|||
const tlComponent = ref<InstanceType<typeof XNotes>>();
|
||||
const pullToRefreshComponent = ref<InstanceType<typeof MkPullToRefresh>>();
|
||||
|
||||
const collapsedReply = defaultStore.reactiveState.collapseReplyInTimeline;
|
||||
const folder = computed(() => {
|
||||
const mergeThread = defaultStore.reactiveState.mergeThreadInTimeline.value;
|
||||
const mergeRenotes = defaultStore.reactiveState.mergeRenotesInTimeline.value;
|
||||
return (ns: NoteType[]) => foldNotes(ns, mergeThread, mergeRenotes);
|
||||
});
|
||||
|
||||
let endpoint: TypeUtils.EndpointsOf<entities.Note[]>; // keyof Endpoints
|
||||
let query: {
|
||||
|
|
|
@ -134,8 +134,11 @@
|
|||
<FormSwitch v-model="foldNotification" class="_formBlock">{{
|
||||
i18n.ts.foldNotification
|
||||
}}</FormSwitch>
|
||||
<FormSwitch v-model="collapseReplyInTimeline" class="_formBlock">{{
|
||||
i18n.ts.collapseReplyInTimeline
|
||||
<FormSwitch v-model="mergeThreadInTimeline" class="_formBlock">{{
|
||||
i18n.ts.mergeThreadInTimeline
|
||||
}}</FormSwitch>
|
||||
<FormSwitch v-model="mergeRenotesInTimeline" class="_formBlock">{{
|
||||
i18n.ts.mergeRenotesInTimeline
|
||||
}}</FormSwitch>
|
||||
|
||||
<FormSelect v-model="serverDisconnectedBehavior" class="_formBlock">
|
||||
|
@ -552,8 +555,11 @@ const autocorrectNoteLanguage = computed(
|
|||
const foldNotification = computed(
|
||||
defaultStore.makeGetterSetter("foldNotification"),
|
||||
);
|
||||
const collapseReplyInTimeline = computed(
|
||||
defaultStore.makeGetterSetter("collapseReplyInTimeline"),
|
||||
const mergeThreadInTimeline = computed(
|
||||
defaultStore.makeGetterSetter("mergeThreadInTimeline"),
|
||||
);
|
||||
const mergeRenotesInTimeline = computed(
|
||||
defaultStore.makeGetterSetter("mergeRenotesInTimeline"),
|
||||
);
|
||||
|
||||
// This feature (along with injectPromo) is currently disabled
|
||||
|
|
|
@ -3,6 +3,9 @@ import type {
|
|||
FoldableNotification,
|
||||
NotificationFolded,
|
||||
} from "@/types/notification";
|
||||
import type { NoteType, NoteThread, NoteFolded } from "@/types/note";
|
||||
import { me } from "@/me";
|
||||
import { isDeleted, isRenote } from "./note";
|
||||
|
||||
interface FoldOption {
|
||||
/** If items length is 1, skip aggregation */
|
||||
|
@ -91,3 +94,89 @@ export function foldNotifications(ns: entities.Notification[]) {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function foldNotes(ns: NoteType[], foldReply = true, foldRenote = true) {
|
||||
// By the implement of MkPagination, lastId is unique and is safe for key
|
||||
const lastId = ns[ns.length - 1]?.id ?? "prepend";
|
||||
|
||||
function foldReplies(ns: NoteType[]) {
|
||||
const res: Array<NoteType | NoteThread> = [];
|
||||
const threads = new Map<NoteType["id"], NoteType[]>();
|
||||
|
||||
for (const n of [...ns].reverse()) {
|
||||
if (isDeleted(n.id)) {
|
||||
continue;
|
||||
}
|
||||
if (n.replyId && threads.has(n.replyId)) {
|
||||
const th = threads.get(n.replyId)!;
|
||||
threads.delete(n.replyId);
|
||||
th.push(n);
|
||||
threads.set(n.id, th);
|
||||
} else {
|
||||
threads.set(n.id, [n]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of ns) {
|
||||
const conversation = threads.get(n.id);
|
||||
if (conversation == null) continue;
|
||||
|
||||
const first = conversation[0];
|
||||
const last = conversation[conversation.length - 1];
|
||||
if (conversation.length === 1) {
|
||||
res.push(first);
|
||||
continue;
|
||||
}
|
||||
|
||||
res.push({
|
||||
// The same note can only appear once in the timeline, so the ID will not be repeated
|
||||
id: first.id,
|
||||
createdAt: last.createdAt,
|
||||
folded: "thread",
|
||||
note: last,
|
||||
parents: (first.reply ? [first.reply] : []).concat(
|
||||
conversation.slice(0, -1),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
let res: (NoteType | NoteThread | NoteFolded)[] = ns;
|
||||
|
||||
if (foldReply) {
|
||||
res = foldReplies(ns);
|
||||
}
|
||||
|
||||
if (foldRenote) {
|
||||
res = foldItems(
|
||||
res,
|
||||
(n) => {
|
||||
// never fold my renotes
|
||||
if (!("folded" in n) && isRenote(n) && n.userId !== me?.id)
|
||||
return `renote-${n.renoteId}`;
|
||||
return n.id;
|
||||
},
|
||||
(ns, key) => {
|
||||
const represent = ns[0];
|
||||
if (!key.startsWith("renote-")) {
|
||||
return represent;
|
||||
}
|
||||
return {
|
||||
id: `G-${lastId}-${key}`,
|
||||
key: `G-${lastId}-${key}`,
|
||||
createdAt: represent.createdAt,
|
||||
folded: "renote",
|
||||
note: (represent as entities.Note).renote!,
|
||||
renotesArr: ns as entities.Note[],
|
||||
};
|
||||
},
|
||||
{
|
||||
skipSingleElement: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
|
20
packages/client/src/scripts/note.ts
Normal file
20
packages/client/src/scripts/note.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import type { entities } from "firefish-js";
|
||||
import { deletedNoteIds } from "./use-note-capture";
|
||||
|
||||
export function isRenote(note: entities.Note): note is entities.Note & {
|
||||
renote: entities.Note;
|
||||
text: null;
|
||||
renoteId: string;
|
||||
poll: undefined;
|
||||
} {
|
||||
return (
|
||||
note.renote != null &&
|
||||
note.text == null &&
|
||||
note.fileIds.length === 0 &&
|
||||
note.poll == null
|
||||
);
|
||||
}
|
||||
|
||||
export function isDeleted(noteId: string) {
|
||||
return deletedNoteIds.has(noteId);
|
||||
}
|
|
@ -1,22 +1,62 @@
|
|||
import type { Ref } from "vue";
|
||||
import { onUnmounted } from "vue";
|
||||
import type { entities } from "firefish-js";
|
||||
import { useStream } from "@/stream";
|
||||
import { isSignedIn, me } from "@/me";
|
||||
import * as os from "@/os";
|
||||
import type { NoteType } from "@/types/note";
|
||||
|
||||
export const deletedNoteIds = new Map<NoteType["id"], boolean>();
|
||||
|
||||
const noteRefs = new Map<NoteType["id"], Ref<NoteType>[]>();
|
||||
|
||||
function addToNoteRefs(note: Ref<NoteType>) {
|
||||
const refs = noteRefs.get(note.value.id);
|
||||
if (refs) {
|
||||
refs.push(note);
|
||||
} else {
|
||||
noteRefs.set(note.value.id, [note]);
|
||||
}
|
||||
}
|
||||
|
||||
function eachNote(id: NoteType["id"], cb: (note: Ref<NoteType>) => void) {
|
||||
const refs = noteRefs.get(id);
|
||||
if (refs) {
|
||||
for (const n of refs) {
|
||||
// n.value.id maybe changed
|
||||
if (n.value.id === id) {
|
||||
cb(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useNoteCapture(props: {
|
||||
rootEl: Ref<HTMLElement | null>;
|
||||
note: Ref<entities.Note>;
|
||||
note: Ref<NoteType>;
|
||||
isDeletedRef: Ref<boolean>;
|
||||
onReplied?: (note: entities.Note) => void;
|
||||
onReplied?: (note: NoteType) => void;
|
||||
}) {
|
||||
let closed = false;
|
||||
const note = props.note;
|
||||
const connection = isSignedIn(me) ? useStream() : null;
|
||||
addToNoteRefs(note);
|
||||
|
||||
function onDeleted() {
|
||||
props.isDeletedRef.value = true;
|
||||
deletedNoteIds.set(note.value.id, true);
|
||||
|
||||
if (note.value.replyId) {
|
||||
eachNote(note.value.replyId, (n) => n.value.repliesCount--);
|
||||
}
|
||||
if (note.value.renoteId) {
|
||||
eachNote(note.value.renoteId, (n) => n.value.renoteCount--);
|
||||
}
|
||||
}
|
||||
|
||||
async function onStreamNoteUpdated(noteData): Promise<void> {
|
||||
const { type, id, body } = noteData;
|
||||
|
||||
if (closed) return;
|
||||
if (id !== note.value.id) return;
|
||||
|
||||
switch (type) {
|
||||
|
@ -87,7 +127,7 @@ export function useNoteCapture(props: {
|
|||
}
|
||||
|
||||
case "deleted": {
|
||||
props.isDeletedRef.value = true;
|
||||
onDeleted();
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -96,17 +136,14 @@ export function useNoteCapture(props: {
|
|||
const editedNote = await os.api("notes/show", {
|
||||
noteId: id,
|
||||
});
|
||||
|
||||
const keys = new Set<string>();
|
||||
Object.keys(editedNote)
|
||||
.concat(Object.keys(note.value))
|
||||
.forEach((key) => keys.add(key));
|
||||
keys.forEach((key) => {
|
||||
for (const key of [
|
||||
...new Set(Object.keys(editedNote).concat(Object.keys(note.value))),
|
||||
]) {
|
||||
note.value[key] = editedNote[key];
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// delete the note if failing to get the edited note
|
||||
props.isDeletedRef.value = true;
|
||||
onDeleted();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
@ -147,4 +184,10 @@ export function useNoteCapture(props: {
|
|||
connection.off("_connected_", onStreamConnected);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
close: () => {
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -454,9 +454,13 @@ export const defaultStore = markRaw(
|
|||
where: "deviceAccount",
|
||||
default: true,
|
||||
},
|
||||
collapseReplyInTimeline: {
|
||||
mergeThreadInTimeline: {
|
||||
where: "deviceAccount",
|
||||
default: false,
|
||||
default: true,
|
||||
},
|
||||
mergeRenotesInTimeline: {
|
||||
where: "deviceAccount",
|
||||
default: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { noteVisibilities } from "firefish-js";
|
||||
import type { entities, noteVisibilities } from "firefish-js";
|
||||
|
||||
export type NoteVisibility = (typeof noteVisibilities)[number] | "private";
|
||||
|
||||
|
@ -6,3 +6,25 @@ export interface NoteTranslation {
|
|||
sourceLang: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type NoteType = entities.Note & {
|
||||
_featuredId_?: string;
|
||||
_prId_?: string;
|
||||
};
|
||||
|
||||
export type NoteFolded = {
|
||||
id: string;
|
||||
key: string;
|
||||
createdAt: entities.Note["createdAt"];
|
||||
folded: "renote";
|
||||
note: entities.Note;
|
||||
renotesArr: entities.Note[];
|
||||
};
|
||||
|
||||
export type NoteThread = {
|
||||
id: string;
|
||||
createdAt: entities.Note["createdAt"];
|
||||
folded: "thread";
|
||||
note: entities.Note;
|
||||
parents: entities.Note[];
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue