aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2021-01-11 21:58:32 +0800
committerGitHub <noreply@github.com>2021-01-11 21:58:32 +0800
commit5d3a3111bbc349d5d5ff0a4ed92f97b14a9d65fe (patch)
treed635081fb6eaa0222270bfb4ac29906fb767a9bd /FrontEnd/src
parent777efa6e0405f4e871de4da21b939e30ed07f754 (diff)
parent26f02d90c2571251b32c3b03b970dd290e3892e6 (diff)
downloadtimeline-5d3a3111bbc349d5d5ff0a4ed92f97b14a9d65fe.tar.gz
timeline-5d3a3111bbc349d5d5ff0a4ed92f97b14a9d65fe.tar.bz2
timeline-5d3a3111bbc349d5d5ff0a4ed92f97b14a9d65fe.zip
Merge pull request #206 from crupest/front-dev
Front development.
Diffstat (limited to 'FrontEnd/src')
-rw-r--r--FrontEnd/src/app/http/bookmark.ts27
-rw-r--r--FrontEnd/src/app/http/common.ts26
-rw-r--r--FrontEnd/src/app/http/highlight.ts21
-rw-r--r--FrontEnd/src/app/http/timeline.ts149
-rw-r--r--FrontEnd/src/app/http/token.ts2
-rw-r--r--FrontEnd/src/app/http/user.ts63
-rw-r--r--FrontEnd/src/app/locales/en/translation.json6
-rw-r--r--FrontEnd/src/app/locales/zh/translation.json6
-rw-r--r--FrontEnd/src/app/services/DataHub.ts225
-rw-r--r--FrontEnd/src/app/services/DataHub2.ts181
-rw-r--r--FrontEnd/src/app/services/common.ts19
-rw-r--r--FrontEnd/src/app/services/timeline.ts626
-rw-r--r--FrontEnd/src/app/services/user.ts225
-rw-r--r--FrontEnd/src/app/utilities/url.ts50
-rw-r--r--FrontEnd/src/app/views/admin/UserAdmin.tsx31
-rw-r--r--FrontEnd/src/app/views/home/BoardWithUser.tsx12
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx20
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineMember.tsx78
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx238
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx61
-rw-r--r--FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx6
-rw-r--r--FrontEnd/src/app/views/user/UserInfoCard.tsx6
22 files changed, 837 insertions, 1241 deletions
diff --git a/FrontEnd/src/app/http/bookmark.ts b/FrontEnd/src/app/http/bookmark.ts
index 68de4d73..15e55d98 100644
--- a/FrontEnd/src/app/http/bookmark.ts
+++ b/FrontEnd/src/app/http/bookmark.ts
@@ -1,6 +1,5 @@
-import axios from "axios";
-
import {
+ axios,
apiBaseUrl,
convertToNetworkError,
extractResponseData,
@@ -18,38 +17,38 @@ export interface HttpHighlightMoveRequest {
}
export interface IHttpBookmarkClient {
- list(token: string): Promise<HttpTimelineInfo[]>;
- put(timeline: string, token: string): Promise<void>;
- delete(timeline: string, token: string): Promise<void>;
- move(req: HttpHighlightMoveRequest, token: string): Promise<void>;
+ list(): Promise<HttpTimelineInfo[]>;
+ put(timeline: string): Promise<void>;
+ delete(timeline: string): Promise<void>;
+ move(req: HttpHighlightMoveRequest): Promise<void>;
}
export class HttpHighlightClient implements IHttpBookmarkClient {
- list(token: string): Promise<HttpTimelineInfo[]> {
+ list(): Promise<HttpTimelineInfo[]> {
return axios
- .get<RawHttpTimelineInfo[]>(`${apiBaseUrl}/bookmarks?token=${token}`)
+ .get<RawHttpTimelineInfo[]>(`${apiBaseUrl}/bookmarks`)
.then(extractResponseData)
.then((list) => list.map(processRawTimelineInfo))
.catch(convertToNetworkError);
}
- put(timeline: string, token: string): Promise<void> {
+ put(timeline: string): Promise<void> {
return axios
- .put(`${apiBaseUrl}/bookmarks/${timeline}?token=${token}`)
+ .put(`${apiBaseUrl}/bookmarks/${timeline}`)
.catch(convertToNetworkError)
.then();
}
- delete(timeline: string, token: string): Promise<void> {
+ delete(timeline: string): Promise<void> {
return axios
- .delete(`${apiBaseUrl}/bookmarks/${timeline}?token=${token}`)
+ .delete(`${apiBaseUrl}/bookmarks/${timeline}`)
.catch(convertToNetworkError)
.then();
}
- move(req: HttpHighlightMoveRequest, token: string): Promise<void> {
+ move(req: HttpHighlightMoveRequest): Promise<void> {
return axios
- .post(`${apiBaseUrl}/bookmarkop/move?token=${token}`, req)
+ .post(`${apiBaseUrl}/bookmarkop/move`, req)
.catch(convertToNetworkError)
.then();
}
diff --git a/FrontEnd/src/app/http/common.ts b/FrontEnd/src/app/http/common.ts
index 54203d1a..0f46280c 100644
--- a/FrontEnd/src/app/http/common.ts
+++ b/FrontEnd/src/app/http/common.ts
@@ -1,7 +1,27 @@
-import { AxiosError, AxiosResponse } from "axios";
+import rawAxios, { AxiosError, AxiosResponse } from "axios";
export const apiBaseUrl = "/api";
+export const axios = rawAxios.create();
+
+let _token: string | null = null;
+
+export function getHttpToken(): string | null {
+ return _token;
+}
+
+export function setHttpToken(token: string | null): void {
+ _token = token;
+
+ if (token == null) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ delete axios.defaults.headers.common["Authorization"];
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
+ }
+}
+
export function base64(blob: Blob): Promise<string> {
return new Promise<string>((resolve) => {
const reader = new FileReader();
@@ -159,3 +179,7 @@ export function convertToBlobWithEtag(res: AxiosResponse<Blob>): BlobWithEtag {
etag: (res.headers as Record<"etag", string>)["etag"],
};
}
+
+export function extractEtag(res: AxiosResponse): string {
+ return (res.headers as Record<"etag", string>)["etag"];
+}
diff --git a/FrontEnd/src/app/http/highlight.ts b/FrontEnd/src/app/http/highlight.ts
index 1f226c19..851d52ce 100644
--- a/FrontEnd/src/app/http/highlight.ts
+++ b/FrontEnd/src/app/http/highlight.ts
@@ -1,6 +1,5 @@
-import axios from "axios";
-
import {
+ axios,
apiBaseUrl,
convertToNetworkError,
extractResponseData,
@@ -19,9 +18,9 @@ export interface HttpHighlightMoveRequest {
export interface IHttpHighlightClient {
list(): Promise<HttpTimelineInfo[]>;
- put(timeline: string, token: string): Promise<void>;
- delete(timeline: string, token: string): Promise<void>;
- move(req: HttpHighlightMoveRequest, token: string): Promise<void>;
+ put(timeline: string): Promise<void>;
+ delete(timeline: string): Promise<void>;
+ move(req: HttpHighlightMoveRequest): Promise<void>;
}
export class HttpHighlightClient implements IHttpHighlightClient {
@@ -33,23 +32,23 @@ export class HttpHighlightClient implements IHttpHighlightClient {
.catch(convertToNetworkError);
}
- put(timeline: string, token: string): Promise<void> {
+ put(timeline: string): Promise<void> {
return axios
- .put(`${apiBaseUrl}/highlights/${timeline}?token=${token}`)
+ .put(`${apiBaseUrl}/highlights/${timeline}`)
.catch(convertToNetworkError)
.then();
}
- delete(timeline: string, token: string): Promise<void> {
+ delete(timeline: string): Promise<void> {
return axios
- .delete(`${apiBaseUrl}/highlights/${timeline}?token=${token}`)
+ .delete(`${apiBaseUrl}/highlights/${timeline}`)
.catch(convertToNetworkError)
.then();
}
- move(req: HttpHighlightMoveRequest, token: string): Promise<void> {
+ move(req: HttpHighlightMoveRequest): Promise<void> {
return axios
- .post(`${apiBaseUrl}/highlightop/move?token=${token}`, req)
+ .post(`${apiBaseUrl}/highlightop/move`, req)
.catch(convertToNetworkError)
.then();
}
diff --git a/FrontEnd/src/app/http/timeline.ts b/FrontEnd/src/app/http/timeline.ts
index 6be0a183..228b6105 100644
--- a/FrontEnd/src/app/http/timeline.ts
+++ b/FrontEnd/src/app/http/timeline.ts
@@ -1,8 +1,9 @@
-import axios, { AxiosError } from "axios";
+import { AxiosError } from "axios";
-import { updateQueryString, applyQueryParameters } from "../utilities/url";
+import { applyQueryParameters } from "../utilities/url";
import {
+ axios,
apiBaseUrl,
extractResponseData,
convertToNetworkError,
@@ -30,6 +31,8 @@ export interface HttpTimelineInfo {
visibility: TimelineVisibility;
lastModified: Date;
members: HttpUser[];
+ isHighlight: boolean;
+ isBookmark: boolean;
}
export interface HttpTimelineListQuery {
@@ -130,6 +133,8 @@ export interface RawHttpTimelineInfo {
visibility: TimelineVisibility;
lastModified: string;
members: HttpUser[];
+ isHighlight: boolean;
+ isBookmark: boolean;
}
interface RawTimelinePostTextContent {
@@ -229,33 +234,17 @@ export interface IHttpTimelineClient {
ifModifiedSince: Date;
}
): Promise<HttpTimelineInfo | NotModified>;
- postTimeline(
- req: HttpTimelinePostRequest,
- token: string
- ): Promise<HttpTimelineInfo>;
+ postTimeline(req: HttpTimelinePostRequest): Promise<HttpTimelineInfo>;
patchTimeline(
timelineName: string,
- req: HttpTimelinePatchRequest,
- token: string
+ req: HttpTimelinePatchRequest
): Promise<HttpTimelineInfo>;
- deleteTimeline(timelineName: string, token: string): Promise<void>;
- memberPut(
- timelineName: string,
- username: string,
- token: string
- ): Promise<void>;
- memberDelete(
- timelineName: string,
- username: string,
- token: string
- ): Promise<void>;
+ deleteTimeline(timelineName: string): Promise<void>;
+ memberPut(timelineName: string, username: string): Promise<void>;
+ memberDelete(timelineName: string, username: string): Promise<void>;
+ listPost(timelineName: string): Promise<HttpTimelinePostInfo[]>;
listPost(
timelineName: string,
- token?: string
- ): Promise<HttpTimelinePostInfo[]>;
- listPost(
- timelineName: string,
- token: string | undefined,
query: {
modifiedSince?: Date;
includeDeleted?: false;
@@ -263,33 +252,22 @@ export interface IHttpTimelineClient {
): Promise<HttpTimelinePostInfo[]>;
listPost(
timelineName: string,
- token: string | undefined,
query: {
modifiedSince?: Date;
includeDeleted: true;
}
): Promise<HttpTimelineGenericPostInfo[]>;
+ getPostData(timelineName: string, postId: number): Promise<BlobWithEtag>;
getPostData(
timelineName: string,
postId: number,
- token?: string
- ): Promise<BlobWithEtag>;
- getPostData(
- timelineName: string,
- postId: number,
- token: string | undefined,
etag: string
): Promise<BlobWithEtag | NotModified>;
postPost(
timelineName: string,
- req: HttpTimelinePostPostRequest,
- token: string
+ req: HttpTimelinePostPostRequest
): Promise<HttpTimelinePostInfo>;
- deletePost(
- timelineName: string,
- postId: number,
- token: string
- ): Promise<void>;
+ deletePost(timelineName: string, postId: number): Promise<void>;
}
export class HttpTimelineClient implements IHttpTimelineClient {
@@ -339,12 +317,9 @@ export class HttpTimelineClient implements IHttpTimelineClient {
.catch(convertToNetworkError);
}
- postTimeline(
- req: HttpTimelinePostRequest,
- token: string
- ): Promise<HttpTimelineInfo> {
+ postTimeline(req: HttpTimelinePostRequest): Promise<HttpTimelineInfo> {
return axios
- .post<RawHttpTimelineInfo>(`${apiBaseUrl}/timelines?token=${token}`, req)
+ .post<RawHttpTimelineInfo>(`${apiBaseUrl}/timelines`, req)
.then(extractResponseData)
.then(processRawTimelineInfo)
.catch(convertToIfErrorCodeIs(11040101, HttpTimelineNameConflictError))
@@ -353,12 +328,11 @@ export class HttpTimelineClient implements IHttpTimelineClient {
patchTimeline(
timelineName: string,
- req: HttpTimelinePatchRequest,
- token: string
+ req: HttpTimelinePatchRequest
): Promise<HttpTimelineInfo> {
return axios
.patch<RawHttpTimelineInfo>(
- `${apiBaseUrl}/timelines/${timelineName}?token=${token}`,
+ `${apiBaseUrl}/timelines/${timelineName}`,
req
)
.then(extractResponseData)
@@ -366,46 +340,30 @@ export class HttpTimelineClient implements IHttpTimelineClient {
.catch(convertToNetworkError);
}
- deleteTimeline(timelineName: string, token: string): Promise<void> {
+ deleteTimeline(timelineName: string): Promise<void> {
return axios
- .delete(`${apiBaseUrl}/timelines/${timelineName}?token=${token}`)
+ .delete(`${apiBaseUrl}/timelines/${timelineName}`)
.catch(convertToNetworkError)
.then();
}
- memberPut(
- timelineName: string,
- username: string,
- token: string
- ): Promise<void> {
+ memberPut(timelineName: string, username: string): Promise<void> {
return axios
- .put(
- `${apiBaseUrl}/timelines/${timelineName}/members/${username}?token=${token}`
- )
+ .put(`${apiBaseUrl}/timelines/${timelineName}/members/${username}`)
.catch(convertToNetworkError)
.then();
}
- memberDelete(
- timelineName: string,
- username: string,
- token: string
- ): Promise<void> {
+ memberDelete(timelineName: string, username: string): Promise<void> {
return axios
- .delete(
- `${apiBaseUrl}/timelines/${timelineName}/members/${username}?token=${token}`
- )
+ .delete(`${apiBaseUrl}/timelines/${timelineName}/members/${username}`)
.catch(convertToNetworkError)
.then();
}
+ listPost(timelineName: string): Promise<HttpTimelinePostInfo[]>;
listPost(
timelineName: string,
- token?: string
- ): Promise<HttpTimelinePostInfo[]>;
- listPost(
- timelineName: string,
- token: string | undefined,
query: {
modifiedSince?: Date;
includeDeleted?: false;
@@ -413,7 +371,6 @@ export class HttpTimelineClient implements IHttpTimelineClient {
): Promise<HttpTimelinePostInfo[]>;
listPost(
timelineName: string,
- token: string | undefined,
query: {
modifiedSince?: Date;
includeDeleted: true;
@@ -421,33 +378,18 @@ export class HttpTimelineClient implements IHttpTimelineClient {
): Promise<HttpTimelineGenericPostInfo[]>;
listPost(
timelineName: string,
- token?: string,
query?: {
modifiedSince?: Date;
includeDeleted?: boolean;
}
): Promise<HttpTimelineGenericPostInfo[]> {
- let url = `${apiBaseUrl}/timelines/${timelineName}/posts`;
- url = updateQueryString("token", token, url);
- if (query != null) {
- if (query.modifiedSince != null) {
- url = updateQueryString(
- "modifiedSince",
- query.modifiedSince.toISOString(),
- url
- );
- }
- if (query.includeDeleted != null) {
- url = updateQueryString(
- "includeDeleted",
- query.includeDeleted ? "true" : "false",
- url
- );
- }
- }
-
return axios
- .get<RawTimelineGenericPostInfo[]>(url)
+ .get<RawTimelineGenericPostInfo[]>(
+ applyQueryParameters(
+ `${apiBaseUrl}/timelines/${timelineName}/posts`,
+ query
+ )
+ )
.then(extractResponseData)
.catch(convertToIfStatusCodeIs(404, HttpTimelineNotExistError))
.catch(convertToForbiddenError)
@@ -457,15 +399,10 @@ export class HttpTimelineClient implements IHttpTimelineClient {
);
}
+ getPostData(timelineName: string, postId: number): Promise<BlobWithEtag>;
getPostData(
timelineName: string,
postId: number,
- token: string
- ): Promise<BlobWithEtag>;
- getPostData(
- timelineName: string,
- postId: number,
- token?: string,
etag?: string
): Promise<BlobWithEtag | NotModified> {
const headers =
@@ -475,8 +412,7 @@ export class HttpTimelineClient implements IHttpTimelineClient {
}
: undefined;
- let url = `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}/data`;
- url = updateQueryString("token", token, url);
+ const url = `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}/data`;
return axios
.get(url, {
@@ -491,8 +427,7 @@ export class HttpTimelineClient implements IHttpTimelineClient {
async postPost(
timelineName: string,
- req: HttpTimelinePostPostRequest,
- token: string
+ req: HttpTimelinePostPostRequest
): Promise<HttpTimelinePostInfo> {
let content: RawTimelinePostPostRequestContent;
if (req.content.type === "image") {
@@ -512,7 +447,7 @@ export class HttpTimelineClient implements IHttpTimelineClient {
}
return await axios
.post<RawTimelinePostInfo>(
- `${apiBaseUrl}/timelines/${timelineName}/posts?token=${token}`,
+ `${apiBaseUrl}/timelines/${timelineName}/posts`,
rawReq
)
.then(extractResponseData)
@@ -520,15 +455,9 @@ export class HttpTimelineClient implements IHttpTimelineClient {
.then((rawPost) => processRawTimelinePostInfo(rawPost));
}
- deletePost(
- timelineName: string,
- postId: number,
- token: string
- ): Promise<void> {
+ deletePost(timelineName: string, postId: number): Promise<void> {
return axios
- .delete(
- `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}?token=${token}`
- )
+ .delete(`${apiBaseUrl}/timelines/${timelineName}/posts/${postId}`)
.catch(convertToNetworkError)
.then();
}
diff --git a/FrontEnd/src/app/http/token.ts b/FrontEnd/src/app/http/token.ts
index ae0cf3f6..c0644515 100644
--- a/FrontEnd/src/app/http/token.ts
+++ b/FrontEnd/src/app/http/token.ts
@@ -1,3 +1,5 @@
+// Don't use axios in common because it will contains
+// authorization header, which shouldn't be used in token apis.
import axios, { AxiosError } from "axios";
import {
diff --git a/FrontEnd/src/app/http/user.ts b/FrontEnd/src/app/http/user.ts
index 929956d0..19accc42 100644
--- a/FrontEnd/src/app/http/user.ts
+++ b/FrontEnd/src/app/http/user.ts
@@ -1,6 +1,7 @@
-import axios, { AxiosError } from "axios";
+import { AxiosError } from "axios";
import {
+ axios,
apiBaseUrl,
convertToNetworkError,
extractResponseData,
@@ -10,6 +11,7 @@ import {
BlobWithEtag,
convertToBlobWithEtag,
convertToNotModified,
+ extractEtag,
} from "./common";
export const kUserManagement = "UserManagement";
@@ -62,28 +64,23 @@ export class HttpChangePasswordBadCredentialError extends Error {
export interface IHttpUserClient {
list(): Promise<HttpUser[]>;
get(username: string): Promise<HttpUser>;
- patch(
- username: string,
- req: HttpUserPatchRequest,
- token: string
- ): Promise<HttpUser>;
- delete(username: string, token: string): Promise<void>;
+ patch(username: string, req: HttpUserPatchRequest): Promise<HttpUser>;
+ delete(username: string): Promise<void>;
getAvatar(username: string): Promise<BlobWithEtag>;
getAvatar(
username: string,
etag: string
): Promise<BlobWithEtag | NotModified>;
- putAvatar(username: string, data: Blob, token: string): Promise<void>;
- changePassword(req: HttpChangePasswordRequest, token: string): Promise<void>;
+ // return etag
+ putAvatar(username: string, data: Blob): Promise<string>;
+ changePassword(req: HttpChangePasswordRequest): Promise<void>;
putUserPermission(
username: string,
- permission: UserPermission,
- token: string
+ permission: UserPermission
): Promise<void>;
deleteUserPermission(
username: string,
- permission: UserPermission,
- token: string
+ permission: UserPermission
): Promise<void>;
createUser(req: HttpCreateUserRequest, token: string): Promise<HttpUser>;
@@ -105,20 +102,16 @@ export class HttpUserClient implements IHttpUserClient {
.catch(convertToNetworkError);
}
- patch(
- username: string,
- req: HttpUserPatchRequest,
- token: string
- ): Promise<HttpUser> {
+ patch(username: string, req: HttpUserPatchRequest): Promise<HttpUser> {
return axios
- .patch<HttpUser>(`${apiBaseUrl}/users/${username}?token=${token}`, req)
+ .patch<HttpUser>(`${apiBaseUrl}/users/${username}`, req)
.then(extractResponseData)
.catch(convertToNetworkError);
}
- delete(username: string, token: string): Promise<void> {
+ delete(username: string): Promise<void> {
return axios
- .delete(`${apiBaseUrl}/users/${username}?token=${token}`)
+ .delete(`${apiBaseUrl}/users/${username}`)
.catch(convertToNetworkError)
.then();
}
@@ -146,20 +139,20 @@ export class HttpUserClient implements IHttpUserClient {
.catch(convertToNetworkError);
}
- putAvatar(username: string, data: Blob, token: string): Promise<void> {
+ putAvatar(username: string, data: Blob): Promise<string> {
return axios
- .put(`${apiBaseUrl}/users/${username}/avatar?token=${token}`, data, {
+ .put(`${apiBaseUrl}/users/${username}/avatar`, data, {
headers: {
"Content-Type": data.type,
},
})
.catch(convertToNetworkError)
- .then();
+ .then(extractEtag);
}
- changePassword(req: HttpChangePasswordRequest, token: string): Promise<void> {
+ changePassword(req: HttpChangePasswordRequest): Promise<void> {
return axios
- .post(`${apiBaseUrl}/userop/changepassword?token=${token}`, req)
+ .post(`${apiBaseUrl}/userop/changepassword`, req)
.catch(
convertToIfErrorCodeIs(11020201, HttpChangePasswordBadCredentialError)
)
@@ -169,33 +162,27 @@ export class HttpUserClient implements IHttpUserClient {
putUserPermission(
username: string,
- permission: UserPermission,
- token: string
+ permission: UserPermission
): Promise<void> {
return axios
- .put(
- `${apiBaseUrl}/users/${username}/permissions/${permission}?token=${token}`
- )
+ .put(`${apiBaseUrl}/users/${username}/permissions/${permission}`)
.catch(convertToNetworkError)
.then();
}
deleteUserPermission(
username: string,
- permission: UserPermission,
- token: string
+ permission: UserPermission
): Promise<void> {
return axios
- .delete(
- `${apiBaseUrl}/users/${username}/permissions/${permission}?token=${token}`
- )
+ .delete(`${apiBaseUrl}/users/${username}/permissions/${permission}`)
.catch(convertToNetworkError)
.then();
}
- createUser(req: HttpCreateUserRequest, token: string): Promise<HttpUser> {
+ createUser(req: HttpCreateUserRequest): Promise<HttpUser> {
return axios
- .post<HttpUser>(`${apiBaseUrl}/userop/createuser?token=${token}`, req)
+ .post<HttpUser>(`${apiBaseUrl}/userop/createuser`, req)
.then(extractResponseData)
.catch(convertToNetworkError)
.then();
diff --git a/FrontEnd/src/app/locales/en/translation.json b/FrontEnd/src/app/locales/en/translation.json
index 596b5217..ebf9552a 100644
--- a/FrontEnd/src/app/locales/en/translation.json
+++ b/FrontEnd/src/app/locales/en/translation.json
@@ -107,8 +107,10 @@
"prompt": "Are you sure to delete the post? This operation is not recoverable."
}
},
- "addHighlightSuccess": "Succeeded to add highlight.",
- "addBookmarkSuccess": "Succeeded to add bookmark."
+ "addHighlightFail": "Failed to add highlight.",
+ "removeHighlightFail": "Failed to remove highlight.",
+ "addBookmarkFail": "Failed to add bookmark.",
+ "removeBookmarkFail": "Failed to remove bookmark."
},
"user": {
"username": "username",
diff --git a/FrontEnd/src/app/locales/zh/translation.json b/FrontEnd/src/app/locales/zh/translation.json
index e15e177e..0d063b4e 100644
--- a/FrontEnd/src/app/locales/zh/translation.json
+++ b/FrontEnd/src/app/locales/zh/translation.json
@@ -107,8 +107,10 @@
"prompt": "确定删除这个消息?这个操作不可撤销。"
}
},
- "addHighlightSuccess": "成功添加高光。",
- "addBookmarkSuccess": "成功添加书签。"
+ "addHighlightFail": "添加高光失败。",
+ "removeHighlightFail": "删除高光失败。",
+ "addBookmarkFail": "添加书签失败。",
+ "removeBookmarkFail": "删除书签失败。"
},
"user": {
"username": "用户名",
diff --git a/FrontEnd/src/app/services/DataHub.ts b/FrontEnd/src/app/services/DataHub.ts
deleted file mode 100644
index 4d618db6..00000000
--- a/FrontEnd/src/app/services/DataHub.ts
+++ /dev/null
@@ -1,225 +0,0 @@
-import { pull } from "lodash";
-import { Observable, BehaviorSubject, combineLatest } from "rxjs";
-import { map } from "rxjs/operators";
-
-export type Subscriber<TData> = (data: TData) => void;
-
-export type WithSyncStatus<T> = T & { syncing: boolean };
-
-export class DataLine<TData> {
- private _current: TData | undefined = undefined;
-
- private _syncPromise: Promise<void> | null = null;
- private _syncingSubject = new BehaviorSubject<boolean>(false);
-
- private _observers: Subscriber<TData>[] = [];
-
- constructor(
- private config: {
- sync: () => Promise<void>;
- destroyable?: (value: TData | undefined) => boolean;
- disableInitSync?: boolean;
- }
- ) {
- if (config.disableInitSync !== true) {
- setTimeout(() => void this.sync());
- }
- }
-
- private subscribe(subscriber: Subscriber<TData>): void {
- this._observers.push(subscriber);
- if (this._current !== undefined) {
- subscriber(this._current);
- }
- }
-
- private unsubscribe(subscriber: Subscriber<TData>): void {
- if (!this._observers.includes(subscriber)) return;
- pull(this._observers, subscriber);
- }
-
- getObservable(): Observable<TData> {
- return new Observable<TData>((observer) => {
- const f = (data: TData): void => {
- observer.next(data);
- };
- this.subscribe(f);
-
- return () => {
- this.unsubscribe(f);
- };
- });
- }
-
- getSyncStatusObservable(): Observable<boolean> {
- return this._syncingSubject.asObservable();
- }
-
- getDataWithSyncStatusObservable(): Observable<WithSyncStatus<TData>> {
- return combineLatest([
- this.getObservable(),
- this.getSyncStatusObservable(),
- ]).pipe(
- map(([data, syncing]) => ({
- ...data,
- syncing,
- }))
- );
- }
-
- get value(): TData | undefined {
- return this._current;
- }
-
- next(value: TData): void {
- this._current = value;
- this._observers.forEach((observer) => observer(value));
- }
-
- get isSyncing(): boolean {
- return this._syncPromise != null;
- }
-
- sync(): Promise<void> {
- if (this._syncPromise == null) {
- this._syncingSubject.next(true);
- this._syncPromise = this.config.sync().then(() => {
- this._syncingSubject.next(false);
- this._syncPromise = null;
- });
- }
-
- return this._syncPromise;
- }
-
- syncWithAction(
- syncAction: (line: DataLine<TData>) => Promise<void>
- ): Promise<void> {
- if (this._syncPromise == null) {
- this._syncingSubject.next(true);
- this._syncPromise = syncAction(this).then(() => {
- this._syncingSubject.next(false);
- this._syncPromise = null;
- });
- }
-
- return this._syncPromise;
- }
-
- get destroyable(): boolean {
- const customDestroyable = this.config?.destroyable;
-
- return (
- this._observers.length === 0 &&
- !this.isSyncing &&
- (customDestroyable != null ? customDestroyable(this._current) : true)
- );
- }
-}
-
-export class DataHub<TKey, TData> {
- private sync: (key: TKey, line: DataLine<TData>) => Promise<void>;
- private keyToString: (key: TKey) => string;
- private destroyable?: (key: TKey, value: TData | undefined) => boolean;
-
- private readonly subscriptionLineMap = new Map<string, DataLine<TData>>();
-
- private cleanTimerId = 0;
-
- // setup is called after creating line and if it returns a function as destroyer, then when the line is destroyed the destroyer will be called.
- constructor(config: {
- sync: (key: TKey, line: DataLine<TData>) => Promise<void>;
- keyToString?: (key: TKey) => string;
- destroyable?: (key: TKey, value: TData | undefined) => boolean;
- }) {
- this.sync = config.sync;
- this.keyToString =
- config.keyToString ??
- ((value): string => {
- if (typeof value === "string") return value;
- else
- throw new Error(
- "Default keyToString function only pass string value."
- );
- });
-
- this.destroyable = config.destroyable;
- }
-
- private cleanLines(): void {
- const toDelete: string[] = [];
- for (const [key, line] of this.subscriptionLineMap.entries()) {
- if (line.destroyable) {
- toDelete.push(key);
- }
- }
-
- if (toDelete.length === 0) return;
-
- for (const key of toDelete) {
- this.subscriptionLineMap.delete(key);
- }
-
- if (this.subscriptionLineMap.size === 0) {
- window.clearInterval(this.cleanTimerId);
- this.cleanTimerId = 0;
- }
- }
-
- private createLine(key: TKey, disableInitSync = false): DataLine<TData> {
- const keyString = this.keyToString(key);
- const { destroyable } = this;
- const newLine: DataLine<TData> = new DataLine<TData>({
- sync: () => this.sync(key, newLine),
- destroyable:
- destroyable != null ? (value) => destroyable(key, value) : undefined,
- disableInitSync: disableInitSync,
- });
- this.subscriptionLineMap.set(keyString, newLine);
- if (this.subscriptionLineMap.size === 1) {
- this.cleanTimerId = window.setInterval(this.cleanLines.bind(this), 20000);
- }
- return newLine;
- }
-
- getObservable(key: TKey): Observable<TData> {
- return this.getLineOrCreate(key).getObservable();
- }
-
- getSyncStatusObservable(key: TKey): Observable<boolean> {
- return this.getLineOrCreate(key).getSyncStatusObservable();
- }
-
- getDataWithSyncStatusObservable(
- key: TKey
- ): Observable<WithSyncStatus<TData>> {
- return this.getLineOrCreate(key).getDataWithSyncStatusObservable();
- }
-
- getLine(key: TKey): DataLine<TData> | null {
- const keyString = this.keyToString(key);
- return this.subscriptionLineMap.get(keyString) ?? null;
- }
-
- getLineOrCreate(key: TKey): DataLine<TData> {
- const keyString = this.keyToString(key);
- return this.subscriptionLineMap.get(keyString) ?? this.createLine(key);
- }
-
- getLineOrCreateWithoutInitSync(key: TKey): DataLine<TData> {
- const keyString = this.keyToString(key);
- return (
- this.subscriptionLineMap.get(keyString) ?? this.createLine(key, true)
- );
- }
-
- optionalInitLineWithSyncAction(
- key: TKey,
- syncAction: (line: DataLine<TData>) => Promise<void>
- ): Promise<void> {
- const optionalLine = this.getLine(key);
- if (optionalLine != null) return Promise.resolve();
- const line = this.createLine(key, true);
- return line.syncWithAction(syncAction);
- }
-}
diff --git a/FrontEnd/src/app/services/DataHub2.ts b/FrontEnd/src/app/services/DataHub2.ts
new file mode 100644
index 00000000..50ae919b
--- /dev/null
+++ b/FrontEnd/src/app/services/DataHub2.ts
@@ -0,0 +1,181 @@
+import { Observable } from "rxjs";
+
+export type DataStatus = "syncing" | "synced" | "offline";
+
+export function mergeDataStatus(statusList: DataStatus[]): DataStatus {
+ if (statusList.includes("offline")) {
+ return "offline";
+ } else if (statusList.includes("syncing")) {
+ return "syncing";
+ } else {
+ return "synced";
+ }
+}
+
+export type Subscriber<TData> = (data: TData) => void;
+
+export interface DataAndStatus<TData> {
+ data: TData | null;
+ status: DataStatus;
+}
+
+export class DataLine2<TData> {
+ constructor(
+ private config: {
+ saveData: (data: TData) => Promise<void>;
+ getSavedData: () => Promise<TData | null>;
+ // return null for offline
+ fetchData: (savedData: TData | null) => Promise<TData | null>;
+ }
+ ) {}
+
+ private _current: DataAndStatus<TData> | null = null;
+ private _observers: Subscriber<DataAndStatus<TData>>[] = [];
+
+ get currentData(): DataAndStatus<TData> | null {
+ return this._current;
+ }
+
+ get isDestroyable(): boolean {
+ const { _observers, currentData } = this;
+ return (
+ _observers.length === 0 &&
+ (currentData == null || currentData.status !== "syncing")
+ );
+ }
+
+ private next(data: DataAndStatus<TData>): void {
+ this._current = data;
+ this._observers.forEach((o) => o(data));
+ }
+
+ subscribe(subsriber: Subscriber<DataAndStatus<TData>>): void {
+ this.sync(); // TODO: Should I sync at this point or let the user sync explicitly.
+ this._observers.push(subsriber);
+ const { currentData } = this;
+ if (currentData != null) {
+ subsriber(currentData);
+ }
+ }
+
+ unsubscribe(subsriber: Subscriber<DataAndStatus<TData>>): void {
+ const index = this._observers.indexOf(subsriber);
+ if (index > -1) this._observers.splice(index, 1);
+ }
+
+ getObservalble(): Observable<DataAndStatus<TData>> {
+ return new Observable<DataAndStatus<TData>>((observer) => {
+ const f = (data: DataAndStatus<TData>): void => {
+ observer.next(data);
+ };
+ this.subscribe(f);
+
+ return () => {
+ this.unsubscribe(f);
+ };
+ });
+ }
+
+ sync(): void {
+ const { currentData } = this;
+ if (currentData != null && currentData.status === "syncing") return;
+ this.next({ data: currentData?.data ?? null, status: "syncing" });
+ void this.config.getSavedData().then((savedData) => {
+ if (currentData == null && savedData != null) {
+ this.next({ data: savedData, status: "syncing" });
+ }
+ return this.config.fetchData(savedData).then((data) => {
+ if (data == null) {
+ this.next({
+ data: savedData,
+ status: "offline",
+ });
+ } else {
+ return this.config.saveData(data).then(() => {
+ this.next({ data: data, status: "synced" });
+ });
+ }
+ });
+ });
+ }
+
+ save(data: TData): void {
+ const { currentData } = this;
+ if (currentData != null && currentData.status === "syncing") return;
+ this.next({ data: currentData?.data ?? null, status: "syncing" });
+ void this.config.saveData(data).then(() => {
+ this.next({ data: data, status: "synced" });
+ });
+ }
+
+ getSavedData(): Promise<TData | null> {
+ return this.config.getSavedData();
+ }
+}
+
+export class DataHub2<TKey, TData> {
+ private readonly subscriptionLineMap = new Map<string, DataLine2<TData>>();
+
+ private keyToString: (key: TKey) => string;
+
+ private cleanTimerId = 0;
+
+ // setup is called after creating line and if it returns a function as destroyer, then when the line is destroyed the destroyer will be called.
+ constructor(
+ private config: {
+ saveData: (key: TKey, data: TData) => Promise<void>;
+ getSavedData: (key: TKey) => Promise<TData | null>;
+ fetchData: (key: TKey, savedData: TData | null) => Promise<TData | null>;
+ keyToString?: (key: TKey) => string;
+ }
+ ) {
+ this.keyToString =
+ config.keyToString ??
+ ((value): string => {
+ if (typeof value === "string") return value;
+ else
+ throw new Error(
+ "Default keyToString function only pass string value."
+ );
+ });
+ }
+
+ private cleanLines(): void {
+ const toDelete: string[] = [];
+ for (const [key, line] of this.subscriptionLineMap.entries()) {
+ if (line.isDestroyable) {
+ toDelete.push(key);
+ }
+ }
+
+ if (toDelete.length === 0) return;
+
+ for (const key of toDelete) {
+ this.subscriptionLineMap.delete(key);
+ }
+
+ if (this.subscriptionLineMap.size === 0) {
+ window.clearInterval(this.cleanTimerId);
+ this.cleanTimerId = 0;
+ }
+ }
+
+ private createLine(key: TKey): DataLine2<TData> {
+ const keyString = this.keyToString(key);
+ const newLine: DataLine2<TData> = new DataLine2<TData>({
+ saveData: (data) => this.config.saveData(key, data),
+ getSavedData: () => this.config.getSavedData(key),
+ fetchData: (savedData) => this.config.fetchData(key, savedData),
+ });
+ this.subscriptionLineMap.set(keyString, newLine);
+ if (this.subscriptionLineMap.size === 1) {
+ this.cleanTimerId = window.setInterval(this.cleanLines.bind(this), 20000);
+ }
+ return newLine;
+ }
+
+ getLine(key: TKey): DataLine2<TData> {
+ const keyString = this.keyToString(key);
+ return this.subscriptionLineMap.get(keyString) ?? this.createLine(key);
+ }
+}
diff --git a/FrontEnd/src/app/services/common.ts b/FrontEnd/src/app/services/common.ts
index 3bb6b9d7..9208737b 100644
--- a/FrontEnd/src/app/services/common.ts
+++ b/FrontEnd/src/app/services/common.ts
@@ -1,6 +1,6 @@
import localforage from "localforage";
-import { HttpNetworkError } from "@/http/common";
+const dataVersion = 1;
export const dataStorage = localforage.createInstance({
name: "data",
@@ -8,16 +8,17 @@ export const dataStorage = localforage.createInstance({
driver: localforage.INDEXEDDB,
});
+void (async () => {
+ const currentVersion = await dataStorage.getItem<number | null>("version");
+ if (currentVersion !== dataVersion) {
+ console.log("Data storage version has changed. Clear all data.");
+ await dataStorage.clear();
+ await dataStorage.setItem("version", dataVersion);
+ }
+})();
+
export class ForbiddenError extends Error {
constructor(message?: string) {
super(message);
}
}
-
-export function throwIfNotNetworkError(e: unknown): void {
- if (!(e instanceof HttpNetworkError)) {
- throw e;
- }
-}
-
-export type BlobOrStatus = Blob | "loading" | "error";
diff --git a/FrontEnd/src/app/services/timeline.ts b/FrontEnd/src/app/services/timeline.ts
index c58516fc..8bc1d40b 100644
--- a/FrontEnd/src/app/services/timeline.ts
+++ b/FrontEnd/src/app/services/timeline.ts
@@ -1,8 +1,6 @@
import React from "react";
import XRegExp from "xregexp";
-import { Observable, from, combineLatest, of } from "rxjs";
-import { map, switchMap, startWith, filter } from "rxjs/operators";
-import { uniqBy } from "lodash";
+import { Observable, from } from "rxjs";
import { convertError } from "@/utilities/rxjs";
import {
@@ -19,22 +17,15 @@ import {
HttpTimelineNotExistError,
HttpTimelineNameConflictError,
} from "@/http/timeline";
-import { BlobWithEtag, NotModified, HttpForbiddenError } from "@/http/common";
-import { HttpUser } from "@/http/user";
+import { HttpForbiddenError, HttpNetworkError } from "@/http/common";
export { kTimelineVisibilities } from "@/http/timeline";
export type { TimelineVisibility } from "@/http/timeline";
-import { dataStorage, throwIfNotNetworkError, BlobOrStatus } from "./common";
-import { DataHub, WithSyncStatus } from "./DataHub";
-import {
- checkLogin,
- userService,
- userInfoService,
- User,
- AuthUser,
-} from "./user";
+import { dataStorage } from "./common";
+import { userInfoService, AuthUser } from "./user";
+import { DataAndStatus, DataHub2 } from "./DataHub2";
export type TimelineInfo = HttpTimelineInfo;
export type TimelineChangePropertyRequest = HttpTimelinePatchRequest;
@@ -47,19 +38,21 @@ export type TimelinePostTextContent = HttpTimelinePostTextContent;
export interface TimelinePostImageContent {
type: "image";
- data: BlobOrStatus;
+ data: Blob;
+ etag: string;
}
export type TimelinePostContent =
| TimelinePostTextContent
| TimelinePostImageContent;
-export interface TimelinePostInfo {
- id: number;
+export type TimelinePostInfo = Omit<HttpTimelinePostInfo, "content"> & {
content: TimelinePostContent;
- time: Date;
+};
+
+export interface TimelinePostsInfo {
lastUpdated: Date;
- author: HttpUser;
+ posts: TimelinePostInfo[];
}
export const timelineVisibilityTooltipTranslationMap: Record<
@@ -71,55 +64,23 @@ export const timelineVisibilityTooltipTranslationMap: Record<
Private: "timeline.visibilityTooltip.private",
};
-export class TimelineNotExistError extends Error {}
export class TimelineNameConflictError extends Error {}
-export type TimelineWithSyncStatus = WithSyncStatus<
- | {
- type: "cache";
- timeline: TimelineInfo;
- }
- | {
- type: "offline" | "synced";
- timeline: TimelineInfo | null;
- }
->;
-
-export type TimelinePostsWithSyncState = WithSyncStatus<{
- type:
- | "cache"
- | "offline" // Sync failed and use cache.
- | "synced" // Sync succeeded.
- | "forbid" // The list is forbidden to see.
- | "notexist"; // The timeline does not exist.
- posts: TimelinePostInfo[];
-}>;
-
type TimelineData = Omit<HttpTimelineInfo, "owner" | "members"> & {
owner: string;
members: string[];
};
-type TimelinePostData = Omit<HttpTimelinePostInfo, "author"> & {
+type TimelinePostData = Omit<TimelinePostInfo, "author"> & {
author: string;
};
-export class TimelineService {
- private getCachedTimeline(
- timelineName: string
- ): Promise<TimelineData | null> {
- return dataStorage.getItem<TimelineData | null>(`timeline.${timelineName}`);
- }
-
- private saveTimeline(
- timelineName: string,
- data: TimelineData
- ): Promise<void> {
- return dataStorage
- .setItem<TimelineData>(`timeline.${timelineName}`, data)
- .then();
- }
+interface TimelinePostsData {
+ lastUpdated: Date;
+ posts: TimelinePostData[];
+}
+export class TimelineService {
private async clearTimelineData(timelineName: string): Promise<void> {
const keys = (await dataStorage.keys()).filter((k) =>
k.startsWith(`timeline.${timelineName}`)
@@ -127,6 +88,10 @@ export class TimelineService {
await Promise.all(keys.map((k) => dataStorage.removeItem(k)));
}
+ private generateTimelineDataStorageKey(timelineName: string): string {
+ return `timeline.${timelineName}`;
+ }
+
private convertHttpTimelineToData(timeline: HttpTimelineInfo): TimelineData {
return {
...timeline,
@@ -135,106 +100,72 @@ export class TimelineService {
};
}
- private _timelineHub = new DataHub<
- string,
- | {
- type: "cache";
- timeline: TimelineData;
- }
- | {
- type: "offline" | "synced";
- timeline: TimelineData | null;
- }
- >({
- sync: async (key, line) => {
- const cache = await this.getCachedTimeline(key);
+ readonly timelineHub = new DataHub2<string, HttpTimelineInfo | "notexist">({
+ saveData: async (timelineName, data) => {
+ if (data === "notexist") return;
- if (line.value == undefined) {
- if (cache != null) {
- line.next({ type: "cache", timeline: cache });
- }
- }
+ userInfoService.saveUser(data.owner);
+ userInfoService.saveUsers(data.members);
- try {
- const httpTimeline = await getHttpTimelineClient().getTimeline(key);
+ await dataStorage.setItem<TimelineData>(
+ this.generateTimelineDataStorageKey(timelineName),
+ this.convertHttpTimelineToData(data)
+ );
+ },
+ getSavedData: async (timelineName) => {
+ const savedData = await dataStorage.getItem<TimelineData | null>(
+ this.generateTimelineDataStorageKey(timelineName)
+ );
+
+ if (savedData == null) return null;
- userInfoService.saveUsers([
- httpTimeline.owner,
- ...httpTimeline.members,
- ]);
+ const owner = await userInfoService.getCachedUser(savedData.owner);
+ if (owner == null) return null;
+ const members = await userInfoService.getCachedUsers(savedData.members);
+ if (members == null) return null;
- const timeline = this.convertHttpTimelineToData(httpTimeline);
+ return { ...savedData, owner, members };
+ },
+ fetchData: async (timelineName, savedData) => {
+ try {
+ const timeline = await getHttpTimelineClient().getTimeline(
+ timelineName
+ );
- if (cache != null && timeline.uniqueId !== cache.uniqueId) {
+ if (
+ savedData != null &&
+ savedData !== "notexist" &&
+ savedData.uniqueId !== timeline.uniqueId
+ ) {
console.log(
- `Timeline with name ${key} has changed to a new one. Clear old data.`
+ `Timeline with name ${timelineName} has changed to a new one. Clear old data.`
);
- await this.clearTimelineData(key); // If timeline has changed, clear all old data.
- }
- await this.saveTimeline(key, timeline);
+ void this.clearTimelineData(timelineName); // If timeline has changed, clear all old data.
+ }
- line.next({ type: "synced", timeline });
+ return timeline;
} catch (e) {
if (e instanceof HttpTimelineNotExistError) {
- line.next({ type: "synced", timeline: null });
+ return "notexist";
+ } else if (e instanceof HttpNetworkError) {
+ return null;
} else {
- if (cache == null) {
- line.next({ type: "offline", timeline: null });
- } else {
- line.next({ type: "offline", timeline: cache });
- }
- throwIfNotNetworkError(e);
+ throw e;
}
}
},
});
- syncTimeline(timelineName: string): Promise<void> {
- return this._timelineHub.getLineOrCreate(timelineName).sync();
- }
-
- getTimeline$(timelineName: string): Observable<TimelineWithSyncStatus> {
- return this._timelineHub.getDataWithSyncStatusObservable(timelineName).pipe(
- switchMap((state) => {
- const { timeline } = state;
- if (timeline != null) {
- return combineLatest(
- [timeline.owner, ...timeline.members].map((u) =>
- state.type === "cache"
- ? from(userInfoService.getCachedUser(u)).pipe(
- filter((u): u is User => u != null)
- )
- : userInfoService.getUser$(u)
- )
- ).pipe(
- map((users) => {
- return {
- ...state,
- timeline: {
- ...timeline,
- owner: users[0],
- members: users.slice(1),
- },
- };
- })
- );
- } else {
- return of(state as TimelineWithSyncStatus);
- }
- })
- );
+ syncTimeline(timelineName: string): void {
+ this.timelineHub.getLine(timelineName).sync();
}
createTimeline(timelineName: string): Observable<TimelineInfo> {
- const user = checkLogin();
return from(
- getHttpTimelineClient().postTimeline(
- {
- name: timelineName,
- },
- user.token
- )
+ getHttpTimelineClient().postTimeline({
+ name: timelineName,
+ })
).pipe(
convertError(HttpTimelineNameConflictError, TimelineNameConflictError)
);
@@ -244,10 +175,9 @@ export class TimelineService {
timelineName: string,
req: TimelineChangePropertyRequest
): Observable<TimelineInfo> {
- const user = checkLogin();
return from(
getHttpTimelineClient()
- .patchTimeline(timelineName, req, user.token)
+ .patchTimeline(timelineName, req)
.then((timeline) => {
void this.syncTimeline(timelineName);
return timeline;
@@ -256,117 +186,119 @@ export class TimelineService {
}
deleteTimeline(timelineName: string): Observable<unknown> {
- const user = checkLogin();
- return from(
- getHttpTimelineClient().deleteTimeline(timelineName, user.token)
- );
+ return from(getHttpTimelineClient().deleteTimeline(timelineName));
}
- addMember(timelineName: string, username: string): Observable<unknown> {
- const user = checkLogin();
- return from(
- getHttpTimelineClient()
- .memberPut(timelineName, username, user.token)
- .then(() => {
- void this.syncTimeline(timelineName);
- })
- );
+ addMember(timelineName: string, username: string): Promise<void> {
+ return getHttpTimelineClient()
+ .memberPut(timelineName, username)
+ .then(() => {
+ void this.syncTimeline(timelineName);
+ });
}
- removeMember(timelineName: string, username: string): Observable<unknown> {
- const user = checkLogin();
- return from(
- getHttpTimelineClient()
- .memberDelete(timelineName, username, user.token)
- .then(() => {
- void this.syncTimeline(timelineName);
- })
- );
+ removeMember(timelineName: string, username: string): Promise<void> {
+ return getHttpTimelineClient()
+ .memberDelete(timelineName, username)
+ .then(() => {
+ void this.syncTimeline(timelineName);
+ });
}
- private convertHttpPostToData(post: HttpTimelinePostInfo): TimelinePostData {
- return {
- ...post,
- author: post.author.username,
- };
+ private generatePostsDataStorageKey(timelineName: string): string {
+ return `timeline.${timelineName}.posts`;
}
- private convertHttpPostToDataList(
- posts: HttpTimelinePostInfo[]
- ): TimelinePostData[] {
- return posts.map((post) => this.convertHttpPostToData(post));
- }
+ readonly postsHub = new DataHub2<
+ string,
+ TimelinePostsInfo | "notexist" | "forbid"
+ >({
+ saveData: async (timelineName, data) => {
+ if (data === "notexist" || data === "forbid") return;
- private getCachedPosts(
- timelineName: string
- ): Promise<TimelinePostData[] | null> {
- return dataStorage.getItem<TimelinePostData[] | null>(
- `timeline.${timelineName}.posts`
- );
- }
+ const savedData: TimelinePostsData = {
+ ...data,
+ posts: data.posts.map((p) => ({ ...p, author: p.author.username })),
+ };
- private savePosts(
- timelineName: string,
- data: TimelinePostData[]
- ): Promise<void> {
- return dataStorage
- .setItem<TimelinePostData[]>(`timeline.${timelineName}.posts`, data)
- .then();
- }
+ data.posts.forEach((p) => {
+ userInfoService.saveUser(p.author);
+ });
- private syncPosts(timelineName: string): Promise<void> {
- return this._postsHub.getLineOrCreate(timelineName).sync();
- }
+ await dataStorage.setItem<TimelinePostsData>(
+ this.generatePostsDataStorageKey(timelineName),
+ savedData
+ );
+ },
+ getSavedData: async (timelineName) => {
+ const savedData = await dataStorage.getItem<TimelinePostsData | null>(
+ this.generatePostsDataStorageKey(timelineName)
+ );
+ if (savedData == null) return null;
- private _postsHub = new DataHub<
- string,
- {
- type: "cache" | "offline" | "synced" | "forbid" | "notexist";
- posts: TimelinePostData[];
- }
- >({
- sync: async (key, line) => {
- // Wait for timeline synced. In case the timeline has changed to another and old data has been cleaned.
- await this.syncTimeline(key);
-
- if (line.value == null) {
- const cache = await this.getCachedPosts(key);
- if (cache != null) {
- line.next({ type: "cache", posts: cache });
+ const authors = await userInfoService.getCachedUsers(
+ savedData.posts.map((p) => p.author)
+ );
+
+ if (authors == null) return null;
+
+ return {
+ ...savedData,
+ posts: savedData.posts.map((p, index) => ({
+ ...p,
+ author: authors[index],
+ })),
+ };
+ },
+ fetchData: async (timelineName, savedData) => {
+ const convert = async (
+ post: HttpTimelinePostInfo
+ ): Promise<TimelinePostInfo> => {
+ const { content } = post;
+ if (content.type === "text") {
+ return { ...post, content };
+ } else {
+ const data = await getHttpTimelineClient().getPostData(
+ timelineName,
+ post.id
+ );
+ return {
+ ...post,
+ content: {
+ type: "image",
+ data: data.data,
+ etag: data.etag,
+ },
+ };
}
- }
+ };
- const now = new Date();
+ const convertList = (
+ posts: HttpTimelinePostInfo[]
+ ): Promise<TimelinePostInfo[]> =>
+ Promise.all(posts.map((p) => convert(p)));
- const lastUpdatedTime = await dataStorage.getItem<Date | null>(
- `timeline.${key}.lastUpdated`
- );
+ const now = new Date();
try {
- if (lastUpdatedTime == null) {
+ if (
+ savedData == null ||
+ savedData === "forbid" ||
+ savedData === "notexist"
+ ) {
const httpPosts = await getHttpTimelineClient().listPost(
- key,
- userService.currentUser?.token
- );
-
- userInfoService.saveUsers(
- uniqBy(
- httpPosts.map((post) => post.author),
- "username"
- )
+ timelineName
);
- const posts = this.convertHttpPostToDataList(httpPosts);
- await this.savePosts(key, posts);
- await dataStorage.setItem<Date>(`timeline.${key}.lastUpdated`, now);
-
- line.next({ type: "synced", posts });
+ return {
+ lastUpdated: now,
+ posts: await convertList(httpPosts),
+ };
} else {
const httpPosts = await getHttpTimelineClient().listPost(
- key,
- userService.currentUser?.token,
+ timelineName,
{
- modifiedSince: lastUpdatedTime,
+ modifiedSince: savedData.lastUpdated,
includeDeleted: true,
}
);
@@ -374,231 +306,65 @@ export class TimelineService {
const deletedIds = httpPosts
.filter((p) => p.deleted)
.map((p) => p.id);
- const changed = httpPosts.filter(
- (p): p is HttpTimelinePostInfo => !p.deleted
- );
- userInfoService.saveUsers(
- uniqBy(
- httpPosts
- .map((post) => post.author)
- .filter((u): u is HttpUser => u != null),
- "username"
- )
+ const changed = await convertList(
+ httpPosts.filter((p): p is HttpTimelinePostInfo => !p.deleted)
);
- const cache = (await this.getCachedPosts(key)) ?? [];
-
- const posts = cache.filter((p) => !deletedIds.includes(p.id));
+ const posts = savedData.posts.filter(
+ (p) => !deletedIds.includes(p.id)
+ );
for (const changedPost of changed) {
const savedChangedPostIndex = posts.findIndex(
(p) => p.id === changedPost.id
);
if (savedChangedPostIndex === -1) {
- posts.push(this.convertHttpPostToData(changedPost));
+ posts.push(await convert(changedPost));
} else {
- posts[savedChangedPostIndex] = this.convertHttpPostToData(
- changedPost
- );
+ posts[savedChangedPostIndex] = await convert(changedPost);
}
}
- await this.savePosts(key, posts);
- await dataStorage.setItem<Date>(`timeline.${key}.lastUpdated`, now);
- line.next({ type: "synced", posts });
+ return { lastUpdated: now, posts };
}
} catch (e) {
if (e instanceof HttpTimelineNotExistError) {
- line.next({ type: "notexist", posts: [] });
+ return "notexist";
} else if (e instanceof HttpForbiddenError) {
- line.next({ type: "forbid", posts: [] });
+ return "forbid";
+ } else if (e instanceof HttpNetworkError) {
+ return null;
} else {
- const cache = await this.getCachedPosts(key);
- if (cache == null) {
- line.next({ type: "offline", posts: [] });
- } else {
- line.next({ type: "offline", posts: cache });
- }
- throwIfNotNetworkError(e);
- }
- }
- },
- });
-
- getPosts$(timelineName: string): Observable<TimelinePostsWithSyncState> {
- return this._postsHub.getDataWithSyncStatusObservable(timelineName).pipe(
- switchMap((state) => {
- if (state.posts.length === 0) {
- return of({
- ...state,
- posts: [],
- });
- }
-
- return combineLatest([
- combineLatest(
- state.posts.map((post) =>
- state.type === "cache"
- ? from(userInfoService.getCachedUser(post.author)).pipe(
- filter((u): u is User => u != null)
- )
- : userInfoService.getUser$(post.author)
- )
- ),
- combineLatest(
- state.posts.map((post) => {
- if (post.content.type === "image") {
- return state.type === "cache"
- ? from(this.getCachedPostData(timelineName, post.id))
- : this.getPostData$(timelineName, post.id);
- } else {
- return of(null);
- }
- })
- ),
- ]).pipe(
- map(([authors, datas]) => {
- return {
- ...state,
- posts: state.posts.map((post, i) => {
- const { content } = post;
-
- return {
- ...post,
- author: authors[i],
- content: (() => {
- if (content.type === "text") return content;
- else
- return {
- type: "image",
- data: datas[i],
- } as TimelinePostImageContent;
- })(),
- };
- }),
- };
- })
- );
- })
- );
- }
-
- private _getCachedPostData(key: {
- timelineName: string;
- postId: number;
- }): Promise<BlobWithEtag | null> {
- return dataStorage.getItem<BlobWithEtag | null>(
- `timeline.${key.timelineName}.post.${key.postId}.data`
- );
- }
-
- private savePostData(
- key: {
- timelineName: string;
- postId: number;
- },
- data: BlobWithEtag
- ): Promise<void> {
- return dataStorage
- .setItem<BlobWithEtag>(
- `timeline.${key.timelineName}.post.${key.postId}.data`,
- data
- )
- .then();
- }
-
- private syncPostData(key: {
- timelineName: string;
- postId: number;
- }): Promise<void> {
- return this._postDataHub.getLineOrCreate(key).sync();
- }
-
- private _postDataHub = new DataHub<
- { timelineName: string; postId: number },
- | { data: Blob; type: "cache" | "synced" | "offline" }
- | { data?: undefined; type: "notexist" | "offline" }
- >({
- keyToString: (key) => `${key.timelineName}.${key.postId}`,
- sync: async (key, line) => {
- const cache = await this._getCachedPostData(key);
- if (line.value == null) {
- if (cache != null) {
- line.next({ type: "cache", data: cache.data });
- }
- }
-
- if (cache == null) {
- try {
- const res = await getHttpTimelineClient().getPostData(
- key.timelineName,
- key.postId
- );
- await this.savePostData(key, res);
- line.next({ data: res.data, type: "synced" });
- } catch (e) {
- line.next({ type: "offline" });
- throwIfNotNetworkError(e);
- }
- } else {
- try {
- const res = await getHttpTimelineClient().getPostData(
- key.timelineName,
- key.postId,
- cache.etag
- );
- if (res instanceof NotModified) {
- line.next({ data: cache.data, type: "synced" });
- } else {
- await this.savePostData(key, res);
- line.next({ data: res.data, type: "synced" });
- }
- } catch (e) {
- line.next({ data: cache.data, type: "offline" });
- throwIfNotNetworkError(e);
+ throw e;
}
}
},
});
- getCachedPostData(
- timelineName: string,
- postId: number
- ): Promise<Blob | null> {
- return this._getCachedPostData({ timelineName, postId }).then(
- (d) => d?.data ?? null
- );
- }
-
- getPostData$(timelineName: string, postId: number): Observable<BlobOrStatus> {
- return this._postDataHub.getObservable({ timelineName, postId }).pipe(
- map((state): BlobOrStatus => state.data ?? "error"),
- startWith("loading")
- );
+ syncPosts(timelineName: string): void {
+ this.postsHub.getLine(timelineName).sync();
}
createPost(
timelineName: string,
request: TimelineCreatePostRequest
): Observable<unknown> {
- const user = checkLogin();
return from(
getHttpTimelineClient()
- .postPost(timelineName, request, user.token)
+ .postPost(timelineName, request)
.then(() => {
- void this.syncPosts(timelineName);
+ this.syncPosts(timelineName);
})
);
}
deletePost(timelineName: string, postId: number): Observable<unknown> {
- const user = checkLogin();
return from(
getHttpTimelineClient()
- .deletePost(timelineName, postId, user.token)
+ .deletePost(timelineName, postId)
.then(() => {
- void this.syncPosts(timelineName);
+ this.syncPosts(timelineName);
})
);
}
@@ -679,15 +445,19 @@ export function validateTimelineName(name: string): boolean {
return timelineNameReg.test(name);
}
-export function useTimelineInfo(
+export function useTimeline(
timelineName: string
-): TimelineWithSyncStatus | undefined {
- const [state, setState] = React.useState<TimelineWithSyncStatus | undefined>(
- undefined
- );
+): DataAndStatus<TimelineInfo | "notexist"> {
+ const [state, setState] = React.useState<
+ DataAndStatus<TimelineInfo | "notexist">
+ >({
+ status: "syncing",
+ data: null,
+ });
React.useEffect(() => {
- const subscription = timelineService
- .getTimeline$(timelineName)
+ const subscription = timelineService.timelineHub
+ .getLine(timelineName)
+ .getObservalble()
.subscribe((data) => {
setState(data);
});
@@ -698,20 +468,16 @@ export function useTimelineInfo(
return state;
}
-export function usePostList(
- timelineName: string | null | undefined
-): TimelinePostsWithSyncState | undefined {
+export function usePosts(
+ timelineName: string
+): DataAndStatus<TimelinePostsInfo | "notexist" | "forbid"> {
const [state, setState] = React.useState<
- TimelinePostsWithSyncState | undefined
- >(undefined);
+ DataAndStatus<TimelinePostsInfo | "notexist" | "forbid">
+ >({ status: "syncing", data: null });
React.useEffect(() => {
- if (timelineName == null) {
- setState(undefined);
- return;
- }
-
- const subscription = timelineService
- .getPosts$(timelineName)
+ const subscription = timelineService.postsHub
+ .getLine(timelineName)
+ .getObservalble()
.subscribe((data) => {
setState(data);
});
diff --git a/FrontEnd/src/app/services/user.ts b/FrontEnd/src/app/services/user.ts
index 7a60b474..5c4e3ae0 100644
--- a/FrontEnd/src/app/services/user.ts
+++ b/FrontEnd/src/app/services/user.ts
@@ -1,11 +1,14 @@
import React, { useState, useEffect } from "react";
import { BehaviorSubject, Observable, from } from "rxjs";
-import { map, filter } from "rxjs/operators";
import { UiLogicError } from "@/common";
-import { convertError } from "@/utilities/rxjs";
-import { HttpNetworkError, BlobWithEtag, NotModified } from "@/http/common";
+import {
+ HttpNetworkError,
+ BlobWithEtag,
+ NotModified,
+ setHttpToken,
+} from "@/http/common";
import {
getHttpTokenClient,
HttpCreateTokenBadCredentialError,
@@ -17,8 +20,8 @@ import {
UserPermission,
} from "@/http/user";
-import { dataStorage, throwIfNotNetworkError } from "./common";
-import { DataHub } from "./DataHub";
+import { DataHub2 } from "./DataHub2";
+import { dataStorage } from "./common";
import { pushAlert } from "./alert";
export type User = HttpUser;
@@ -61,6 +64,12 @@ export class BadCredentialError {
const USER_STORAGE_KEY = "currentuser";
export class UserService {
+ constructor() {
+ this.userSubject.subscribe((u) => {
+ setHttpToken(u?.token ?? null);
+ });
+ }
+
private userSubject = new BehaviorSubject<AuthUser | null | undefined>(
undefined
);
@@ -167,13 +176,10 @@ export class UserService {
throw new UiLogicError("Not login or checked now, can't log out.");
}
const $ = from(
- getHttpUserClient().changePassword(
- {
- oldPassword,
- newPassword,
- },
- this.currentUser.token
- )
+ getHttpUserClient().changePassword({
+ oldPassword,
+ newPassword,
+ })
);
$.subscribe(() => {
void this.logout();
@@ -243,150 +249,114 @@ export class UserNotExistError extends Error {}
export class UserInfoService {
saveUser(user: HttpUser): void {
- const key = user.username;
- void this._userHub.optionalInitLineWithSyncAction(key, async (line) => {
- await this.doSaveUser(user);
- line.next({ user, type: "synced" });
- });
+ this.userHub.getLine(user.username).save(user);
}
saveUsers(users: HttpUser[]): void {
return users.forEach((user) => this.saveUser(user));
}
- private _getCachedUser(username: string): Promise<User | null> {
- return dataStorage.getItem<HttpUser | null>(`user.${username}`);
+ async getCachedUser(username: string): Promise<HttpUser | null> {
+ const user = await this.userHub.getLine(username).getSavedData();
+ if (user == null || user === "notexist") return null;
+ return user;
}
- private doSaveUser(user: HttpUser): Promise<void> {
- return dataStorage.setItem<HttpUser>(`user.${user.username}`, user).then();
- }
+ async getCachedUsers(usernames: string[]): Promise<HttpUser[] | null> {
+ const users = await Promise.all(
+ usernames.map((username) => this.userHub.getLine(username).getSavedData())
+ );
- getCachedUser(username: string): Promise<User | null> {
- return this._getCachedUser(username);
- }
+ for (const u of users) {
+ if (u == null || u === "notexist") {
+ return null;
+ }
+ }
- syncUser(username: string): Promise<void> {
- return this._userHub.getLineOrCreate(username).sync();
+ return users as HttpUser[];
}
- private _userHub = new DataHub<
- string,
- | { user: User; type: "cache" | "synced" | "offline" }
- | { user?: undefined; type: "notexist" | "offline" }
- >({
- sync: async (key, line) => {
- if (line.value == undefined) {
- const cache = await this._getCachedUser(key);
- if (cache != null) {
- line.next({ user: cache, type: "cache" });
- }
- }
+ private generateUserDataStorageKey(username: string): string {
+ return `user.${username}`;
+ }
+ readonly userHub = new DataHub2<string, HttpUser | "notexist">({
+ saveData: (username, data) => {
+ if (typeof data === "string") return Promise.resolve();
+ return dataStorage
+ .setItem<HttpUser>(this.generateUserDataStorageKey(username), data)
+ .then();
+ },
+ getSavedData: (username) => {
+ return dataStorage.getItem<HttpUser | null>(
+ this.generateUserDataStorageKey(username)
+ );
+ },
+ fetchData: async (username) => {
try {
- const res = await getHttpUserClient().get(key);
- await this.doSaveUser(res);
- line.next({ user: res, type: "synced" });
+ return await getHttpUserClient().get(username);
} catch (e) {
if (e instanceof HttpUserNotExistError) {
- line.next({ type: "notexist" });
- } else {
- const cache = await this._getCachedUser(key);
- line.next({ user: cache ?? undefined, type: "offline" });
- throwIfNotNetworkError(e);
+ return "notexist";
+ } else if (e instanceof HttpNetworkError) {
+ return null;
}
+ throw e;
}
},
});
- getUser$(username: string): Observable<User> {
- return this._userHub.getObservable(username).pipe(
- map((state) => state?.user),
- filter((user): user is User => user != null)
- );
- }
-
- private _getCachedAvatar(username: string): Promise<BlobWithEtag | null> {
- return dataStorage.getItem<BlobWithEtag | null>(`user.${username}.avatar`);
- }
-
- private saveAvatar(username: string, data: BlobWithEtag): Promise<void> {
- return dataStorage
- .setItem<BlobWithEtag>(`user.${username}.avatar`, data)
- .then();
- }
-
- getCachedAvatar(username: string): Promise<Blob | null> {
- return this._getCachedAvatar(username).then((d) => d?.data ?? null);
- }
-
- syncAvatar(username: string): Promise<void> {
- return this._avatarHub.getLineOrCreate(username).sync();
+ private generateAvatarDataStorageKey(username: string): string {
+ return `user.${username}.avatar`;
}
- private _avatarHub = new DataHub<
- string,
- | { data: Blob; type: "cache" | "synced" | "offline" }
- | { data?: undefined; type: "notexist" | "offline" }
- >({
- sync: async (key, line) => {
- const cache = await this._getCachedAvatar(key);
- if (line.value == null) {
- if (cache != null) {
- line.next({ data: cache.data, type: "cache" });
- }
- }
-
- if (cache == null) {
- try {
- const avatar = await getHttpUserClient().getAvatar(key);
- await this.saveAvatar(key, avatar);
- line.next({ data: avatar.data, type: "synced" });
- } catch (e) {
- line.next({ type: "offline" });
- throwIfNotNetworkError(e);
- }
- } else {
- try {
- const res = await getHttpUserClient().getAvatar(key, cache.etag);
+ readonly avatarHub = new DataHub2<string, BlobWithEtag | "notexist">({
+ saveData: async (username, data) => {
+ if (typeof data === "string") return;
+ await dataStorage.setItem<BlobWithEtag>(
+ this.generateAvatarDataStorageKey(username),
+ data
+ );
+ },
+ getSavedData: (username) =>
+ dataStorage.getItem<BlobWithEtag | null>(
+ this.generateAvatarDataStorageKey(username)
+ ),
+ fetchData: async (username, savedData) => {
+ try {
+ if (savedData == null || savedData === "notexist") {
+ return await getHttpUserClient().getAvatar(username);
+ } else {
+ const res = await getHttpUserClient().getAvatar(
+ username,
+ savedData.etag
+ );
if (res instanceof NotModified) {
- line.next({ data: cache.data, type: "synced" });
+ return savedData;
} else {
- const avatar = res;
- await this.saveAvatar(key, avatar);
- line.next({ data: avatar.data, type: "synced" });
+ return res;
}
- } catch (e) {
- line.next({ data: cache.data, type: "offline" });
- throwIfNotNetworkError(e);
+ }
+ } catch (e) {
+ if (e instanceof HttpUserNotExistError) {
+ return "notexist";
+ } else if (e instanceof HttpNetworkError) {
+ return null;
+ } else {
+ throw e;
}
}
},
});
- getAvatar$(username: string): Observable<Blob> {
- return this._avatarHub.getObservable(username).pipe(
- map((state) => state.data),
- filter((blob): blob is Blob => blob != null)
- );
- }
-
- getUserInfo(username: string): Observable<User> {
- return from(getHttpUserClient().get(username)).pipe(
- convertError(HttpUserNotExistError, UserNotExistError)
- );
- }
-
async setAvatar(username: string, blob: Blob): Promise<void> {
- const user = checkLogin();
- await getHttpUserClient().putAvatar(username, blob, user.token);
- this._avatarHub.getLine(username)?.next({ data: blob, type: "synced" });
+ const etag = await getHttpUserClient().putAvatar(username, blob);
+ this.avatarHub.getLine(username).save({ data: blob, etag });
}
async setNickname(username: string, nickname: string): Promise<void> {
- const user = checkLogin();
return getHttpUserClient()
- .patch(username, { nickname }, user.token)
+ .patch(username, { nickname })
.then((user) => {
this.saveUser(user);
});
@@ -403,14 +373,21 @@ export function useAvatar(username?: string): Blob | undefined {
return;
}
- const subscription = userInfoService
- .getAvatar$(username)
- .subscribe((blob) => {
- setState(blob);
+ const subscription = userInfoService.avatarHub
+ .getLine(username)
+ .getObservalble()
+ .subscribe((data) => {
+ if (data.data != null && data.data !== "notexist") {
+ setState(data.data.data);
+ } else {
+ setState(undefined);
+ }
});
+
return () => {
subscription.unsubscribe();
};
}, [username]);
+
return state;
}
diff --git a/FrontEnd/src/app/utilities/url.ts b/FrontEnd/src/app/utilities/url.ts
index 17ead5b2..21ad6304 100644
--- a/FrontEnd/src/app/utilities/url.ts
+++ b/FrontEnd/src/app/utilities/url.ts
@@ -1,52 +1,16 @@
-//copied from https://stackoverflow.com/questions/5999118/how-can-i-add-or-update-a-query-string-parameter
-export function updateQueryString(
- key: string,
- value: undefined | string | null,
- url: string
-): string {
- const re = new RegExp("([?&])" + key + "=.*?(&|#|$)(.*)", "gi");
- let hash;
-
- if (re.test(url)) {
- if (typeof value !== "undefined" && value !== null) {
- return url.replace(re, "$1" + key + "=" + value + "$2$3");
- } else {
- hash = url.split("#");
- url = hash[0].replace(re, "$1$3").replace(/(&|\?)$/, "");
- if (typeof hash[1] !== "undefined" && hash[1] !== null) {
- url += "#" + hash[1];
- }
- return url;
- }
- } else {
- if (typeof value !== "undefined" && value !== null) {
- const separator = url.includes("?") ? "&" : "?";
- hash = url.split("#");
- url = hash[0] + separator + key + "=" + value;
- if (typeof hash[1] !== "undefined" && hash[1] !== null) {
- url += "#" + hash[1];
- }
- return url;
- } else {
- return url;
- }
- }
-}
-
export function applyQueryParameters<T>(url: string, query: T): string {
if (query == null) return url;
+ const params = new URLSearchParams();
+
for (const [key, value] of Object.entries(query)) {
- if (typeof value === "string") url = updateQueryString(key, value, url);
- else if (typeof value === "number")
- url = updateQueryString(key, String(value), url);
- else if (typeof value === "boolean")
- url = updateQueryString(key, value ? "true" : "false", url);
- else if (value instanceof Date)
- url = updateQueryString(key, value.toISOString(), url);
+ if (typeof value === "string") params.set(key, value);
+ else if (typeof value === "number") params.set(key, String(value));
+ else if (typeof value === "boolean") params.set(key, String(value));
+ else if (value instanceof Date) params.set(key, value.toISOString());
else {
console.error("Unknown query parameter type. Param: ", value);
}
}
- return url;
+ return url + "?" + params.toString();
}
diff --git a/FrontEnd/src/app/views/admin/UserAdmin.tsx b/FrontEnd/src/app/views/admin/UserAdmin.tsx
index d66abbec..fbdfd5a3 100644
--- a/FrontEnd/src/app/views/admin/UserAdmin.tsx
+++ b/FrontEnd/src/app/views/admin/UserAdmin.tsx
@@ -62,7 +62,7 @@ const UsernameLabel: React.FC = (props) => {
const UserDeleteDialog: React.FC<
DialogProps<{ username: string }, unknown>
-> = ({ open, close, token, data: { username }, onSuccess }) => {
+> = ({ open, close, data: { username }, onSuccess }) => {
return (
<OperationDialog
open={open}
@@ -74,7 +74,7 @@ const UserDeleteDialog: React.FC<
0<UsernameLabel>{username}</UsernameLabel>2
</Trans>
)}
- onProcess={() => getHttpUserClient().delete(username, token)}
+ onProcess={() => getHttpUserClient().delete(username)}
onSuccessAndClose={onSuccess}
/>
);
@@ -87,7 +87,7 @@ const UserModifyDialog: React.FC<
},
HttpUser
>
-> = ({ open, close, token, data: { oldUser }, onSuccess }) => {
+> = ({ open, close, data: { oldUser }, onSuccess }) => {
return (
<OperationDialog
open={open}
@@ -115,15 +115,11 @@ const UserModifyDialog: React.FC<
] as const
}
onProcess={([username, password, nickname]) =>
- getHttpUserClient().patch(
- oldUser.username,
- {
- username: username !== oldUser.username ? username : undefined,
- password: password !== "" ? password : undefined,
- nickname: nickname !== oldUser.nickname ? nickname : undefined,
- },
- token
- )
+ getHttpUserClient().patch(oldUser.username, {
+ username: username !== oldUser.username ? username : undefined,
+ password: password !== "" ? password : undefined,
+ nickname: nickname !== oldUser.nickname ? nickname : undefined,
+ })
}
onSuccessAndClose={onSuccess}
/>
@@ -138,7 +134,7 @@ const UserPermissionModifyDialog: React.FC<
},
UserPermission[]
>
-> = ({ open, close, token, data: { username, permissions }, onSuccess }) => {
+> = ({ open, close, data: { username, permissions }, onSuccess }) => {
const oldPermissionBoolList: boolean[] = kUserPermissionList.map(
(permission) => permissions.includes(permission)
);
@@ -168,16 +164,11 @@ const UserPermissionModifyDialog: React.FC<
const permission = kUserPermissionList[index];
if (oldValue === newValue) continue;
if (newValue) {
- await getHttpUserClient().putUserPermission(
- username,
- permission,
- token
- );
+ await getHttpUserClient().putUserPermission(username, permission);
} else {
await getHttpUserClient().deleteUserPermission(
username,
- permission,
- token
+ permission
);
}
}
diff --git a/FrontEnd/src/app/views/home/BoardWithUser.tsx b/FrontEnd/src/app/views/home/BoardWithUser.tsx
index 8afe440b..ba22916c 100644
--- a/FrontEnd/src/app/views/home/BoardWithUser.tsx
+++ b/FrontEnd/src/app/views/home/BoardWithUser.tsx
@@ -20,11 +20,11 @@ const BoardWithUser: React.FC<{ user: AuthUser }> = ({ user }) => {
<Col xs="12" md="6">
<TimelineBoard
title={t("home.bookmarkTimeline")}
- load={() => getHttpBookmarkClient().list(user.token)}
+ load={() => getHttpBookmarkClient().list()}
editHandler={{
onDelete: (timeline) => {
return getHttpBookmarkClient()
- .delete(timeline, user.token)
+ .delete(timeline)
.catch((e) => {
pushAlert({
message: {
@@ -39,8 +39,7 @@ const BoardWithUser: React.FC<{ user: AuthUser }> = ({ user }) => {
onMove: (timeline, index, offset) => {
return getHttpBookmarkClient()
.move(
- { timeline, newPosition: index + offset + 1 }, // +1 because backend contract: index starts at 1
- user.token
+ { timeline, newPosition: index + offset + 1 } // +1 because backend contract: index starts at 1
)
.catch((e) => {
pushAlert({
@@ -75,7 +74,7 @@ const BoardWithUser: React.FC<{ user: AuthUser }> = ({ user }) => {
? {
onDelete: (timeline) => {
return getHttpHighlightClient()
- .delete(timeline, user.token)
+ .delete(timeline)
.catch((e) => {
pushAlert({
message: {
@@ -90,8 +89,7 @@ const BoardWithUser: React.FC<{ user: AuthUser }> = ({ user }) => {
onMove: (timeline, index, offset) => {
return getHttpHighlightClient()
.move(
- { timeline, newPosition: index + offset + 1 }, // +1 because backend contract: index starts at 1
- user.token
+ { timeline, newPosition: index + offset + 1 } // +1 because backend contract: index starts at 1
)
.catch((e) => {
pushAlert({
diff --git a/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx
index ece1942f..e62f76fa 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx
@@ -10,7 +10,11 @@ import SyncStatusBadge from "../timeline-common/SyncStatusBadge";
import CollapseButton from "../timeline-common/CollapseButton";
export interface TimelineCardTemplateProps
- extends Omit<TimelineCardComponentProps<"">, "onManage" | "onMember"> {
+ extends Omit<TimelineCardComponentProps<"">, "operations"> {
+ operations: Pick<
+ TimelineCardComponentProps<"">["operations"],
+ "onHighlight" | "onBookmark"
+ >;
infoArea: React.ReactElement;
manageArea:
| { type: "member"; onMember: () => void }
@@ -33,13 +37,13 @@ function TimelineCardTemplate({
collapse,
infoArea,
manageArea,
- onBookmark,
- onHighlight,
+ operations,
toggleCollapse,
syncStatus,
className,
}: TimelineCardTemplateProps): React.ReactElement | null {
const { t } = useTranslation();
+ const { onBookmark, onHighlight } = operations;
return (
<div className={clsx("cru-card p-2 clearfix", className)}>
@@ -56,13 +60,19 @@ function TimelineCardTemplate({
<div className="text-right mt-2">
{onHighlight != null ? (
<i
- className="bi-star icon-button text-yellow mr-3"
+ className={clsx(
+ timeline.isHighlight ? "bi-star-fill" : "bi-star",
+ "icon-button text-yellow mr-3"
+ )}
onClick={onHighlight}
/>
) : null}
{onBookmark != null ? (
<i
- className="bi-bookmark icon-button text-yellow mr-3"
+ className={clsx(
+ timeline.isBookmark ? "bi-bookmark-fill" : "bi-bookmark",
+ "icon-button text-yellow mr-3"
+ )}
onClick={onBookmark}
/>
) : null}
diff --git a/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
index 089d11a0..efa7e971 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
@@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next";
import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap";
import { User, useAvatar } from "@/services/user";
+import { TimelineInfo, timelineService } from "@/services/timeline";
+import { getHttpUserClient, HttpUserNotExistError } from "@/http/user";
import SearchInput from "../common/SearchInput";
import BlobImage from "../common/BlobImage";
@@ -52,15 +54,9 @@ const TimelineMemberItem: React.FC<{
);
};
-export interface TimelineMemberCallbacks {
- onCheckUser: (username: string) => Promise<User | null>;
- onAddUser: (user: User) => Promise<void>;
- onRemoveUser: (username: string) => void;
-}
-
export interface TimelineMemberProps {
- members: User[];
- edit: TimelineMemberCallbacks | null | undefined;
+ timeline: TimelineInfo;
+ editable: boolean;
}
const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
@@ -81,7 +77,9 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
userSearchState.type === "user" ? userSearchState.data.username : undefined
);
- const members = props.members;
+ const { timeline } = props;
+
+ const members = [timeline.owner, ...timeline.members];
return (
<Container className="px-4 py-3">
@@ -91,13 +89,21 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
key={member.username}
user={member}
owner={index === 0}
- onRemove={props.edit?.onRemoveUser}
+ onRemove={
+ props.editable
+ ? () => {
+ void timelineService.removeMember(
+ timeline.name,
+ member.username
+ );
+ }
+ : undefined
+ }
/>
))}
</ListGroup>
{(() => {
- const edit = props.edit;
- if (edit != null) {
+ if (props.editable) {
return (
<>
<SearchInput
@@ -115,26 +121,34 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
});
return;
}
-
setUserSearchState({ type: "loading" });
- edit.onCheckUser(userSearchText).then(
- (u) => {
- if (u == null) {
+ getHttpUserClient()
+ .get(userSearchText)
+ .catch((e) => {
+ if (e instanceof HttpUserNotExistError) {
+ return null;
+ } else {
+ throw e;
+ }
+ })
+ .then(
+ (u) => {
+ if (u == null) {
+ setUserSearchState({
+ type: "error",
+ data: "timeline.userNotExist",
+ });
+ } else {
+ setUserSearchState({ type: "user", data: u });
+ }
+ },
+ (e) => {
setUserSearchState({
type: "error",
- data: "timeline.userNotExist",
+ data: `${e as string}`,
});
- } else {
- setUserSearchState({ type: "user", data: u });
}
- },
- (e) => {
- setUserSearchState({
- type: "error",
- data: `${e as string}`,
- });
- }
- );
+ );
}}
/>
{(() => {
@@ -166,10 +180,12 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
className="align-self-center"
disabled={!addable}
onClick={() => {
- void edit.onAddUser(u).then((_) => {
- setUserSearchText("");
- setUserSearchState({ type: "init" });
- });
+ void timelineService
+ .addMember(timeline.name, u.username)
+ .then(() => {
+ setUserSearchText("");
+ setUserSearchState({ type: "init" });
+ });
}}
>
{t("timeline.member.add")}
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
index 7f5c8206..f8b2b38b 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
@@ -1,16 +1,10 @@
import React from "react";
import { useTranslation } from "react-i18next";
-import { of } from "rxjs";
-import { catchError } from "rxjs/operators";
import { UiLogicError } from "@/common";
import { pushAlert } from "@/services/alert";
-import { useUser, userInfoService, UserNotExistError } from "@/services/user";
-import {
- timelineService,
- usePostList,
- useTimelineInfo,
-} from "@/services/timeline";
+import { useUser } from "@/services/user";
+import { timelineService, usePosts, useTimeline } from "@/services/timeline";
import { getHttpBookmarkClient } from "@/http/bookmark";
import { getHttpHighlightClient } from "@/http/highlight";
@@ -18,8 +12,8 @@ import { TimelineMemberDialog } from "./TimelineMember";
import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
import { TimelinePageTemplateUIProps } from "./TimelinePageTemplateUI";
import { TimelinePostSendCallback } from "./TimelinePostEdit";
-import { TimelineSyncStatus } from "./SyncStatusBadge";
import { TimelinePostInfoEx } from "./Timeline";
+import { mergeDataStatus } from "@/services/DataHub2";
export interface TimelinePageTemplateProps<TManageItem> {
name: string;
@@ -45,8 +39,8 @@ export default function TimelinePageTemplate<TManageItem>(
null
);
- const timelineState = useTimelineInfo(name);
- const postListState = usePostList(name);
+ const timelineAndStatus = useTimeline(name);
+ const postsAndState = usePosts(name);
const onPost: TimelinePostSendCallback = React.useCallback(
(req) => {
@@ -68,111 +62,104 @@ export default function TimelinePageTemplate<TManageItem>(
[onManageProp]
);
- const childProps = ((): [
- data: TimelinePageTemplateUIProps<TManageItem>["data"],
- syncStatus: TimelineSyncStatus
- ] => {
- if (timelineState == null) {
- return [undefined, "syncing"];
+ const data = ((): TimelinePageTemplateUIProps<TManageItem>["data"] => {
+ const { status, data: timeline } = timelineAndStatus;
+ if (timeline == null) {
+ if (status === "offline") {
+ return { type: "custom", value: "Network Error" };
+ } else {
+ return undefined;
+ }
+ } else if (timeline === "notexist") {
+ return props.notFoundI18nKey;
} else {
- const { type, timeline } = timelineState;
- if (timeline == null) {
- if (type === "offline") {
- return [{ type: "custom", value: "Network Error" }, "offline"];
- } else if (type === "synced") {
- return [props.notFoundI18nKey, "synced"];
+ const posts = ((): TimelinePostInfoEx[] | "forbid" | undefined => {
+ const { data: postsInfo } = postsAndState;
+ if (postsInfo === "forbid") {
+ return "forbid";
+ } else if (postsInfo == null || postsInfo === "notexist") {
+ return undefined;
} else {
- return [undefined, "syncing"];
- }
- } else {
- if (postListState != null && postListState.type === "notexist") {
- return [props.notFoundI18nKey, "synced"];
- }
- if (postListState != null && postListState.type === "forbid") {
- return ["timeline.messageCantSee", "synced"];
+ return postsInfo.posts.map((post) => ({
+ ...post,
+ onDelete: service.hasModifyPostPermission(user, timeline, post)
+ ? () => {
+ service.deletePost(name, post.id).subscribe({
+ error: () => {
+ pushAlert({
+ type: "danger",
+ message: t("timeline.deletePostFailed"),
+ });
+ },
+ });
+ }
+ : undefined,
+ }));
}
-
- const posts:
- | TimelinePostInfoEx[]
- | undefined = postListState?.posts?.map((post) => ({
- ...post,
- onDelete: service.hasModifyPostPermission(user, timeline, post)
+ })();
+
+ const operations = {
+ onPost: service.hasPostPermission(user, timeline) ? onPost : undefined,
+ onManage: service.hasManagePermission(user, timeline)
+ ? onManage
+ : undefined,
+ onMember: () => setDialog("member"),
+ onBookmark:
+ user != null
? () => {
- service.deletePost(name, post.id).subscribe({
- error: () => {
+ const { isBookmark } = timeline;
+ const client = getHttpBookmarkClient();
+ const promise = isBookmark
+ ? client.delete(name)
+ : client.put(name);
+ promise.then(
+ () => {
+ void timelineService.syncTimeline(name);
+ },
+ () => {
pushAlert({
+ message: {
+ type: "i18n",
+ key: isBookmark
+ ? "timeline.removeBookmarkFail"
+ : "timeline.addBookmarkFail",
+ },
type: "danger",
- message: t("timeline.deletePostFailed"),
});
- },
- });
+ }
+ );
}
: undefined,
- }));
-
- const others = {
- onPost: service.hasPostPermission(user, timeline)
- ? onPost
- : undefined,
- onManage: service.hasManagePermission(user, timeline)
- ? onManage
- : undefined,
- onMember: () => setDialog("member"),
- onBookmark:
- user != null
- ? () => {
- void getHttpBookmarkClient()
- .put(name, user.token)
- .then(() => {
- pushAlert({
- message: {
- type: "i18n",
- key: "timeline.addBookmarkSuccess",
- },
- type: "success",
- });
- });
- }
- : undefined,
- onHighlight:
- user != null && user.hasHighlightTimelineAdministrationPermission
- ? () => {
- void getHttpHighlightClient()
- .put(name, user.token)
- .then(() => {
- pushAlert({
- message: {
- type: "i18n",
- key: "timeline.addHighlightSuccess",
- },
- type: "success",
- });
+ onHighlight:
+ user != null && user.hasHighlightTimelineAdministrationPermission
+ ? () => {
+ const { isHighlight } = timeline;
+ const client = getHttpHighlightClient();
+ const promise = isHighlight
+ ? client.delete(name)
+ : client.put(name);
+ promise.then(
+ () => {
+ void timelineService.syncTimeline(name);
+ },
+ () => {
+ pushAlert({
+ message: {
+ type: "i18n",
+ key: isHighlight
+ ? "timeline.removeHighlightFail"
+ : "timeline.addHighlightFail",
+ },
+ type: "danger",
});
- }
- : undefined,
- };
+ }
+ );
+ }
+ : undefined,
+ };
- if (type === "cache") {
- return [{ timeline, posts, ...others }, "syncing"];
- } else if (type === "offline") {
- return [{ timeline, posts, ...others }, "offline"];
- } else {
- if (postListState == null) {
- return [{ timeline, posts, ...others }, "syncing"];
- } else {
- const { type: postListType } = postListState;
- if (postListType === "synced") {
- return [{ timeline, posts, ...others }, "synced"];
- } else if (postListType === "cache") {
- return [{ timeline, posts, ...others }, "syncing"];
- } else if (postListType === "offline") {
- return [{ timeline, posts, ...others }, "offline"];
- }
- }
- }
- }
+ return { timeline, posts, operations };
}
- throw new UiLogicError("Failed to calculate TimelinePageUITemplate props.");
})();
const closeDialog = React.useCallback((): void => {
@@ -181,10 +168,10 @@ export default function TimelinePageTemplate<TManageItem>(
let dialogElement: React.ReactElement | undefined;
- const timeline = timelineState?.timeline;
+ const timeline = timelineAndStatus?.data;
if (dialog === "property") {
- if (timeline == null) {
+ if (timeline == null || timeline === "notexist") {
throw new UiLogicError(
"Timeline is null but attempt to open change property dialog."
);
@@ -205,7 +192,7 @@ export default function TimelinePageTemplate<TManageItem>(
/>
);
} else if (dialog === "member") {
- if (timeline == null) {
+ if (timeline == null || timeline === "notexist") {
throw new UiLogicError(
"Timeline is null but attempt to open change property dialog."
);
@@ -215,33 +202,8 @@ export default function TimelinePageTemplate<TManageItem>(
<TimelineMemberDialog
open
onClose={closeDialog}
- members={[timeline.owner, ...timeline.members]}
- edit={
- service.hasManagePermission(user, timeline)
- ? {
- onCheckUser: (u) => {
- return userInfoService
- .getUserInfo(u)
- .pipe(
- catchError((e) => {
- if (e instanceof UserNotExistError) {
- return of(null);
- } else {
- throw e;
- }
- })
- )
- .toPromise();
- },
- onAddUser: (u) => {
- return service.addMember(name, u.username).toPromise().then();
- },
- onRemoveUser: (u) => {
- service.removeMember(name, u);
- },
- }
- : null
- }
+ timeline={timeline}
+ editable={service.hasManagePermission(user, timeline)}
/>
);
}
@@ -250,7 +212,13 @@ export default function TimelinePageTemplate<TManageItem>(
return (
<>
- <UiComponent data={childProps[0]} syncStatus={childProps[1]} />
+ <UiComponent
+ data={data}
+ syncStatus={mergeDataStatus([
+ timelineAndStatus.status,
+ postsAndState.status,
+ ])}
+ />
{dialogElement}
</>
);
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
index 20ec6e43..41246175 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
@@ -13,26 +13,30 @@ import { TimelineSyncStatus } from "./SyncStatusBadge";
export interface TimelineCardComponentProps<TManageItems> {
timeline: TimelineInfo;
- onManage?: (item: TManageItems | "property") => void;
- onMember: () => void;
- onBookmark?: () => void;
- onHighlight?: () => void;
- className?: string;
- collapse: boolean;
syncStatus: TimelineSyncStatus;
+ operations: {
+ onManage?: (item: TManageItems | "property") => void;
+ onMember: () => void;
+ onBookmark?: () => void;
+ onHighlight?: () => void;
+ };
+ collapse: boolean;
toggleCollapse: () => void;
+ className?: string;
}
export interface TimelinePageTemplateUIProps<TManageItems> {
data?:
| {
timeline: TimelineInfo;
- posts?: TimelinePostInfoEx[];
- onManage?: (item: TManageItems | "property") => void;
- onMember: () => void;
- onBookmark?: () => void;
- onHighlight?: () => void;
- onPost?: TimelinePostSendCallback;
+ posts?: TimelinePostInfoEx[] | "forbid";
+ operations: {
+ onManage?: (item: TManageItems | "property") => void;
+ onMember: () => void;
+ onBookmark?: () => void;
+ onHighlight?: () => void;
+ onPost?: TimelinePostSendCallback;
+ };
}
| I18nText;
syncStatus: TimelineSyncStatus;
@@ -155,32 +159,33 @@ export default function TimelinePageTemplateUI<TManageItems>(
<CardComponent
className="timeline-template-card"
timeline={data.timeline}
- onManage={data.onManage}
- onMember={data.onMember}
- onBookmark={data.onBookmark}
- onHighlight={data.onHighlight}
+ operations={data.operations}
syncStatus={syncStatus}
collapse={cardCollapse}
toggleCollapse={toggleCardCollapse}
/>
) : null}
{posts != null ? (
- <div
- className="timeline-container"
- style={{ minHeight: `calc(100vh - ${56 + bottomSpaceHeight}px)` }}
- >
- <Timeline
- containerRef={timelineRef}
- posts={posts}
- onResize={triggerResizeEvent}
- />
- </div>
+ posts === "forbid" ? (
+ <div>{t("timeline.messageCantSee")}</div>
+ ) : (
+ <div
+ className="timeline-container"
+ style={{ minHeight: `calc(100vh - ${56 + bottomSpaceHeight}px)` }}
+ >
+ <Timeline
+ containerRef={timelineRef}
+ posts={posts}
+ onResize={triggerResizeEvent}
+ />
+ </div>
+ )
) : (
<div className="full-viewport-center-child">
<Spinner variant="primary" animation="grow" />
</div>
)}
- {data != null && data.onPost != null ? (
+ {data != null && data.operations.onPost != null ? (
<>
<div
style={{ height: bottomSpaceHeight }}
@@ -188,7 +193,7 @@ export default function TimelinePageTemplateUI<TManageItems>(
/>
<TimelinePostEdit
className="fixed-bottom"
- onPost={data.onPost}
+ onPost={data.operations.onPost}
onHeightChange={onPostEditHeightChange}
timelineUniqueId={data.timeline.uniqueId}
/>
diff --git a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx
index f4dbb67d..920f504d 100644
--- a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx
+++ b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx
@@ -13,8 +13,8 @@ export type OrdinaryTimelineManageItem = "delete";
export type TimelineInfoCardProps = TimelineCardComponentProps<OrdinaryTimelineManageItem>;
const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
- const { onMember, onManage, ...otherProps } = props;
- const { timeline } = props;
+ const { timeline, operations } = props;
+ const { onManage, onMember } = operations;
const avatar = useAvatar(timeline?.owner?.username);
@@ -66,7 +66,7 @@ const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
};
}
})()}
- {...otherProps}
+ {...props}
/>
);
};
diff --git a/FrontEnd/src/app/views/user/UserInfoCard.tsx b/FrontEnd/src/app/views/user/UserInfoCard.tsx
index f31a939f..01d2c096 100644
--- a/FrontEnd/src/app/views/user/UserInfoCard.tsx
+++ b/FrontEnd/src/app/views/user/UserInfoCard.tsx
@@ -13,8 +13,8 @@ export type PersonalTimelineManageItem = "avatar" | "nickname";
export type UserInfoCardProps = TimelineCardComponentProps<PersonalTimelineManageItem>;
const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
- const { onMember, onManage, ...otherProps } = props;
- const { timeline } = props;
+ const { timeline, operations } = props;
+ const { onManage, onMember } = operations;
const avatar = useAvatar(timeline?.owner?.username);
@@ -66,7 +66,7 @@ const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
};
}
})()}
- {...otherProps}
+ {...props}
/>
);
};