Merge branch 'feat/collepse-reply-timeline' into 'develop'

feat: collepse renotes and replies in timeline

Co-authored-by: Lhcfl <Lhcfl@outlook.com>

Closes #10908

See merge request firefish/firefish!10788
This commit is contained in:
naskya 2024-05-07 16:20:45 +00:00
commit df81cb6a85
17 changed files with 646 additions and 232 deletions

View file

@ -2244,3 +2244,5 @@ incorrectLanguageWarning: "It looks like your post is in {detected}, but you sel
noteEditHistory: "Post edit history"
slashQuote: "Chain quote"
foldNotification: "Group similar notifications"
mergeThreadInTimeline: "Merge multiple posts in the same thread in timelines"
mergeRenotesInTimeline: "Group multiple boosts of the same post"

View file

@ -2071,3 +2071,5 @@ noteEditHistory: "帖子编辑历史"
media: 媒体
slashQuote: "斜杠引用"
foldNotification: "将通知按同类型分组"
mergeThreadInTimeline: "将时间线内的连续回复合并成一串"
mergeRenotesInTimeline: "合并同一个帖子的转发"

View file

@ -40,7 +40,7 @@
</template>
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import type { entities } from "firefish-js";
import PhotoSwipeLightbox from "photoswipe/lightbox";
import PhotoSwipe from "photoswipe";
@ -207,9 +207,9 @@ const isModule = (file: entities.DriveFile): boolean => {
);
};
const previewableCount = props.mediaList.filter((media) =>
previewable(media),
).length;
const previewableCount = computed(
() => props.mediaList.filter((media) => previewable(media)).length,
);
</script>
<style lang="scss" scoped>

View file

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

View file

@ -48,8 +48,6 @@
</template>
<script lang="ts" setup>
import { ref } from "vue";
import type { entities } from "firefish-js";
import { defaultStore } from "@/store";
import MkVisibility from "@/components/MkVisibility.vue";
@ -66,18 +64,16 @@ const props = defineProps<{
canOpenServerInfo?: boolean;
}>();
const note = ref(props.note);
const showTicker =
defaultStore.state.instanceTicker === "always" ||
(defaultStore.state.instanceTicker === "remote" && note.value.user.instance);
(defaultStore.state.instanceTicker === "remote" && props.note.user.instance);
function openServerInfo() {
if (!props.canOpenServerInfo || !defaultStore.state.openServerInfo) return;
const instanceInfoUrl =
note.value.user.host == null
props.note.user.host == null
? "/about"
: `/instance-info/${note.value.user.host}`;
: `/instance-info/${props.note.user.host}`;
pageWindow(instanceInfoUrl);
}
</script>

View file

@ -1,5 +1,10 @@
<template>
<div v-size="{ min: [350, 500] }" class="yohlumlk">
<div
v-show="!deleted"
v-size="{ min: [350, 500] }"
class="yohlumlk"
ref="el"
>
<MkAvatar class="avatar" :user="note.user" />
<div class="main">
<XNoteHeader class="header" :note="note" :mini="true" />
@ -14,11 +19,40 @@
import type { entities } from "firefish-js";
import XNoteHeader from "@/components/MkNoteHeader.vue";
import MkSubNoteContent from "@/components/MkSubNoteContent.vue";
import { computed, ref, watch } from "vue";
import { deepClone } from "@/scripts/clone";
import { useNoteCapture } from "@/scripts/use-note-capture";
import { isDeleted } from "@/scripts/note";
defineProps<{
const props = defineProps<{
note: entities.Note;
pinned?: boolean;
}>();
const rootEl = ref<HTMLElement | null>(null);
const note = ref(deepClone(props.note));
const deleted = computed(() => isDeleted(note.value.id));
let capture = useNoteCapture({
note,
rootEl,
});
function reload() {
note.value = deepClone(props.note);
capture.close();
capture = useNoteCapture({
note,
rootEl,
});
}
watch(
() => props.note.id,
(o, n) => {
if (o === n) return;
reload();
},
);
</script>
<style lang="scss" scoped>

View file

@ -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,6 +29,21 @@
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"
@ -51,14 +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;
}>();
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>

View file

