feat: fold notifications

This commit is contained in:
Lhcfl 2024-04-24 21:33:56 +08:00
parent ac9a07d25a
commit 7e9633a36b
9 changed files with 443 additions and 8 deletions

View File

@ -2146,6 +2146,7 @@ _notification:
reacted: "reacted to your post"
renoted: "boosted your post"
voted: "voted on your poll"
andCountUsers: "and {count} users {acted}"
_types:
all: "All"
follow: "New followers"
@ -2232,3 +2233,5 @@ autocorrectNoteLanguage: "Show a warning if the post language does not match the
incorrectLanguageWarning: "It looks like your post is in {detected}, but you selected
{current}.\nWould you like to set the language to {detected} instead?"
noteEditHistory: "Post edit history"
experimental: "Experimental"
foldNotification: "Collapse notifications of the same type"

View File

@ -1787,6 +1787,7 @@ _notification:
reacted: 回应了您的帖子
voted: 在您的问卷调查中投了票
renoted: 转发了您的帖子
andCountUsers: "等 {count} 名用户{acted}"
_deck:
alwaysShowMainColumn: "总是显示主列"
columnAlign: "列对齐"
@ -2059,3 +2060,5 @@ autocorrectNoteLanguage: 当帖子语言不符合自动检测的结果的时候
incorrectLanguageWarning: "看上去您帖子使用的语言是{detected},但您选择的语言是{current}。\n要改为以{detected}发帖吗?"
noteEditHistory: "帖子编辑历史"
media: 媒体
experimental: "实验性"
foldNotification: "折叠同类型通知"

View File

