Merge branch 'feat/fold' into 'develop'

feat: fold notifications

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

Closes #10908

See merge request firefish/firefish!10767
This commit is contained in:
naskya 2024-04-25 04:10:41 +00:00
commit 5891a90f71
11 changed files with 483 additions and 30 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} more users {acted}"
_types:
all: "All"
follow: "New followers"
@ -2232,3 +2233,4 @@ 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"
foldNotification: "Group similar notifications"

View file

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

View file

@ -164,7 +164,7 @@
tabindex="-1"
>
<XReactionsViewer
v-if="enableEmojiReactions"
v-if="enableEmojiReactions && !hideEmojiViewer"
ref="reactionsViewer"
:note="appearNote"
/>
@ -191,12 +191,7 @@
v-if="!enableEmojiReactions"
class="button"
:note="appearNote"
:count="
Object.values(appearNote.reactions).reduce(
(partialSum, val) => partialSum + val,
0,
)
"
:count="reactionCount"
:reacted="appearNote.myReaction != null"
/>
<XStarButton
@ -219,6 +214,7 @@
@click.stop="react()"
>
<i :class="icon('ph-smiley')"></i>
<p class="count" v-if="reactionCount > 0 && hideEmojiViewer">{{reactionCount}}</p>
</button>
<button
v-if="
@ -231,6 +227,7 @@
@click.stop="undoReact(appearNote)"
>
<i :class="icon('ph-minus')"></i>
<p class="count" v-if="reactionCount > 0 && hideEmojiViewer">{{reactionCount}}</p>
</button>
<XQuoteButton class="button" :note="appearNote" />
<button
@ -327,6 +324,7 @@ const props = defineProps<{
detailedView?: boolean;
collapsedReply?: boolean;
hideFooter?: boolean;
hideEmojiViewer?: boolean;
}>();
const inChannel = inject("inChannel", null);
@ -397,6 +395,13 @@ const isForeignLanguage: boolean =
return postLang !== "" && postLang !== targetLang;
})();
const reactionCount = computed(() =>
Object.values(appearNote.value.reactions).reduce(
(partialSum, val) => partialSum + val,
0,
),
);
async function translate_(noteId: string, targetLang: string) {
return await os.api("notes/translate", {
noteId,

View file

@ -60,7 +60,7 @@
</div>
<footer ref="footerEl" class="footer" tabindex="-1">
<XReactionsViewer
v-if="enableEmojiReactions"
v-if="enableEmojiReactions && !hideEmojiViewer"
ref="reactionsViewer"
:note="appearNote"
/>
@ -84,12 +84,7 @@
v-if="!enableEmojiReactions"
class="button"
:note="appearNote"
:count="
Object.values(appearNote.reactions).reduce(
(partialSum, val) => partialSum + val,
0,
)
"
:count="reactionCount"
:reacted="appearNote.myReaction != null"
/>
<XStarButton
@ -112,6 +107,7 @@
@click.stop="react()"
>
<i :class="icon('ph-smiley')"></i>
<p class="count" v-if="reactionCount > 0 && hideEmojiViewer">{{reactionCount}}</p>
</button>
<button
v-if="
@ -124,6 +120,7 @@
@click.stop="undoReact(appearNote)"
>
<i :class="icon('ph-minus')"></i>
<p class="count" v-if="reactionCount > 0 && hideEmojiViewer">{{reactionCount}}</p>
</button>
<XQuoteButton class="button" :note="appearNote" />
<button
@ -250,6 +247,7 @@ const props = withDefaults(
// the actual reply level of this note within the conversation thread
replyLevel?: number;
autoAddReplies?: boolean;
hideEmojiViewer?: boolean;
}>(),
{
depth: 1,
@ -339,6 +337,13 @@ const lang = localStorage.getItem("lang");
const translateLang = localStorage.getItem("translateLang");
const targetLang = (translateLang || lang || navigator.language)?.slice(0, 2);
const reactionCount = computed(() =>
Object.values(appearNote.value.reactions).reduce(
(partialSum, val) => partialSum + val,
0,
),
);
const isForeignLanguage: boolean =
defaultStore.state.detectPostLanguage &&
appearNote.value.text != null &&

View file

@ -0,0 +1,244 @@
<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>
<!-- Since the reacted user list is actually shown above, the emoji-viewer is hidden to prevent visual noise -->
<XNote
v-if="notification.type === 'renote'"
class="content"
:note="removeReplyTo(notification.note.renote)"
:hide-emoji-viewer="true"
/>
<XNote
v-else
class="content"
:note="removeReplyTo(notification.note)"
:hide-emoji-viewer="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";
import type { entities } from "firefish-js";
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;
}
/**
* Delete reply-related properties that are not needed for notifications
*/
function removeReplyTo(note: entities.Note): entities.Note {
return Object.assign(note, {
replyId: null,
reply: undefined,
});
}
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 0 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,15 +75,30 @@ const stream = useStream();
const pagingComponent = ref<MkPaginationType<"i/notifications"> | null>(null);
const pagination = {
endpoint: "i/notifications" as const,
limit: 10,
params: computed(() => ({
includeTypes: props.includeTypes ?? undefined,
excludeTypes: props.includeTypes ? undefined : me?.mutingNotificationTypes,
unreadOnly: props.unreadOnly,
})),
};
const shouldFold = defaultStore.state.foldNotification;
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: FETCH_LIMIT,
secondFetchLimit: FETCH_LIMIT,
}
: {
limit: 30,
},
);
function isNoteNotification(
n: entities.Notification,
@ -81,6 +108,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 +134,14 @@ const onNotification = (notification: entities.Notification) => {
let connection: StreamTypes.ChannelOf<"main"> | undefined;
function convertNotification(n: entities.Notification[]) {
if (shouldFold) {
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.experimentalFeatures }}</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: "deviceAccount",
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;