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:
commit
df81cb6a85
|
@ -2244,3 +2244,5 @@ incorrectLanguageWarning: "It looks like your post is in {detected}, but you sel
|
||||||
noteEditHistory: "Post edit history"
|
noteEditHistory: "Post edit history"
|
||||||
slashQuote: "Chain quote"
|
slashQuote: "Chain quote"
|
||||||
foldNotification: "Group similar notifications"
|
foldNotification: "Group similar notifications"
|
||||||
|
mergeThreadInTimeline: "Merge multiple posts in the same thread in timelines"
|
||||||
|
mergeRenotesInTimeline: "Group multiple boosts of the same post"
|
||||||
|
|
|
@ -2071,3 +2071,5 @@ noteEditHistory: "帖子编辑历史"
|
||||||
media: 媒体
|
media: 媒体
|
||||||
slashQuote: "斜杠引用"
|
slashQuote: "斜杠引用"
|
||||||
foldNotification: "将通知按同类型分组"
|
foldNotification: "将通知按同类型分组"
|
||||||
|
mergeThreadInTimeline: "将时间线内的连续回复合并成一串"
|
||||||
|
mergeRenotesInTimeline: "合并同一个帖子的转发"
|
||||||
|
|
|
@ -40,7 +40,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import type { entities } from "firefish-js";
|
import type { entities } from "firefish-js";
|
||||||
import PhotoSwipeLightbox from "photoswipe/lightbox";
|
import PhotoSwipeLightbox from "photoswipe/lightbox";
|
||||||
import PhotoSwipe from "photoswipe";
|
import PhotoSwipe from "photoswipe";
|
||||||
|
@ -207,9 +207,9 @@ const isModule = (file: entities.DriveFile): boolean => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const previewableCount = props.mediaList.filter((media) =>
|
const previewableCount = computed(
|
||||||
previewable(media),
|
() => props.mediaList.filter((media) => previewable(media)).length,
|
||||||
).length;
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="!muted.muted"
|
v-if="!muted.muted"
|
||||||
v-show="!isDeleted"
|
v-show="!isDeleted && renotes?.length !== 0"
|
||||||
:id="appearNote.historyId || appearNote.id"
|
:id="appearNote.historyId || appearNote.id"
|
||||||
ref="el"
|
ref="el"
|
||||||
v-hotkey="keymap"
|
v-hotkey="keymap"
|
||||||
|
@ -10,13 +10,20 @@
|
||||||
:aria-label="accessibleLabel"
|
:aria-label="accessibleLabel"
|
||||||
class="tkcbzcuz note-container"
|
class="tkcbzcuz note-container"
|
||||||
:tabindex="!isDeleted ? '-1' : undefined"
|
:tabindex="!isDeleted ? '-1' : undefined"
|
||||||
:class="{ renote: isRenote }"
|
:class="{ renote: isRenote || (renotesSliced && renotesSliced.length > 0) }"
|
||||||
>
|
>
|
||||||
<MkNoteSub
|
<MkNoteSub
|
||||||
v-if="appearNote.reply && !detailedView && !collapsedReply"
|
v-if="appearNote.reply && !detailedView && !collapsedReply && !parents"
|
||||||
:note="appearNote.reply"
|
:note="appearNote.reply"
|
||||||
class="reply-to"
|
class="reply-to"
|
||||||
/>
|
/>
|
||||||
|
<MkNoteSub
|
||||||
|
v-else-if="!detailedView && !collapsedReply && parents"
|
||||||
|
v-for="n of parents"
|
||||||
|
:key="n.id"
|
||||||
|
:note="n"
|
||||||
|
class="reply-to"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="!detailedView"
|
v-if="!detailedView"
|
||||||
class="note-context"
|
class="note-context"
|
||||||
|
@ -41,35 +48,6 @@
|
||||||
<div v-if="pinned" class="info">
|
<div v-if="pinned" class="info">
|
||||||
<i :class="icon('ph-push-pin')"></i>{{ i18n.ts.pinnedNote }}
|
<i :class="icon('ph-push-pin')"></i>{{ i18n.ts.pinnedNote }}
|
||||||
</div>
|
</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">
|
<div v-if="collapsedReply && appearNote.reply" class="info">
|
||||||
<MkAvatar class="avatar" :user="appearNote.reply.user" />
|
<MkAvatar class="avatar" :user="appearNote.reply.user" />
|
||||||
<MkUserName
|
<MkUserName
|
||||||
|
@ -85,6 +63,71 @@
|
||||||
:custom-emojis="note.emojis"
|
:custom-emojis="note.emojis"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<article
|
<article
|
||||||
class="article"
|
class="article"
|
||||||
|
@ -279,7 +322,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 { Ref } from "vue";
|
||||||
import type { entities } from "firefish-js";
|
import type { entities } from "firefish-js";
|
||||||
import MkSubNoteContent from "./MkSubNoteContent.vue";
|
import MkSubNoteContent from "./MkSubNoteContent.vue";
|
||||||
|
@ -310,17 +353,13 @@ import { notePage } from "@/filters/note";
|
||||||
import { deepClone } from "@/scripts/clone";
|
import { deepClone } from "@/scripts/clone";
|
||||||
import { getNoteSummary } from "@/scripts/get-note-summary";
|
import { getNoteSummary } from "@/scripts/get-note-summary";
|
||||||
import icon from "@/scripts/icon";
|
import icon from "@/scripts/icon";
|
||||||
import type { NoteTranslation } from "@/types/note";
|
import type { NoteTranslation, NoteType } from "@/types/note";
|
||||||
|
import { isRenote as _isRenote, isDeleted as _isDeleted } from "@/scripts/note";
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
type NoteType = entities.Note & {
|
|
||||||
_featuredId_?: string;
|
|
||||||
_prId_?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
note: NoteType;
|
note: NoteType;
|
||||||
|
parents?: NoteType[];
|
||||||
|
renotes?: entities.Note[];
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
detailedView?: boolean;
|
detailedView?: boolean;
|
||||||
collapsedReply?: boolean;
|
collapsedReply?: boolean;
|
||||||
|
@ -329,37 +368,20 @@ const props = defineProps<{
|
||||||
isLongJudger?: (note: entities.Note) => boolean;
|
isLongJudger?: (note: entities.Note) => boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
//#region Constants
|
||||||
|
const router = useRouter();
|
||||||
const inChannel = inject("inChannel", null);
|
const inChannel = inject("inChannel", null);
|
||||||
|
const keymap = {
|
||||||
const note = ref(deepClone(props.note));
|
r: () => reply(true),
|
||||||
|
"e|a|plus": () => react(true),
|
||||||
const softMuteReasonI18nSrc = (what?: string) => {
|
q: () => renoteButton.value!.renote(true),
|
||||||
if (what === "note") return i18n.ts.userSaysSomethingReason;
|
"up|k": focusBefore,
|
||||||
if (what === "reply") return i18n.ts.userSaysSomethingReasonReply;
|
"down|j": focusAfter,
|
||||||
if (what === "renote") return i18n.ts.userSaysSomethingReasonRenote;
|
esc: blur,
|
||||||
if (what === "quote") return i18n.ts.userSaysSomethingReasonQuote;
|
"m|o": () => menu(true),
|
||||||
|
// FIXME: What's this?
|
||||||
// I don't think here is reachable, but just in case
|
// s: () => showContent.value !== showContent.value,
|
||||||
return i18n.ts.userSaysSomething;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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 el = ref<HTMLElement | null>(null);
|
||||||
const footerEl = ref<HTMLElement>();
|
const footerEl = ref<HTMLElement>();
|
||||||
const menuButton = 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 renoteButton = ref<InstanceType<typeof XRenoteButton> | null>(null);
|
||||||
const renoteTime = ref<HTMLElement>();
|
const renoteTime = ref<HTMLElement>();
|
||||||
const reactButton = ref<HTMLElement | null>(null);
|
const reactButton = ref<HTMLElement | null>(null);
|
||||||
const appearNote = computed(() =>
|
const enableEmojiReactions = defaultStore.reactiveState.enableEmojiReactions;
|
||||||
isRenote ? (note.value.renote as NoteType) : note.value,
|
const expandOnNoteClick = defaultStore.reactiveState.expandOnNoteClick;
|
||||||
);
|
|
||||||
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 lang = localStorage.getItem("lang");
|
const lang = localStorage.getItem("lang");
|
||||||
const translateLang = localStorage.getItem("translateLang");
|
const translateLang = localStorage.getItem("translateLang");
|
||||||
const targetLang = (translateLang || lang || navigator.language)?.slice(0, 2);
|
const targetLang = (translateLang || lang || navigator.language)?.slice(0, 2);
|
||||||
|
const currentClipPage = inject<Ref<entities.Clip> | null>(
|
||||||
|
"currentClipPage",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
//#endregion
|
||||||
|
|
||||||
const isForeignLanguage: boolean =
|
//#region Variables bound to Notes
|
||||||
defaultStore.state.detectPostLanguage &&
|
let capture: ReturnType<typeof useNoteCapture> | undefined;
|
||||||
appearNote.value.text != null &&
|
const note = ref(deepClone(props.note));
|
||||||
(() => {
|
const postIsExpanded = ref(false);
|
||||||
const postLang = detectLanguage(appearNote.value.text);
|
const translation = ref<NoteTranslation | null>(null);
|
||||||
return postLang !== "" && postLang !== targetLang;
|
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(() =>
|
const reactionCount = computed(() =>
|
||||||
Object.values(appearNote.value.reactions).reduce(
|
Object.values(appearNote.value.reactions).reduce(
|
||||||
(partialSum, val) => partialSum + val,
|
(partialSum, val) => partialSum + val,
|
||||||
0,
|
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) {
|
async function translate_(noteId: string, targetLang: string) {
|
||||||
return await os.api("notes/translate", {
|
return await os.api("notes/translate", {
|
||||||
|
@ -431,24 +590,14 @@ async function translate() {
|
||||||
translating.value = false;
|
translating.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keymap = {
|
function softMuteReasonI18nSrc(what?: string) {
|
||||||
r: () => reply(true),
|
if (what === "note") return i18n.ts.userSaysSomethingReason;
|
||||||
"e|a|plus": () => react(true),
|
if (what === "reply") return i18n.ts.userSaysSomethingReasonReply;
|
||||||
q: () => renoteButton.value!.renote(true),
|
if (what === "renote") return i18n.ts.userSaysSomethingReasonRenote;
|
||||||
"up|k": focusBefore,
|
if (what === "quote") return i18n.ts.userSaysSomethingReasonQuote;
|
||||||
"down|j": focusAfter,
|
|
||||||
esc: blur,
|
|
||||||
"m|o": () => menu(true),
|
|
||||||
// FIXME: What's this?
|
|
||||||
// s: () => showContent.value !== showContent.value,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (appearNote.value.historyId == null) {
|
// I don't think here is reachable, but just in case
|
||||||
useNoteCapture({
|
return i18n.ts.userSaysSomething;
|
||||||
rootEl: el,
|
|
||||||
note: appearNote,
|
|
||||||
isDeletedRef: isDeleted,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reply(_viaKeyboard = false): void {
|
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 {
|
function onContextmenu(ev: MouseEvent): void {
|
||||||
const isLink = (el: HTMLElement): boolean => {
|
const isLink = (el: HTMLElement): boolean => {
|
||||||
if (el.tagName === "A") return true;
|
if (el.tagName === "A") return true;
|
||||||
|
@ -582,7 +726,7 @@ function menu(viaKeyboard = false): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRenoteMenu(viaKeyboard = false): void {
|
function showRenoteMenu(viaKeyboard = false): void {
|
||||||
if (!isMyRenote) return;
|
if (!isMyNote.value) return;
|
||||||
os.popupMenu(
|
os.popupMenu(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
@ -643,39 +787,10 @@ function readPromo() {
|
||||||
isDeleted.value = true;
|
isDeleted.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const postIsExpanded = ref(false);
|
|
||||||
|
|
||||||
function setPostExpanded(val: boolean) {
|
function setPostExpanded(val: boolean) {
|
||||||
postIsExpanded.value = val;
|
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({
|
defineExpose({
|
||||||
focus,
|
focus,
|
||||||
blur,
|
blur,
|
||||||
|
@ -749,6 +864,7 @@ defineExpose({
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0 32px 0 32px;
|
padding: 0 32px 0 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
&:first-child {
|
&:first-child {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
@ -801,6 +917,16 @@ defineExpose({
|
||||||
margin-right: 4px;
|
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 {
|
> span {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
|
|
|
@ -48,8 +48,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from "vue";
|
|
||||||
|
|
||||||
import type { entities } from "firefish-js";
|
import type { entities } from "firefish-js";
|
||||||
import { defaultStore } from "@/store";
|
import { defaultStore } from "@/store";
|
||||||
import MkVisibility from "@/components/MkVisibility.vue";
|
import MkVisibility from "@/components/MkVisibility.vue";
|
||||||
|
@ -66,18 +64,16 @@ const props = defineProps<{
|
||||||
canOpenServerInfo?: boolean;
|
canOpenServerInfo?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const note = ref(props.note);
|
|
||||||
|
|
||||||
const showTicker =
|
const showTicker =
|
||||||
defaultStore.state.instanceTicker === "always" ||
|
defaultStore.state.instanceTicker === "always" ||
|
||||||
(defaultStore.state.instanceTicker === "remote" && note.value.user.instance);
|
(defaultStore.state.instanceTicker === "remote" && props.note.user.instance);
|
||||||
|
|
||||||
function openServerInfo() {
|
function openServerInfo() {
|
||||||
if (!props.canOpenServerInfo || !defaultStore.state.openServerInfo) return;
|
if (!props.canOpenServerInfo || !defaultStore.state.openServerInfo) return;
|
||||||
const instanceInfoUrl =
|
const instanceInfoUrl =
|
||||||
note.value.user.host == null
|
props.note.user.host == null
|
||||||
? "/about"
|
? "/about"
|
||||||
: `/instance-info/${note.value.user.host}`;
|
: `/instance-info/${props.note.user.host}`;
|
||||||
pageWindow(instanceInfoUrl);
|
pageWindow(instanceInfoUrl);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
<template>
|
<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" />
|
<MkAvatar class="avatar" :user="note.user" />
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<XNoteHeader class="header" :note="note" :mini="true" />
|
<XNoteHeader class="header" :note="note" :mini="true" />
|
||||||
|
@ -14,11 +19,40 @@
|
||||||
import type { entities } from "firefish-js";
|
import type { entities } from "firefish-js";
|
||||||
import XNoteHeader from "@/components/MkNoteHeader.vue";
|
import XNoteHeader from "@/components/MkNoteHeader.vue";
|
||||||
import MkSubNoteContent from "@/components/MkSubNoteContent.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;
|
note: entities.Note;
|
||||||
pinned?: boolean;
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
ref="pagingComponent"
|
ref="pagingComponent"
|
||||||
:pagination="pagination"
|
:pagination="pagination"
|
||||||
:disable-auto-load="disableAutoLoad"
|
:disable-auto-load="disableAutoLoad"
|
||||||
|
:folder
|
||||||
>
|
>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<div class="_fullinfo">
|
<div class="_fullinfo">
|
||||||
|
@ -15,7 +16,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #default="{ items: notes }">
|
<template #default="{ foldedItems: notes }">
|
||||||
<div ref="tlEl" class="giivymft" :class="{ noGap }">
|
<div ref="tlEl" class="giivymft" :class="{ noGap }">
|
||||||
<XList
|
<XList
|
||||||
ref="notes"
|
ref="notes"
|
||||||
|
@ -28,6 +29,21 @@
|
||||||
class="notes"
|
class="notes"
|
||||||
>
|
>
|
||||||
<XNote
|
<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"
|
:key="note._featuredId_ || note._prId_ || note.id"
|
||||||
class="qtqtichx"
|
class="qtqtichx"
|
||||||
:note="note"
|
:note="note"
|
||||||
|
@ -51,14 +67,21 @@ import XList from "@/components/MkDateSeparatedList.vue";
|
||||||
import MkPagination from "@/components/MkPagination.vue";
|
import MkPagination from "@/components/MkPagination.vue";
|
||||||
import { i18n } from "@/i18n";
|
import { i18n } from "@/i18n";
|
||||||
import { scroll } from "@/scripts/scroll";
|
import { scroll } from "@/scripts/scroll";
|
||||||
|
import type { NoteFolded, NoteThread, NoteType } from "@/types/note";
|
||||||
|
|
||||||
const tlEl = ref<HTMLElement>();
|
const tlEl = ref<HTMLElement>();
|
||||||
|
|
||||||
defineProps<{
|
withDefaults(
|
||||||
pagination: PagingOf<entities.Note>;
|
defineProps<{
|
||||||
noGap?: boolean;
|
pagination: PagingOf<entities.Note>;
|
||||||
disableAutoLoad?: boolean;
|
noGap?: boolean;
|
||||||
}>();
|
disableAutoLoad?: boolean;
|
||||||
|
folder?: (ns: entities.Note[]) => (NoteType | NoteThread | NoteFolded)[];
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
folder: (ns: entities.Note[]) => ns,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const pagingComponent = ref<MkPaginationType<
|
const pagingComponent = ref<MkPaginationType<
|
||||||
PagingKeyOf<entities.Note>
|
PagingKeyOf<entities.Note>
|
||||||
|
|
|
@ -79,29 +79,35 @@ const stream = useStream();
|
||||||
|
|
||||||
const pagingComponent = ref<MkPaginationType<"i/notifications"> | null>(null);
|
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 FETCH_LIMIT = 90;
|
||||||
|
|
||||||
const pagination = Object.assign(
|
const pagination = computed(() =>
|
||||||
{
|
Object.assign(
|
||||||
endpoint: "i/notifications" as const,
|
{
|
||||||
params: computed(() => ({
|
endpoint: "i/notifications" as const,
|
||||||
includeTypes: props.includeTypes ?? undefined,
|
params: computed(() => ({
|
||||||
excludeTypes: props.includeTypes
|
includeTypes: props.includeTypes ?? undefined,
|
||||||
? undefined
|
excludeTypes: props.includeTypes
|
||||||
: me?.mutingNotificationTypes,
|
? undefined
|
||||||
unreadOnly: props.unreadOnly,
|
: me?.mutingNotificationTypes,
|
||||||
})),
|
unreadOnly: props.unreadOnly,
|
||||||
},
|
})),
|
||||||
shouldFold
|
},
|
||||||
? {
|
shouldFold.value
|
||||||
limit: 50,
|
? {
|
||||||
secondFetchLimit: FETCH_LIMIT,
|
limit: 50,
|
||||||
}
|
secondFetchLimit: FETCH_LIMIT,
|
||||||
: {
|
}
|
||||||
limit: 30,
|
: {
|
||||||
},
|
limit: 30,
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
function isNoteNotification(
|
function isNoteNotification(
|
||||||
|
@ -138,14 +144,6 @@ const onNotification = (notification: entities.Notification) => {
|
||||||
|
|
||||||
let connection: StreamTypes.ChannelOf<"main"> | undefined;
|
let connection: StreamTypes.ChannelOf<"main"> | undefined;
|
||||||
|
|
||||||
function convertNotification(ns: entities.Notification[]) {
|
|
||||||
if (shouldFold) {
|
|
||||||
return foldNotifications(ns);
|
|
||||||
} else {
|
|
||||||
return ns;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
connection = stream.useChannel("main");
|
connection = stream.useChannel("main");
|
||||||
connection.on("notification", onNotification);
|
connection.on("notification", onNotification);
|
||||||
|
|
|
@ -365,9 +365,9 @@ async function fetch(firstFetching?: boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/style/noParameterAssign: assign it intentially
|
// biome-ignore lint/style/noParameterAssign: assign it intentially
|
||||||
res = res.filter((item) => {
|
res = res.filter((it) => {
|
||||||
if (idMap.has(item)) return false;
|
if (idMap.has(it.id)) return false;
|
||||||
idMap.set(item, true);
|
idMap.set(it.id, true);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -435,8 +435,20 @@ const prepend = (...item: Item[]): void => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const append = (...items: Item[]): void => {
|
const append = (...it: Item[]): void => {
|
||||||
appended.value.push(...items);
|
// 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();
|
calculateItems();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -486,6 +498,8 @@ if (props.pagination.params && isRef<Param>(props.pagination.params)) {
|
||||||
watch(props.pagination.params, reload, { deep: true });
|
watch(props.pagination.params, reload, { deep: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(() => props.folder, calculateItems);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
queue,
|
queue,
|
||||||
(a, b) => {
|
(a, b) => {
|
||||||
|
|
|
@ -178,7 +178,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import type { entities } from "firefish-js";
|
import type { entities } from "firefish-js";
|
||||||
import * as mfm from "mfm-js";
|
import * as mfm from "mfm-js";
|
||||||
import * as os from "@/os";
|
import * as os from "@/os";
|
||||||
|
@ -226,24 +226,35 @@ const emit = defineEmits<{
|
||||||
const cwButton = ref<HTMLElement>();
|
const cwButton = ref<HTMLElement>();
|
||||||
const showMoreButton = ref<HTMLElement>();
|
const showMoreButton = ref<HTMLElement>();
|
||||||
|
|
||||||
const isLong =
|
const isLong = computed(
|
||||||
!props.detailedView &&
|
() =>
|
||||||
props.note.cw == null &&
|
!props.detailedView &&
|
||||||
props.isLongJudger(props.note);
|
props.note.cw == null &&
|
||||||
const collapsed = ref(props.note.cw == null && isLong);
|
props.isLongJudger(props.note),
|
||||||
const urls = props.note.text
|
);
|
||||||
? extractUrlFromMfm(mfm.parse(props.note.text)).slice(0, 5)
|
const urls = computed(() =>
|
||||||
: null;
|
props.note.text
|
||||||
|
? extractUrlFromMfm(mfm.parse(props.note.text)).slice(0, 5)
|
||||||
const showContent = ref(false);
|
: null,
|
||||||
|
);
|
||||||
const mfms = props.note.text
|
const mfms = computed(() =>
|
||||||
? extractMfmWithAnimation(mfm.parse(props.note.text))
|
props.note.text ? extractMfmWithAnimation(mfm.parse(props.note.text)) : null,
|
||||||
: null;
|
);
|
||||||
|
const hasMfm = computed(() => mfms.value && mfms.value.length > 0);
|
||||||
const hasMfm = ref(mfms && mfms.length > 0);
|
|
||||||
|
|
||||||
const disableMfm = ref(defaultStore.state.animatedMfm);
|
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() {
|
async function toggleMfm() {
|
||||||
if (disableMfm.value) {
|
if (disableMfm.value) {
|
||||||
|
|
|
@ -30,6 +30,7 @@
|
||||||
:pagination="pagination"
|
:pagination="pagination"
|
||||||
@queue="(x) => (queue = x)"
|
@queue="(x) => (queue = x)"
|
||||||
@status="pullToRefreshComponent?.setDisabled($event)"
|
@status="pullToRefreshComponent?.setDisabled($event)"
|
||||||
|
:folder
|
||||||
/>
|
/>
|
||||||
</MkPullToRefresh>
|
</MkPullToRefresh>
|
||||||
<XNotes
|
<XNotes
|
||||||
|
@ -39,6 +40,7 @@
|
||||||
:pagination="pagination"
|
:pagination="pagination"
|
||||||
@queue="(x) => (queue = x)"
|
@queue="(x) => (queue = x)"
|
||||||
@status="pullToRefreshComponent?.setDisabled($event)"
|
@status="pullToRefreshComponent?.setDisabled($event)"
|
||||||
|
:folder
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -54,6 +56,8 @@ import { isSignedIn, me } from "@/me";
|
||||||
import { i18n } from "@/i18n";
|
import { i18n } from "@/i18n";
|
||||||
import { defaultStore } from "@/store";
|
import { defaultStore } from "@/store";
|
||||||
import icon from "@/scripts/icon";
|
import icon from "@/scripts/icon";
|
||||||
|
import { foldNotes } from "@/scripts/fold";
|
||||||
|
import type { NoteType } from "@/types/note";
|
||||||
|
|
||||||
export type TimelineSource =
|
export type TimelineSource =
|
||||||
| "antenna"
|
| "antenna"
|
||||||
|
@ -85,6 +89,12 @@ const emit = defineEmits<{
|
||||||
const tlComponent = ref<InstanceType<typeof XNotes>>();
|
const tlComponent = ref<InstanceType<typeof XNotes>>();
|
||||||
const pullToRefreshComponent = ref<InstanceType<typeof MkPullToRefresh>>();
|
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 endpoint: TypeUtils.EndpointsOf<entities.Note[]>; // keyof Endpoints
|
||||||
let query: {
|
let query: {
|
||||||
antennaId?: string | undefined;
|
antennaId?: string | undefined;
|
||||||
|
|
|
@ -140,6 +140,12 @@
|
||||||
<FormSwitch v-model="foldNotification" class="_formBlock">{{
|
<FormSwitch v-model="foldNotification" class="_formBlock">{{
|
||||||
i18n.ts.foldNotification
|
i18n.ts.foldNotification
|
||||||
}}</FormSwitch>
|
}}</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">
|
<FormSelect v-model="serverDisconnectedBehavior" class="_formBlock">
|
||||||
<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
|
<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
|
||||||
|
@ -556,6 +562,12 @@ const autocorrectNoteLanguage = computed(
|
||||||
const foldNotification = computed(
|
const foldNotification = computed(
|
||||||
defaultStore.makeGetterSetter("foldNotification"),
|
defaultStore.makeGetterSetter("foldNotification"),
|
||||||
);
|
);
|
||||||
|
const mergeThreadInTimeline = computed(
|
||||||
|
defaultStore.makeGetterSetter("mergeThreadInTimeline"),
|
||||||
|
);
|
||||||
|
const mergeRenotesInTimeline = computed(
|
||||||
|
defaultStore.makeGetterSetter("mergeRenotesInTimeline"),
|
||||||
|
);
|
||||||
|
|
||||||
// This feature (along with injectPromo) is currently disabled
|
// This feature (along with injectPromo) is currently disabled
|
||||||
// function onChangeInjectFeaturedNote(v) {
|
// function onChangeInjectFeaturedNote(v) {
|
||||||
|
@ -632,7 +644,6 @@ watch(
|
||||||
enableTimelineStreaming,
|
enableTimelineStreaming,
|
||||||
enablePullToRefresh,
|
enablePullToRefresh,
|
||||||
pullToRefreshThreshold,
|
pullToRefreshThreshold,
|
||||||
foldNotification,
|
|
||||||
],
|
],
|
||||||
async () => {
|
async () => {
|
||||||
await reloadAsk();
|
await reloadAsk();
|
||||||
|
|
|
@ -3,6 +3,9 @@ import type {
|
||||||
FoldableNotification,
|
FoldableNotification,
|
||||||
NotificationFolded,
|
NotificationFolded,
|
||||||
} from "@/types/notification";
|
} from "@/types/notification";
|
||||||
|
import type { NoteType, NoteThread, NoteFolded } from "@/types/note";
|
||||||
|
import { me } from "@/me";
|
||||||
|
import { isDeleted, isRenote } from "./note";
|
||||||
|
|
||||||
interface FoldOption {
|
interface FoldOption {
|
||||||
/** If items length is 1, skip aggregation */
|
/** 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;
|
||||||
|
}
|
||||||
|
|
|
@ -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 type { Ref } from "vue";
|
||||||
import { onUnmounted } from "vue";
|
import { onUnmounted, ref } from "vue";
|
||||||
import type { entities } from "firefish-js";
|
|
||||||
import { useStream } from "@/stream";
|
import { useStream } from "@/stream";
|
||||||
import { isSignedIn, me } from "@/me";
|
import { isSignedIn, me } from "@/me";
|
||||||
import * as os from "@/os";
|
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: {
|
export function useNoteCapture(props: {
|
||||||
rootEl: Ref<HTMLElement | null>;
|
rootEl: Ref<HTMLElement | null>;
|
||||||
note: Ref<entities.Note>;
|
note: Ref<NoteType>;
|
||||||
isDeletedRef: Ref<boolean>;
|
isDeletedRef?: Ref<boolean>;
|
||||||
onReplied?: (note: entities.Note) => void;
|
onReplied?: (note: NoteType) => void;
|
||||||
}) {
|
}) {
|
||||||
|
let closed = false;
|
||||||
const note = props.note;
|
const note = props.note;
|
||||||
const connection = isSignedIn(me) ? useStream() : null;
|
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> {
|
async function onStreamNoteUpdated(noteData): Promise<void> {
|
||||||
const { type, id, body } = noteData;
|
const { type, id, body } = noteData;
|
||||||
|
|
||||||
|
if (closed) return;
|
||||||
if (id !== note.value.id) return;
|
if (id !== note.value.id) return;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
@ -87,7 +127,7 @@ export function useNoteCapture(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
case "deleted": {
|
case "deleted": {
|
||||||
props.isDeletedRef.value = true;
|
onDeleted();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -96,17 +136,14 @@ export function useNoteCapture(props: {
|
||||||
const editedNote = await os.api("notes/show", {
|
const editedNote = await os.api("notes/show", {
|
||||||
noteId: id,
|
noteId: id,
|
||||||
});
|
});
|
||||||
|
for (const key of [
|
||||||
const keys = new Set<string>();
|
...new Set(Object.keys(editedNote).concat(Object.keys(note.value))),
|
||||||
Object.keys(editedNote)
|
]) {
|
||||||
.concat(Object.keys(note.value))
|
|
||||||
.forEach((key) => keys.add(key));
|
|
||||||
keys.forEach((key) => {
|
|
||||||
note.value[key] = editedNote[key];
|
note.value[key] = editedNote[key];
|
||||||
});
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// delete the note if failing to get the edited note
|
// delete the note if failing to get the edited note
|
||||||
props.isDeletedRef.value = true;
|
onDeleted();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -147,4 +184,10 @@ export function useNoteCapture(props: {
|
||||||
connection.off("_connected_", onStreamConnected);
|
connection.off("_connected_", onStreamConnected);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: () => {
|
||||||
|
closed = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -454,6 +454,14 @@ export const defaultStore = markRaw(
|
||||||
where: "deviceAccount",
|
where: "deviceAccount",
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
mergeThreadInTimeline: {
|
||||||
|
where: "deviceAccount",
|
||||||
|
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";
|
export type NoteVisibility = (typeof noteVisibilities)[number] | "private";
|
||||||
|
|
||||||
|
@ -6,3 +6,25 @@ export interface NoteTranslation {
|
||||||
sourceLang: string;
|
sourceLang: string;
|
||||||
text: 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 New Issue