@ -0,0 +1,246 @@
<template>
<div
ref="elRef"
v-size="{ max: [500, 450] }"
class="qglefbjs notification"
:class="notification.type"
>
<div class="meta">
<span class="info">
<span class="sub-icon" :class="notification.type">
<i
v-if="notification.type === 'renote'"
:class="icon('ph-rocket-launch', false)"
></i>
<XReactionIcon
v-else-if="
showEmojiReactions && notification.type === 'reaction'
"
ref="reactionRef"
:reaction="
notification.reaction
? notification.reaction.replace(
/^:(\w+):$/,
':$1@.:',
)
: notification.reaction
"
:custom-emojis="notification.note.emojis"
:no-style="true"
/>
<XReactionIcon
v-else-if="
!showEmojiReactions && notification.type === 'reaction'
"
:reaction="defaultReaction"
:no-style="true"
/>
</span>
<span class="avatars">
<MkAvatar
v-for="user in users"
class="avatar"
:user="user"
/>
</span>
<span class="text">
{{ getText() }}
</span>
</span>
<MkTime
v-if="withTime"
:time="notification.createdAt"
class="time"
/>
</div>
<!-- <MkA
v-if="notification.type === 'reaction'"
class="text"
:to="notePage(notification.note)"
:title="getNoteSummary(notification.note)"
>
<Mfm
:text="getNoteSummary(notification.note)"
:plain="true"
:nowrap="!full"
:lang="notification.note.lang"
:custom-emojis="notification.note.emojis"
/>
</MkA> -->
<XNote
v-if="notification.type === 'renote'"
class="content"
:note="notification.note.renote"
:hide-footer="true"
/>
<XNote
v-else
class="content"
:note="notification.note"
:hide-footer="true"
/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from "vue";
import type { Connection } from "firefish-js/src/streaming";
import type { Channels } from "firefish-js/src/streaming.types";
import XReactionIcon from "@/components/MkReactionIcon.vue";
import XReactionTooltip from "@/components/MkReactionTooltip.vue";
import { i18n } from "@/i18n";
import * as os from "@/os";
import { useStream } from "@/stream";
import { useTooltip } from "@/scripts/use-tooltip";
import { defaultStore } from "@/store";
import { instance } from "@/instance";
import icon from "@/scripts/icon";
import type {
NotificationFolded,
ReactionNotificationFolded,
} from "@/types/notification";
import XNote from "@/components/MkNote.vue";
const props = withDefaults(
defineProps<{
notification: NotificationFolded;
withTime?: boolean;
full?: boolean;
}>(),
{
withTime: false,
full: false,
},
);
const stream = useStream();
const elRef = ref<HTMLElement | null>(null);
const reactionRef = ref<InstanceType<typeof XReactionIcon> | null>(null);
const showEmojiReactions =
defaultStore.state.enableEmojiReactions ||
defaultStore.state.showEmojisInReactionNotifications;
const defaultReaction = ["⭐", "👍", "❤️"].includes(instance.defaultReaction)
? instance.defaultReaction
: "⭐";
const users = ref(props.notification.users.slice(0, 5));
const userleft = ref(props.notification.users.length - users.value.length);
let readObserver: IntersectionObserver | undefined;
let connection: Connection<Channels["main"]> | null = null;
function getText() {
let res = "";
switch (props.notification.type) {
case "renote":
res = i18n.ts._notification.renoted;
break;
case "reaction":
res = i18n.ts._notification.reacted;
break;
}
if (userleft.value > 0) {
res = i18n.t("_notification.andCountUsers", {
count: userleft.value,
acted: res,
});
}
return res;
}
useTooltip(reactionRef, (showing) => {
const n = props.notification as ReactionNotificationFolded;
os.popup(
XReactionTooltip,
{
showing,
reaction: n.reaction
? n.reaction.replace(/^:(\w+):$/, ":$1@.:")
: n.reaction,
emojis: n.note.emojis,
targetElement: reactionRef.value!.$el,
},
{},
"closed",
);
});
onMounted(() => {
const unreadNotifications = props.notification.notifications.filter(
(n) => !n.isRead,
);
readObserver = new IntersectionObserver((entries, observer) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
for (const u of unreadNotifications) {
stream.send("readNotification", {
id: u.id,
});
}
observer.disconnect();
});
readObserver.observe(elRef.value!);
connection = stream.useChannel("main");
connection.on("readAllNotifications", () => readObserver!.disconnect());
});
onUnmounted(() => {
if (readObserver) readObserver.disconnect();
if (connection) connection.dispose();
});
</script>
<style lang="scss" scoped>
.qglefbjs {
position: relative;
box-sizing: border-box;
font-size: 0.9em;
overflow-wrap: break-word;
contain: content;
&.max-width_500px > .meta{
padding-block: 16px;
font-size: 0.9em;
}
&.max-width_450px > .meta {
padding: 12px 16px;
}
> .meta {
margin-top: 1px; // Otherwise it will cover the line
padding: 24px 32px 0 32px;
display: flex;
align-items: baseline;
white-space: nowrap;
> .info {
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
overflow: hidden;
// flex-grow: 1;
// display: inline-flex;
> .sub-icon {
margin-right: 3px;
font-size: 14px;
}
> .avatars > .avatar {
width: 20px;
height: 20px;
margin-right: 5px;
}
}
> .time {
margin-left: auto;
// flex-grow: 0;
// flex-shrink: 0;
white-space: nowrap;
font-size: 0.9em;
}
}
}
</style>

View File

