feat: fold notifications
This commit is contained in:
parent
ac9a07d25a
commit
7e9633a36b
9 changed files with 443 additions and 8 deletions
|
@ -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"
|
||||
|
|
|
@ -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: "折叠同类型通知"
|
||||
|
|
246
packages/client/src/components/MkNotificationFolded.vue
Normal file
246
packages/client/src/components/MkNotificationFolded.vue
Normal 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>
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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();
|
||||
|
|
100
packages/client/src/scripts/fold.ts
Normal file
100
packages/client/src/scripts/fold.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
|
@ -450,6 +450,10 @@ export const defaultStore = markRaw(
|
|||
where: "account",
|
||||
default: true,
|
||||
},
|
||||
foldNotification: {
|
||||
where: "device",
|
||||
default: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
31
packages/client/src/types/notification.ts
Normal file
31
packages/client/src/types/notification.ts
Normal 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;
|
Loading…
Reference in a new issue