@ -79,29 +79,35 @@ const stream = useStream();
const pagingComponent = ref<MkPaginationType<"i/notifications"> | null>(null);
const shouldFold = defaultStore.state.foldNotification;
const shouldFold = defaultStore.reactiveState.foldNotification;
const convertNotification = computed(() =>
shouldFold.value ? foldNotifications : (ns: entities.Notification[]) => ns,
);
const FETCH_LIMIT = 90;
const pagination = Object.assign(
{
endpoint: "i/notifications" as const,
params: computed(() => ({
includeTypes: props.includeTypes ?? undefined,
excludeTypes: props.includeTypes
? undefined
: me?.mutingNotificationTypes,
unreadOnly: props.unreadOnly,
})),
},
shouldFold
? {
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(
@ -138,14 +144,6 @@ const onNotification = (notification: entities.Notification) => {
let connection: StreamTypes.ChannelOf<"main"> | undefined;
function convertNotification(ns: entities.Notification[]) {
if (shouldFold) {
return foldNotifications(ns);
} else {
return ns;
}
}
onMounted(() => {
connection = stream.useChannel("main");
connection.on("notification", onNotification);

View file

@ -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();
};
@ -486,6 +498,8 @@ if (props.pagination.params && isRef<Param>(props.pagination.params)) {
watch(props.pagination.params, reload, { deep: true });
}
watch(() => props.folder, calculateItems);
watch(
queue,
(a, b) => {

View file

@ -178,7 +178,7 @@
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { computed, ref, watch } from "vue";
import type { entities } from "firefish-js";
import * as mfm from "mfm-js";
import * as os from "@/os";
@ -226,24 +226,35 @@ const emit = defineEmits<{
const cwButton = ref<HTMLElement>();
const showMoreButton = ref<HTMLElement>();
const isLong =
!props.detailedView &&
props.note.cw == null &&
props.isLongJudger(props.note);
const collapsed = ref(props.note.cw == null && isLong);
const urls = props.note.text
? extractUrlFromMfm(mfm.parse(props.note.text)).slice(0, 5)
: null;
const showContent = ref(false);
const mfms = props.note.text
? extractMfmWithAnimation(mfm.parse(props.note.text))
: null;
const hasMfm = ref(mfms && mfms.length > 0);
const isLong = computed(
() =>
!props.detailedView &&
props.note.cw == null &&
props.isLongJudger(props.note),
);
const urls = computed(() =>
props.note.text
? extractUrlFromMfm(mfm.parse(props.note.text)).slice(0, 5)
: null,
);
const mfms = computed(() =>
props.note.text ? extractMfmWithAnimation(mfm.parse(props.note.text)) : null,
);
const hasMfm = computed(() => mfms.value && mfms.value.length > 0);
const disableMfm = ref(defaultStore.state.animatedMfm);
const showContent = ref(false);
const collapsed = ref(props.note.cw == null && isLong.value);
watch(
() => props.note.id,
(o, n) => {
if (o !== n) return;
disableMfm.value = defaultStore.state.animatedMfm;
showContent.value = false;
collapsed.value = props.note.cw == null && isLong.value;
},
);
async function toggleMfm() {
if (disableMfm.value) {

View file

@ -30,6 +30,7 @@
:pagination="pagination"
@queue="(x) => (queue = x)"
@status="pullToRefreshComponent?.setDisabled($event)"
:folder
/>
</MkPullToRefresh>
<XNotes
@ -39,6 +40,7 @@
:pagination="pagination"
@queue="(x) => (queue = x)"
@status="pullToRefreshComponent?.setDisabled($event)"
:folder
/>
</template>
@ -54,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"
@ -85,6 +89,12 @@ const emit = defineEmits<{
const tlComponent = ref<InstanceType<typeof XNotes>>();
const pullToRefreshComponent = ref<InstanceType<typeof MkPullToRefresh>>();
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: {
antennaId?: string | undefined;

View file

@ -140,6 +140,12 @@
<FormSwitch v-model="foldNotification" class="_formBlock">{{
i18n.ts.foldNotification
}}</FormSwitch>
<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">
<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
@ -556,6 +562,12 @@ const autocorrectNoteLanguage = computed(
const foldNotification = computed(
defaultStore.makeGetterSetter("foldNotification"),
);
const mergeThreadInTimeline = computed(
defaultStore.makeGetterSetter("mergeThreadInTimeline"),
);
const mergeRenotesInTimeline = computed(
defaultStore.makeGetterSetter("mergeRenotesInTimeline"),
);
// This feature (along with injectPromo) is currently disabled
// function onChangeInjectFeaturedNote(v) {
@ -632,7 +644,6 @@ watch(
enableTimelineStreaming,
enablePullToRefresh,
pullToRefreshThreshold,
foldNotification,
],
async () => {
await reloadAsk();

View file

@ -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,94 @@ 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 if (n.reply?.replyId && threads.has(n.reply.replyId)) {
const th = threads.get(n.reply.replyId)!;
threads.delete(n.reply.replyId);
th.push(n.reply, 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;
}

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

View file

@ -1,22 +1,62 @@
import type { Ref } from "vue";
import { onUnmounted } from "vue";
import type { entities } from "firefish-js";
import { onUnmounted, ref } from "vue";
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>;
isDeletedRef: Ref<boolean>;
onReplied?: (note: entities.Note) => void;
note: Ref<NoteType>;
isDeletedRef?: Ref<boolean>;
onReplied?: (note: NoteType) => void;
}) {
let closed = false;
const note = props.note;
const connection = isSignedIn(me) ? useStream() : null;
addToNoteRefs(note);
function onDeleted() {
if (props.isDeletedRef) 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;
},
};
}

View file

@ -454,6 +454,14 @@ export const defaultStore = markRaw(
where: "deviceAccount",
default: true,
},
mergeThreadInTimeline: {
where: "deviceAccount",
default: true,
},
mergeRenotesInTimeline: {
where: "deviceAccount",
default: true,
},
}),
);

View file

@ -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[];
};