@ -13,13 +13,21 @@
<template #default="{ items: notifications }">
<XList
:items="convertNotification(notifications)"
v-slot="{ item: notification }"
class="elsfgstc"
:items="notifications"
:no-gap="true"
>
<XNotificationFolded
v-if="isFoldedNotification(notification)"
:key="'nf-' + notification.id"
:notification="notification"
:with-time="true"
:full="true"
class="_panel notification"
/>
<XNote
v-if="isNoteNotification(notification)"
v-else-if="isNoteNotification(notification)"
:key="'nn-' + notification.id"
:note="notification.note"
:collapsed-reply="
@ -48,11 +56,15 @@ import MkPagination, {
type MkPaginationType,
} from "@/components/MkPagination.vue";
import XNotification from "@/components/MkNotification.vue";
import XNotificationFolded from "@/components/MkNotificationFolded.vue";
import XList from "@/components/MkDateSeparatedList.vue";
import XNote from "@/components/MkNote.vue";
import { useStream } from "@/stream";
import { me } from "@/me";
import { i18n } from "@/i18n";
import type { NotificationFolded } from "@/types/notification";
import { foldNotifications } from "@/scripts/fold";
import { defaultStore } from "@/store";
const props = defineProps<{
includeTypes?: (typeof notificationTypes)[number][];
@ -63,9 +75,12 @@ const stream = useStream();
const pagingComponent = ref<MkPaginationType<"i/notifications"> | null>(null);
const FETCH_LIMIT = 50;
const pagination = {
endpoint: "i/notifications" as const,
limit: 10,
limit: FETCH_LIMIT,
secondFetchLimit: FETCH_LIMIT,
params: computed(() => ({
includeTypes: props.includeTypes ?? undefined,
excludeTypes: props.includeTypes ? undefined : me?.mutingNotificationTypes,
@ -81,6 +96,11 @@ function isNoteNotification(
| entities.MentionNotification {
return n.type === "reply" || n.type === "quote" || n.type === "mention";
}
function isFoldedNotification(
n: NotificationFolded | entities.Notification,
): n is NotificationFolded {
return "folded" in n;
}
const onNotification = (notification: entities.Notification) => {
const isMuted = props.includeTypes
@ -102,6 +122,14 @@ const onNotification = (notification: entities.Notification) => {
let connection: StreamTypes.ChannelOf<"main"> | undefined;
function convertNotification(n: entities.Notification[]) {
if (defaultStore.state.foldNotification) {
return foldNotifications(n, FETCH_LIMIT);
} else {
return n;
}
}
onMounted(() => {
connection = stream.useChannel("main");
connection.on("notification", onNotification);

View File

@ -117,6 +117,7 @@ export type PagingKey = PagingKeyOf<any>;
export interface Paging<E extends PagingKey = PagingKey> {
endpoint: E;
limit: number;
secondFetchLimit?: number;
params?: Endpoints[E]["req"] | ComputedRef<Endpoints[E]["req"]>;
/**
@ -141,7 +142,7 @@ export interface Paging<E extends PagingKey = PagingKey> {
export type PagingOf<T> = Paging<TypeUtils.EndpointsOf<T[]>>;
const SECOND_FETCH_LIMIT = 30;
const SECOND_FETCH_LIMIT_DEFAULT = 30;
const props = withDefaults(
defineProps<{
@ -285,7 +286,8 @@ const fetchMore = async (): Promise<void> => {
await os
.api(props.pagination.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT + 1,
limit:
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT) + 1,
...(props.pagination.offsetMode
? {
offset: offset.value,
@ -312,7 +314,10 @@ const fetchMore = async (): Promise<void> => {
if (i === 10) item._shouldInsertAd_ = true;
}
}
if (res.length > SECOND_FETCH_LIMIT) {
if (
res.length >
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT)
) {
res.pop();
items.value = props.pagination.reversed
? res.toReversed().concat(items.value)
@ -346,7 +351,8 @@ const fetchMoreAhead = async (): Promise<void> => {
await os
.api(props.pagination.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT + 1,
limit:
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT) + 1,
...(props.pagination.offsetMode
? {
offset: offset.value,
@ -361,7 +367,10 @@ const fetchMoreAhead = async (): Promise<void> => {
})
.then(
(res: Item[]) => {
if (res.length > SECOND_FETCH_LIMIT) {
if (
res.length >
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT)
) {
res.pop();
items.value = props.pagination.reversed
? res.toReversed().concat(items.value)

View File

@ -327,6 +327,13 @@
</FormSelect>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts.experimental }}</template>
<FormSwitch v-model="foldNotification" class="_formBlock">{{
i18n.ts.foldNotification
}}</FormSwitch>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts.forMobile }}</template>
<FormSwitch
@ -542,6 +549,9 @@ const showAddFileDescriptionAtFirstPost = computed(
const autocorrectNoteLanguage = computed(
defaultStore.makeGetterSetter("autocorrectNoteLanguage"),
);
const foldNotification = computed(
defaultStore.makeGetterSetter("foldNotification"),
);
// This feature (along with injectPromo) is currently disabled
// function onChangeInjectFeaturedNote(v) {
@ -610,6 +620,7 @@ watch(
enableTimelineStreaming,
enablePullToRefresh,
pullToRefreshThreshold,
foldNotification,
],
async () => {
await reloadAsk();

View File

@ -0,0 +1,100 @@
import type {
FoldableNotification,
NotificationFolded,
} from "@/types/notification";
import type { entities } from "firefish-js";
interface FoldOption {
/** If items length is 1, skip aggregation */
/** @default true */
skipSingleElement?: boolean;
}
/**
* Fold similar content
* @param ns items to fold
* @param fetch_limit fetch limit of pagination. items will be divided into subarrays with this limit as the length.
* @param classfier Classify the given item into a certain category (return a string representing the category)
* @param aggregator Aggregate items of the given class into itemfolded
* @returns folded items
*/
export function foldItems<ItemFolded, Item>(
ns: Item[],
fetch_limit: number,
classfier: (n: Item, index: number) => string,
aggregator: (ns: Item[], key: string) => ItemFolded,
_options?: FoldOption,
) {
let res: (ItemFolded | Item)[] = [];
const options: FoldOption = _options ?? {};
options.skipSingleElement ??= true;
for (let i = 0; i < ns.length; i += fetch_limit) {
const toFold = ns.slice(i, i + fetch_limit);
const toAppendKeys: string[] = [];
const foldMap = new Map<string, Item[]>();
for (const [index, n] of toFold.entries()) {
const key = classfier(n, index);
const arr = foldMap.get(key);
if (arr != null) {
arr.push(n);
} else {
foldMap.set(key, [n]);
toAppendKeys.push(key);
}
}
res = res.concat(
toAppendKeys.map((key) => {
const arr = foldMap.get(key)!;
if (arr?.length === 1 && options?.skipSingleElement) {
return arr[0];
}
return aggregator(arr, key);
}),
);
}
return res;
}
export function foldNotifications(
ns: entities.Notification[],
fetch_limit: number,
) {
return foldItems(
ns,
fetch_limit,
(n) => {
switch (n.type) {
case "renote":
return `renote-of:${n.note.renote.id}`;
case "reaction":
return `reaction:${n.reaction}:of:${n.note.id}`;
default: {
return `${n.id}`;
}
}
},
(ns) => {
const represent = ns[0];
function check(
ns: entities.Notification[],
): ns is FoldableNotification[] {
return represent.type === "renote" || represent.type === "reaction";
}
if (!check(ns)) {
return represent;
}
return {
...represent,
folded: true,
userIds: ns.map((nn) => nn.userId),
users: ns.map((nn) => nn.user),
notifications: ns!,
} as NotificationFolded;
},
);
}

View File

@ -450,6 +450,10 @@ export const defaultStore = markRaw(
where: "account",
default: true,
},
foldNotification: {
where: "device",
default: false,
},
}),
);

View File

@ -0,0 +1,31 @@
import type { entities } from "firefish-js";
export type FoldableNotification =
| entities.RenoteNotification
| entities.ReactionNotification;
type Fold<T extends FoldableNotification> = {
id: string;
type: T["type"];
createdAt: T["createdAt"];
note: T["note"];
folded: true;
userIds: entities.User["id"][];
users: entities.User[];
notifications: T[];
};
export type RenoteNotificationFolded = Fold<entities.RenoteNotification>;
export type ReactionNotificationFolded = Fold<entities.ReactionNotification> & {
reaction: string;
};
export type GetNotificationFoldedType<T extends FoldableNotification> =
T["type"] extends "renote"
? RenoteNotificationFolded
: ReactionNotificationFolded;
export type NotificationFolded =
| RenoteNotificationFolded
| ReactionNotificationFolded;