aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src')
-rw-r--r--FrontEnd/src/app/App.tsx6
-rw-r--r--FrontEnd/src/app/http/bookmark.ts34
-rw-r--r--FrontEnd/src/app/http/common.ts69
-rw-r--r--FrontEnd/src/app/http/highlight.ts34
-rw-r--r--FrontEnd/src/app/http/search.ts22
-rw-r--r--FrontEnd/src/app/http/timeline.ts339
-rw-r--r--FrontEnd/src/app/http/token.ts7
-rw-r--r--FrontEnd/src/app/http/user.ts54
-rw-r--r--FrontEnd/src/app/locales/en/translation.json1
-rw-r--r--FrontEnd/src/app/locales/zh/translation.json1
-rw-r--r--FrontEnd/src/app/services/DataHub2.ts191
-rw-r--r--FrontEnd/src/app/services/common.ts24
-rw-r--r--FrontEnd/src/app/services/timeline.ts506
-rw-r--r--FrontEnd/src/app/services/user.ts222
-rw-r--r--FrontEnd/src/app/utilities/url.ts3
-rw-r--r--FrontEnd/src/app/views/admin/UserAdmin.tsx6
-rw-r--r--FrontEnd/src/app/views/common/AppBar.tsx10
-rw-r--r--FrontEnd/src/app/views/common/user/UserAvatar.tsx13
-rw-r--r--FrontEnd/src/app/views/home/OfflineBoard.tsx61
-rw-r--r--FrontEnd/src/app/views/home/TimelineBoard.tsx12
-rw-r--r--FrontEnd/src/app/views/home/TimelineCreateDialog.tsx15
-rw-r--r--FrontEnd/src/app/views/search/index.tsx11
-rw-r--r--FrontEnd/src/app/views/settings/index.tsx3
-rw-r--r--FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx58
-rw-r--r--FrontEnd/src/app/views/timeline-common/Timeline.tsx176
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx23
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineDateLabel.tsx (renamed from FrontEnd/src/app/views/timeline-common/TimelineDateItem.tsx)4
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineMember.tsx43
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx157
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx69
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx114
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx122
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostListView.tsx75
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostView.tsx (renamed from FrontEnd/src/app/views/timeline-common/TimelineItem.tsx)77
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx21
-rw-r--r--FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx4
-rw-r--r--FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx10
-rw-r--r--FrontEnd/src/app/views/timeline/index.tsx5
-rw-r--r--FrontEnd/src/app/views/user/UserInfoCard.tsx10
-rw-r--r--FrontEnd/src/app/views/user/index.tsx19
40 files changed, 737 insertions, 1894 deletions
diff --git a/FrontEnd/src/app/App.tsx b/FrontEnd/src/app/App.tsx
index 0a7513e4..fb57bd1e 100644
--- a/FrontEnd/src/app/App.tsx
+++ b/FrontEnd/src/app/App.tsx
@@ -12,7 +12,6 @@ import TimelinePage from "./views/timeline";
import Search from "./views/search";
import AlertHost from "./views/common/alert/AlertHost";
-import { dataStorage } from "./services/common";
import { userService, useRawUser } from "./services/user";
const NoMatch: React.FC = () => {
@@ -24,16 +23,13 @@ const LazyAdmin = React.lazy(
);
const App: React.FC = () => {
- const [loading, setLoading] = React.useState<boolean>(true);
-
const user = useRawUser();
React.useEffect(() => {
void userService.checkLoginState();
- void dataStorage.ready().then(() => setLoading(false));
}, []);
- if (user === undefined || loading) {
+ if (user === undefined) {
return <LoadingPage />;
} else {
return (
diff --git a/FrontEnd/src/app/http/bookmark.ts b/FrontEnd/src/app/http/bookmark.ts
index 15e55d98..3e5be229 100644
--- a/FrontEnd/src/app/http/bookmark.ts
+++ b/FrontEnd/src/app/http/bookmark.ts
@@ -1,15 +1,6 @@
-import {
- axios,
- apiBaseUrl,
- convertToNetworkError,
- extractResponseData,
-} from "./common";
+import { axios, apiBaseUrl, extractResponseData } from "./common";
-import {
- HttpTimelineInfo,
- processRawTimelineInfo,
- RawHttpTimelineInfo,
-} from "./timeline";
+import { HttpTimelineInfo } from "./timeline";
export interface HttpHighlightMoveRequest {
timeline: string;
@@ -26,31 +17,20 @@ export interface IHttpBookmarkClient {
export class HttpHighlightClient implements IHttpBookmarkClient {
list(): Promise<HttpTimelineInfo[]> {
return axios
- .get<RawHttpTimelineInfo[]>(`${apiBaseUrl}/bookmarks`)
- .then(extractResponseData)
- .then((list) => list.map(processRawTimelineInfo))
- .catch(convertToNetworkError);
+ .get<HttpTimelineInfo[]>(`${apiBaseUrl}/bookmarks`)
+ .then(extractResponseData);
}
put(timeline: string): Promise<void> {
- return axios
- .put(`${apiBaseUrl}/bookmarks/${timeline}`)
- .catch(convertToNetworkError)
- .then();
+ return axios.put(`${apiBaseUrl}/bookmarks/${timeline}`).then();
}
delete(timeline: string): Promise<void> {
- return axios
- .delete(`${apiBaseUrl}/bookmarks/${timeline}`)
- .catch(convertToNetworkError)
- .then();
+ return axios.delete(`${apiBaseUrl}/bookmarks/${timeline}`).then();
}
move(req: HttpHighlightMoveRequest): Promise<void> {
- return axios
- .post(`${apiBaseUrl}/bookmarkop/move`, req)
- .catch(convertToNetworkError)
- .then();
+ return axios.post(`${apiBaseUrl}/bookmarkop/move`, req).then();
}
}
diff --git a/FrontEnd/src/app/http/common.ts b/FrontEnd/src/app/http/common.ts
index 0f46280c..5c44e8e3 100644
--- a/FrontEnd/src/app/http/common.ts
+++ b/FrontEnd/src/app/http/common.ts
@@ -4,6 +4,45 @@ export const apiBaseUrl = "/api";
export const axios = rawAxios.create();
+function convertToNetworkError(error: AxiosError): never {
+ if (error.isAxiosError && error.response == null) {
+ throw new HttpNetworkError(error);
+ } else {
+ throw error;
+ }
+}
+
+function convertToForbiddenError(error: AxiosError): never {
+ if (
+ error.isAxiosError &&
+ error.response != null &&
+ (error.response.status == 401 || error.response.status == 403)
+ ) {
+ throw new HttpForbiddenError(error);
+ } else {
+ throw error;
+ }
+}
+
+function convertToNotFoundError(error: AxiosError): never {
+ if (
+ error.isAxiosError &&
+ error.response != null &&
+ error.response.status == 404
+ ) {
+ throw new HttpNotFoundError(error);
+ } else {
+ throw error;
+ }
+}
+
+rawAxios.interceptors.response.use(undefined, convertToNetworkError);
+rawAxios.interceptors.response.use(undefined, convertToForbiddenError);
+rawAxios.interceptors.response.use(undefined, convertToNotFoundError);
+axios.interceptors.response.use(undefined, convertToNetworkError);
+axios.interceptors.response.use(undefined, convertToForbiddenError);
+axios.interceptors.response.use(undefined, convertToNotFoundError);
+
let _token: string | null = null;
export function getHttpToken(): string | null {
@@ -71,6 +110,12 @@ export class HttpForbiddenError extends Error {
}
}
+export class HttpNotFoundError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
export class NotModified {}
export interface BlobWithEtag {
@@ -135,30 +180,6 @@ export function convertToIfErrorCodeIs<NewError>(
});
}
-export function convertToNetworkError(
- error: AxiosError<CommonErrorResponse>
-): never {
- if (error.isAxiosError && error.response == null) {
- throw new HttpNetworkError(error);
- } else {
- throw error;
- }
-}
-
-export function convertToForbiddenError(
- error: AxiosError<CommonErrorResponse>
-): never {
- if (
- error.isAxiosError &&
- error.response != null &&
- (error.response.status == 401 || error.response.status == 403)
- ) {
- throw new HttpForbiddenError(error);
- } else {
- throw error;
- }
-}
-
export function convertToNotModified(
error: AxiosError<CommonErrorResponse>
): NotModified {
diff --git a/FrontEnd/src/app/http/highlight.ts b/FrontEnd/src/app/http/highlight.ts
index 851d52ce..fddf0729 100644
--- a/FrontEnd/src/app/http/highlight.ts
+++ b/FrontEnd/src/app/http/highlight.ts
@@ -1,15 +1,6 @@
-import {
- axios,
- apiBaseUrl,
- convertToNetworkError,
- extractResponseData,
-} from "./common";
+import { axios, apiBaseUrl, extractResponseData } from "./common";
-import {
- HttpTimelineInfo,
- processRawTimelineInfo,
- RawHttpTimelineInfo,
-} from "./timeline";
+import { HttpTimelineInfo } from "./timeline";
export interface HttpHighlightMoveRequest {
timeline: string;
@@ -26,31 +17,20 @@ export interface IHttpHighlightClient {
export class HttpHighlightClient implements IHttpHighlightClient {
list(): Promise<HttpTimelineInfo[]> {
return axios
- .get<RawHttpTimelineInfo[]>(`${apiBaseUrl}/highlights`)
- .then(extractResponseData)
- .then((list) => list.map(processRawTimelineInfo))
- .catch(convertToNetworkError);
+ .get<HttpTimelineInfo[]>(`${apiBaseUrl}/highlights`)
+ .then(extractResponseData);
}
put(timeline: string): Promise<void> {
- return axios
- .put(`${apiBaseUrl}/highlights/${timeline}`)
- .catch(convertToNetworkError)
- .then();
+ return axios.put(`${apiBaseUrl}/highlights/${timeline}`).then();
}
delete(timeline: string): Promise<void> {
- return axios
- .delete(`${apiBaseUrl}/highlights/${timeline}`)
- .catch(convertToNetworkError)
- .then();
+ return axios.delete(`${apiBaseUrl}/highlights/${timeline}`).then();
}
move(req: HttpHighlightMoveRequest): Promise<void> {
- return axios
- .post(`${apiBaseUrl}/highlightop/move`, req)
- .catch(convertToNetworkError)
- .then();
+ return axios.post(`${apiBaseUrl}/highlightop/move`, req).then();
}
}
diff --git a/FrontEnd/src/app/http/search.ts b/FrontEnd/src/app/http/search.ts
index 2da9295e..8ca48fe9 100644
--- a/FrontEnd/src/app/http/search.ts
+++ b/FrontEnd/src/app/http/search.ts
@@ -1,14 +1,5 @@
-import {
- apiBaseUrl,
- axios,
- convertToNetworkError,
- extractResponseData,
-} from "./common";
-import {
- HttpTimelineInfo,
- processRawTimelineInfo,
- RawHttpTimelineInfo,
-} from "./timeline";
+import { apiBaseUrl, axios, extractResponseData } from "./common";
+import { HttpTimelineInfo } from "./timeline";
import { HttpUser } from "./user";
export interface IHttpSearchClient {
@@ -19,17 +10,14 @@ export interface IHttpSearchClient {
export class HttpSearchClient implements IHttpSearchClient {
searchTimelines(query: string): Promise<HttpTimelineInfo[]> {
return axios
- .get<RawHttpTimelineInfo[]>(`${apiBaseUrl}/search/timelines?q=${query}`)
- .then(extractResponseData)
- .then((ts) => ts.map(processRawTimelineInfo))
- .catch(convertToNetworkError);
+ .get<HttpTimelineInfo[]>(`${apiBaseUrl}/search/timelines?q=${query}`)
+ .then(extractResponseData);
}
searchUsers(query: string): Promise<HttpUser[]> {
return axios
.get<HttpUser[]>(`${apiBaseUrl}/search/users?q=${query}`)
- .then(extractResponseData)
- .catch(convertToNetworkError);
+ .then(extractResponseData);
}
}
diff --git a/FrontEnd/src/app/http/timeline.ts b/FrontEnd/src/app/http/timeline.ts
index 68fee5ae..50af259e 100644
--- a/FrontEnd/src/app/http/timeline.ts
+++ b/FrontEnd/src/app/http/timeline.ts
@@ -6,15 +6,8 @@ import {
axios,
apiBaseUrl,
extractResponseData,
- convertToNetworkError,
- base64,
- convertToIfStatusCodeIs,
convertToIfErrorCodeIs,
- BlobWithEtag,
- NotModified,
- convertToNotModified,
- convertToForbiddenError,
- convertToBlobWithEtag,
+ getHttpToken,
} from "./common";
import { HttpUser } from "./user";
@@ -29,10 +22,13 @@ export interface HttpTimelineInfo {
description: string;
owner: HttpUser;
visibility: TimelineVisibility;
- lastModified: Date;
+ color: string;
+ lastModified: string;
members: HttpUser[];
isHighlight: boolean;
isBookmark: boolean;
+ manageable: boolean;
+ postable: boolean;
}
export interface HttpTimelineListQuery {
@@ -45,57 +41,32 @@ export interface HttpTimelinePostRequest {
name: string;
}
-export interface HttpTimelinePostTextContent {
- type: "text";
- text: string;
-}
-
-export interface HttpTimelinePostImageContent {
- type: "image";
+export interface HttpTimelinePostDataDigest {
+ kind: string;
+ eTag: string;
+ lastUpdated: string;
}
-export type HttpTimelinePostContent =
- | HttpTimelinePostTextContent
- | HttpTimelinePostImageContent;
-
export interface HttpTimelinePostInfo {
id: number;
- content: HttpTimelinePostContent;
- time: Date;
- lastUpdated: Date;
+ time: string;
author: HttpUser;
- deleted: false;
-}
-
-export interface HttpTimelineDeletedPostInfo {
- id: number;
- time: Date;
- lastUpdated: Date;
- author?: HttpUser;
- deleted: true;
-}
-
-export type HttpTimelineGenericPostInfo =
- | HttpTimelinePostInfo
- | HttpTimelineDeletedPostInfo;
-
-export interface HttpTimelinePostPostRequestTextContent {
- type: "text";
- text: string;
+ dataList: HttpTimelinePostDataDigest[];
+ color: string;
+ lastUpdated: string;
+ timelineName: string;
+ editable: boolean;
}
-export interface HttpTimelinePostPostRequestImageContent {
- type: "image";
- data: Blob;
+export interface HttpTimelinePostPostRequestData {
+ contentType: string;
+ data: string;
}
-export type HttpTimelinePostPostRequestContent =
- | HttpTimelinePostPostRequestTextContent
- | HttpTimelinePostPostRequestImageContent;
-
export interface HttpTimelinePostPostRequest {
- content: HttpTimelinePostPostRequestContent;
- time?: Date;
+ time?: string;
+ color?: string;
+ dataList: HttpTimelinePostPostRequestData[];
}
export interface HttpTimelinePatchRequest {
@@ -105,120 +76,12 @@ export interface HttpTimelinePatchRequest {
description?: string;
}
-export class HttpTimelineNotExistError extends Error {
- constructor(public innerError?: AxiosError) {
- super();
- }
-}
-
-export class HttpTimelinePostNotExistError extends Error {
- constructor(public innerError?: AxiosError) {
- super();
- }
-}
-
export class HttpTimelineNameConflictError extends Error {
constructor(public innerError?: AxiosError) {
super();
}
}
-//-------------------- begin: internal model --------------------
-
-export interface RawHttpTimelineInfo {
- uniqueId: string;
- title: string;
- name: string;
- description: string;
- owner: HttpUser;
- visibility: TimelineVisibility;
- lastModified: string;
- members: HttpUser[];
- isHighlight: boolean;
- isBookmark: boolean;
-}
-
-interface RawTimelinePostTextContent {
- type: "text";
- text: string;
-}
-
-interface RawTimelinePostImageContent {
- type: "image";
- url: string;
-}
-
-type RawTimelinePostContent =
- | RawTimelinePostTextContent
- | RawTimelinePostImageContent;
-
-interface RawTimelinePostInfo {
- id: number;
- content: RawTimelinePostContent;
- time: string;
- lastUpdated: string;
- author: HttpUser;
- deleted: false;
-}
-
-interface RawTimelineDeletedPostInfo {
- id: number;
- time: string;
- lastUpdated: string;
- author: HttpUser;
- deleted: true;
-}
-
-type RawTimelineGenericPostInfo =
- | RawTimelinePostInfo
- | RawTimelineDeletedPostInfo;
-
-interface RawTimelinePostPostRequestTextContent {
- type: "text";
- text: string;
-}
-
-interface RawTimelinePostPostRequestImageContent {
- type: "image";
- data: string;
-}
-
-type RawTimelinePostPostRequestContent =
- | RawTimelinePostPostRequestTextContent
- | RawTimelinePostPostRequestImageContent;
-
-interface RawTimelinePostPostRequest {
- content: RawTimelinePostPostRequestContent;
- time?: string;
-}
-
-//-------------------- end: internal model --------------------
-
-export function processRawTimelineInfo(
- raw: RawHttpTimelineInfo
-): HttpTimelineInfo {
- return {
- ...raw,
- lastModified: new Date(raw.lastModified),
- };
-}
-
-function processRawTimelinePostInfo(
- raw: RawTimelinePostInfo
-): HttpTimelinePostInfo;
-function processRawTimelinePostInfo(
- raw: RawTimelineGenericPostInfo
-): HttpTimelineGenericPostInfo;
-function processRawTimelinePostInfo(
- raw: RawTimelineGenericPostInfo
-): HttpTimelineGenericPostInfo {
- return {
- ...raw,
- time: new Date(raw.time),
- lastUpdated: new Date(raw.lastUpdated),
- };
-}
-
export interface IHttpTimelineClient {
listTimeline(query: HttpTimelineListQuery): Promise<HttpTimelineInfo[]>;
getTimeline(timelineName: string): Promise<HttpTimelineInfo>;
@@ -231,26 +94,8 @@ export interface IHttpTimelineClient {
memberPut(timelineName: string, username: string): Promise<void>;
memberDelete(timelineName: string, username: string): Promise<void>;
listPost(timelineName: string): Promise<HttpTimelinePostInfo[]>;
- listPost(
- timelineName: string,
- query: {
- modifiedSince?: Date;
- includeDeleted?: false;
- }
- ): Promise<HttpTimelinePostInfo[]>;
- listPost(
- timelineName: string,
- query: {
- modifiedSince?: Date;
- includeDeleted: true;
- }
- ): Promise<HttpTimelineGenericPostInfo[]>;
- getPostData(timelineName: string, postId: number): Promise<BlobWithEtag>;
- getPostData(
- timelineName: string,
- postId: number,
- etag: string
- ): Promise<BlobWithEtag | NotModified>;
+ generatePostDataUrl(timelineName: string, postId: number): string;
+ getPostDataAsString(timelineName: string, postId: number): Promise<string>;
postPost(
timelineName: string,
req: HttpTimelinePostPostRequest
@@ -261,30 +106,23 @@ export interface IHttpTimelineClient {
export class HttpTimelineClient implements IHttpTimelineClient {
listTimeline(query: HttpTimelineListQuery): Promise<HttpTimelineInfo[]> {
return axios
- .get<RawHttpTimelineInfo[]>(
+ .get<HttpTimelineInfo[]>(
applyQueryParameters(`${apiBaseUrl}/timelines`, query)
)
- .then(extractResponseData)
- .then((list) => list.map(processRawTimelineInfo))
- .catch(convertToNetworkError);
+ .then(extractResponseData);
}
getTimeline(timelineName: string): Promise<HttpTimelineInfo> {
return axios
- .get<RawHttpTimelineInfo>(`${apiBaseUrl}/timelines/${timelineName}`)
- .then(extractResponseData)
- .then(processRawTimelineInfo)
- .catch(convertToIfStatusCodeIs(404, HttpTimelineNotExistError))
- .catch(convertToNetworkError);
+ .get<HttpTimelineInfo>(`${apiBaseUrl}/timelines/${timelineName}`)
+ .then(extractResponseData);
}
postTimeline(req: HttpTimelinePostRequest): Promise<HttpTimelineInfo> {
return axios
- .post<RawHttpTimelineInfo>(`${apiBaseUrl}/timelines`, req)
+ .post<HttpTimelineInfo>(`${apiBaseUrl}/timelines`, req)
.then(extractResponseData)
- .then(processRawTimelineInfo)
- .catch(convertToIfErrorCodeIs(11040101, HttpTimelineNameConflictError))
- .catch(convertToNetworkError);
+ .catch(convertToIfErrorCodeIs(11040101, HttpTimelineNameConflictError));
}
patchTimeline(
@@ -292,134 +130,67 @@ export class HttpTimelineClient implements IHttpTimelineClient {
req: HttpTimelinePatchRequest
): Promise<HttpTimelineInfo> {
return axios
- .patch<RawHttpTimelineInfo>(
- `${apiBaseUrl}/timelines/${timelineName}`,
- req
- )
- .then(extractResponseData)
- .then(processRawTimelineInfo)
- .catch(convertToNetworkError);
+ .patch<HttpTimelineInfo>(`${apiBaseUrl}/timelines/${timelineName}`, req)
+ .then(extractResponseData);
}
deleteTimeline(timelineName: string): Promise<void> {
- return axios
- .delete(`${apiBaseUrl}/timelines/${timelineName}`)
- .catch(convertToNetworkError)
- .then();
+ return axios.delete(`${apiBaseUrl}/timelines/${timelineName}`).then();
}
memberPut(timelineName: string, username: string): Promise<void> {
return axios
.put(`${apiBaseUrl}/timelines/${timelineName}/members/${username}`)
- .catch(convertToNetworkError)
.then();
}
memberDelete(timelineName: string, username: string): Promise<void> {
return axios
.delete(`${apiBaseUrl}/timelines/${timelineName}/members/${username}`)
- .catch(convertToNetworkError)
.then();
}
- listPost(timelineName: string): Promise<HttpTimelinePostInfo[]>;
- listPost(
- timelineName: string,
- query: {
- modifiedSince?: Date;
- includeDeleted?: false;
- }
- ): Promise<HttpTimelinePostInfo[]>;
- listPost(
- timelineName: string,
- query: {
- modifiedSince?: Date;
- includeDeleted: true;
- }
- ): Promise<HttpTimelineGenericPostInfo[]>;
- listPost(
- timelineName: string,
- query?: {
- modifiedSince?: Date;
- includeDeleted?: boolean;
- }
- ): Promise<HttpTimelineGenericPostInfo[]> {
+ listPost(timelineName: string): Promise<HttpTimelinePostInfo[]> {
return axios
- .get<RawTimelineGenericPostInfo[]>(
- applyQueryParameters(
- `${apiBaseUrl}/timelines/${timelineName}/posts`,
- query
- )
+ .get<HttpTimelinePostInfo[]>(
+ `${apiBaseUrl}/timelines/${timelineName}/posts`
)
- .then(extractResponseData)
- .catch(convertToIfStatusCodeIs(404, HttpTimelineNotExistError))
- .catch(convertToForbiddenError)
- .catch(convertToNetworkError)
- .then((rawPosts) =>
- rawPosts.map((raw) => processRawTimelinePostInfo(raw))
- );
+ .then(extractResponseData);
}
- getPostData(timelineName: string, postId: number): Promise<BlobWithEtag>;
- getPostData(
- timelineName: string,
- postId: number,
- etag?: string
- ): Promise<BlobWithEtag | NotModified> {
- const headers =
- etag != null
- ? {
- "If-None-Match": etag,
- }
- : undefined;
-
- const url = `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}/data`;
+ generatePostDataUrl(timelineName: string, postId: number): string {
+ return applyQueryParameters(
+ `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}/data`,
+ { token: getHttpToken() }
+ );
+ }
+ getPostDataAsString(timelineName: string, postId: number): Promise<string> {
return axios
- .get(url, {
- responseType: "blob",
- headers,
- })
- .then(convertToBlobWithEtag)
- .catch(convertToNotModified)
- .catch(convertToIfStatusCodeIs(404, HttpTimelinePostNotExistError))
- .catch(convertToNetworkError);
+ .get<string>(
+ `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}/data`,
+ {
+ responseType: "text",
+ }
+ )
+ .then(extractResponseData);
}
- async postPost(
+ postPost(
timelineName: string,
req: HttpTimelinePostPostRequest
): Promise<HttpTimelinePostInfo> {
- let content: RawTimelinePostPostRequestContent;
- if (req.content.type === "image") {
- const base64Data = await base64(req.content.data);
- content = {
- ...req.content,
- data: base64Data,
- } as RawTimelinePostPostRequestImageContent;
- } else {
- content = req.content;
- }
- const rawReq: RawTimelinePostPostRequest = {
- content,
- };
- if (req.time != null) {
- rawReq.time = req.time.toISOString();
- }
- return await axios
- .post<RawTimelinePostInfo>(
+ return axios
+ .post<HttpTimelinePostInfo>(
`${apiBaseUrl}/timelines/${timelineName}/posts`,
- rawReq
+ req
)
- .then(extractResponseData)
- .catch(convertToNetworkError)
- .then((rawPost) => processRawTimelinePostInfo(rawPost));
+ .then(extractResponseData);
}
deletePost(timelineName: string, postId: number): Promise<void> {
return axios
.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 c0644515..f8b09d63 100644
--- a/FrontEnd/src/app/http/token.ts
+++ b/FrontEnd/src/app/http/token.ts
@@ -4,7 +4,6 @@ import axios, { AxiosError } from "axios";
import {
apiBaseUrl,
- convertToNetworkError,
convertToIfErrorCodeIs,
extractResponseData,
} from "./common";
@@ -47,15 +46,13 @@ export class HttpTokenClient implements IHttpTokenClient {
.then(extractResponseData)
.catch(
convertToIfErrorCodeIs(11010101, HttpCreateTokenBadCredentialError)
- )
- .catch(convertToNetworkError);
+ );
}
verify(req: HttpVerifyTokenRequest): Promise<HttpVerifyTokenResponse> {
return axios
.post<HttpVerifyTokenResponse>(`${apiBaseUrl}/token/verify`, req)
- .then(extractResponseData)
- .catch(convertToNetworkError);
+ .then(extractResponseData);
}
}
diff --git a/FrontEnd/src/app/http/user.ts b/FrontEnd/src/app/http/user.ts
index 19accc42..dcb222bf 100644
--- a/FrontEnd/src/app/http/user.ts
+++ b/FrontEnd/src/app/http/user.ts
@@ -3,14 +3,9 @@ import { AxiosError } from "axios";
import {
axios,
apiBaseUrl,
- convertToNetworkError,
extractResponseData,
convertToIfStatusCodeIs,
convertToIfErrorCodeIs,
- NotModified,
- BlobWithEtag,
- convertToBlobWithEtag,
- convertToNotModified,
extractEtag,
} from "./common";
@@ -66,12 +61,7 @@ export interface IHttpUserClient {
get(username: string): Promise<HttpUser>;
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>;
- // return etag
+ generateAvatarUrl(username: string): string;
putAvatar(username: string, data: Blob): Promise<string>;
changePassword(req: HttpChangePasswordRequest): Promise<void>;
putUserPermission(
@@ -90,53 +80,28 @@ export class HttpUserClient implements IHttpUserClient {
list(): Promise<HttpUser[]> {
return axios
.get<HttpUser[]>(`${apiBaseUrl}/users`)
- .then(extractResponseData)
- .catch(convertToNetworkError);
+ .then(extractResponseData);
}
get(username: string): Promise<HttpUser> {
return axios
.get<HttpUser>(`${apiBaseUrl}/users/${username}`)
.then(extractResponseData)
- .catch(convertToIfStatusCodeIs(404, HttpUserNotExistError))
- .catch(convertToNetworkError);
+ .catch(convertToIfStatusCodeIs(404, HttpUserNotExistError));
}
patch(username: string, req: HttpUserPatchRequest): Promise<HttpUser> {
return axios
.patch<HttpUser>(`${apiBaseUrl}/users/${username}`, req)
- .then(extractResponseData)
- .catch(convertToNetworkError);
+ .then(extractResponseData);
}
delete(username: string): Promise<void> {
- return axios
- .delete(`${apiBaseUrl}/users/${username}`)
- .catch(convertToNetworkError)
- .then();
+ return axios.delete(`${apiBaseUrl}/users/${username}`).then();
}
- getAvatar(username: string): Promise<BlobWithEtag>;
- getAvatar(
- username: string,
- etag?: string
- ): Promise<BlobWithEtag | NotModified> {
- const headers =
- etag != null
- ? {
- "If-None-Match": etag,
- }
- : undefined;
-
- return axios
- .get(`${apiBaseUrl}/users/${username}/avatar`, {
- responseType: "blob",
- headers,
- })
- .then(convertToBlobWithEtag)
- .catch(convertToNotModified)
- .catch(convertToIfStatusCodeIs(404, HttpUserNotExistError))
- .catch(convertToNetworkError);
+ generateAvatarUrl(username: string): string {
+ return `${apiBaseUrl}/users/${username}/avatar`;
}
putAvatar(username: string, data: Blob): Promise<string> {
@@ -146,7 +111,6 @@ export class HttpUserClient implements IHttpUserClient {
"Content-Type": data.type,
},
})
- .catch(convertToNetworkError)
.then(extractEtag);
}
@@ -156,7 +120,6 @@ export class HttpUserClient implements IHttpUserClient {
.catch(
convertToIfErrorCodeIs(11020201, HttpChangePasswordBadCredentialError)
)
- .catch(convertToNetworkError)
.then();
}
@@ -166,7 +129,6 @@ export class HttpUserClient implements IHttpUserClient {
): Promise<void> {
return axios
.put(`${apiBaseUrl}/users/${username}/permissions/${permission}`)
- .catch(convertToNetworkError)
.then();
}
@@ -176,7 +138,6 @@ export class HttpUserClient implements IHttpUserClient {
): Promise<void> {
return axios
.delete(`${apiBaseUrl}/users/${username}/permissions/${permission}`)
- .catch(convertToNetworkError)
.then();
}
@@ -184,7 +145,6 @@ export class HttpUserClient implements IHttpUserClient {
return axios
.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 408950b1..9e40cb85 100644
--- a/FrontEnd/src/app/locales/en/translation.json
+++ b/FrontEnd/src/app/locales/en/translation.json
@@ -29,7 +29,6 @@
"relatedTimeline": "Timelines Related To You",
"publicTimeline": "Public Timelines",
"bookmarkTimeline": "Bookmark Timelines",
- "offlinePrompt": "Oh oh, it seems you are offline. Here list some timelines cached locally. You can view them or click <1>here</1> to refresh.",
"message": {
"moveHighlightFail": "Failed to move highlight timeline.",
"deleteHighlightFail": "Failed to delete highlight timeline.",
diff --git a/FrontEnd/src/app/locales/zh/translation.json b/FrontEnd/src/app/locales/zh/translation.json
index 498a74e4..8d6dbcf3 100644
--- a/FrontEnd/src/app/locales/zh/translation.json
+++ b/FrontEnd/src/app/locales/zh/translation.json
@@ -29,7 +29,6 @@
"relatedTimeline": "关于你的时间线",
"publicTimeline": "公开时间线",
"bookmarkTimeline": "书签时间线",
- "offlinePrompt": "你好像处于离线状态。以下是一些缓存在本地的时间线。你可以查看它们或者<1>点击</1>重新获取在线信息。",
"message": {
"moveHighlightFail": "移动高光时间线失败。",
"deleteHighlightFail": "删除高光时间线失败。",
diff --git a/FrontEnd/src/app/services/DataHub2.ts b/FrontEnd/src/app/services/DataHub2.ts
deleted file mode 100644
index f0fb724b..00000000
--- a/FrontEnd/src/app/services/DataHub2.ts
+++ /dev/null
@@ -1,191 +0,0 @@
-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>>[] = [];
-
- private _syncPromise: Promise<void> | null = null;
-
- 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 {
- 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);
- };
- });
- }
-
- private syncWithAction(action: () => Promise<void>): Promise<void> {
- if (this._syncPromise != null) return this._syncPromise;
- this._syncPromise = action().then(() => {
- this._syncPromise = null;
- });
- return this._syncPromise;
- }
-
- sync(): Promise<void> {
- return this.syncWithAction(this.doSync.bind(this));
- }
-
- private async doSync(): Promise<void> {
- const { currentData } = this;
- this.next({ data: currentData?.data ?? null, status: "syncing" });
- const savedData = await this.config.getSavedData();
- if (currentData == null && savedData != null) {
- this.next({ data: savedData, status: "syncing" });
- }
- const data = await this.config.fetchData(savedData);
- if (data == null) {
- this.next({
- data: savedData,
- status: "offline",
- });
- } else {
- await this.config.saveData(data);
- this.next({ data: data, status: "synced" });
- }
- }
-
- save(data: TData): Promise<void> {
- return this.syncWithAction(this.doSave.bind(this, data));
- }
-
- private async doSave(data: TData): Promise<void> {
- await this.config.saveData(data);
- 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
deleted file mode 100644
index 9208737b..00000000
--- a/FrontEnd/src/app/services/common.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import localforage from "localforage";
-
-const dataVersion = 1;
-
-export const dataStorage = localforage.createInstance({
- name: "data",
- description: "Database for offline data.",
- 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);
- }
-}
diff --git a/FrontEnd/src/app/services/timeline.ts b/FrontEnd/src/app/services/timeline.ts
index 46671ea1..a24ec8eb 100644
--- a/FrontEnd/src/app/services/timeline.ts
+++ b/FrontEnd/src/app/services/timeline.ts
@@ -1,60 +1,10 @@
-import React from "react";
+import { TimelineVisibility } from "@/http/timeline";
import XRegExp from "xregexp";
-import { Observable, from } from "rxjs";
-import { convertError } from "@/utilities/rxjs";
-import {
- TimelineVisibility,
- HttpTimelineInfo,
- HttpTimelinePatchRequest,
- HttpTimelinePostPostRequest,
- HttpTimelinePostPostRequestContent,
- HttpTimelinePostPostRequestTextContent,
- HttpTimelinePostPostRequestImageContent,
- HttpTimelinePostInfo,
- HttpTimelinePostTextContent,
- getHttpTimelineClient,
- HttpTimelineNotExistError,
- HttpTimelineNameConflictError,
-} from "@/http/timeline";
-import { HttpForbiddenError, HttpNetworkError } from "@/http/common";
-
-export { kTimelineVisibilities } from "@/http/timeline";
-
-export type { TimelineVisibility } from "@/http/timeline";
-
-import { dataStorage } from "./common";
-import { userInfoService, AuthUser } from "./user";
-import { DataAndStatus, DataHub2 } from "./DataHub2";
-import { getHttpBookmarkClient } from "@/http/bookmark";
-import { getHttpHighlightClient } from "@/http/highlight";
-
-export type TimelineInfo = HttpTimelineInfo;
-export type TimelineChangePropertyRequest = HttpTimelinePatchRequest;
-export type TimelineCreatePostRequest = HttpTimelinePostPostRequest;
-export type TimelineCreatePostContent = HttpTimelinePostPostRequestContent;
-export type TimelineCreatePostTextContent = HttpTimelinePostPostRequestTextContent;
-export type TimelineCreatePostImageContent = HttpTimelinePostPostRequestImageContent;
-
-export type TimelinePostTextContent = HttpTimelinePostTextContent;
-
-export interface TimelinePostImageContent {
- type: "image";
- data: Blob;
- etag: string;
-}
-
-export type TimelinePostContent =
- | TimelinePostTextContent
- | TimelinePostImageContent;
-
-export type TimelinePostInfo = Omit<HttpTimelinePostInfo, "content"> & {
- content: TimelinePostContent;
-};
+const timelineNameReg = XRegExp("^[-_\\p{L}]*$", "u");
-export interface TimelinePostsInfo {
- lastUpdated: Date;
- posts: TimelinePostInfo[];
+export function validateTimelineName(name: string): boolean {
+ return timelineNameReg.test(name);
}
export const timelineVisibilityTooltipTranslationMap: Record<
@@ -65,451 +15,3 @@ export const timelineVisibilityTooltipTranslationMap: Record<
Register: "timeline.visibilityTooltip.register",
Private: "timeline.visibilityTooltip.private",
};
-
-export class TimelineNameConflictError extends Error {}
-
-type TimelineData = Omit<HttpTimelineInfo, "owner" | "members"> & {
- owner: string;
- members: string[];
-};
-
-type TimelinePostData = Omit<TimelinePostInfo, "author"> & {
- author: string;
-};
-
-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}`)
- );
- await Promise.all(keys.map((k) => dataStorage.removeItem(k)));
- }
-
- private generateTimelineDataStorageKey(timelineName: string): string {
- return `timeline.${timelineName}`;
- }
-
- private convertHttpTimelineToData(timeline: HttpTimelineInfo): TimelineData {
- return {
- ...timeline,
- owner: timeline.owner.username,
- members: timeline.members.map((m) => m.username),
- };
- }
-
- readonly timelineHub = new DataHub2<string, HttpTimelineInfo | "notexist">({
- saveData: async (timelineName, data) => {
- if (data === "notexist") return;
-
- // TODO: Avoid save same user.
- void userInfoService.saveUser(data.owner);
- void userInfoService.saveUsers(data.members);
-
- 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;
-
- const owner = await userInfoService.getCachedUser(savedData.owner);
- if (owner == null) return null;
- const members = await userInfoService.getCachedUsers(savedData.members);
- if (members == null) return null;
-
- return { ...savedData, owner, members };
- },
- fetchData: async (timelineName, savedData) => {
- try {
- const timeline = await getHttpTimelineClient().getTimeline(
- timelineName
- );
-
- if (
- savedData != null &&
- savedData !== "notexist" &&
- savedData.uniqueId !== timeline.uniqueId
- ) {
- console.log(
- `Timeline with name ${timelineName} has changed to a new one. Clear old data.`
- );
-
- void this.clearTimelineData(timelineName); // If timeline has changed, clear all old data.
- }
-
- return timeline;
- } catch (e) {
- if (e instanceof HttpTimelineNotExistError) {
- return "notexist";
- } else if (e instanceof HttpNetworkError) {
- return null;
- } else {
- throw e;
- }
- }
- },
- });
-
- syncTimeline(timelineName: string): Promise<void> {
- return this.timelineHub.getLine(timelineName).sync();
- }
-
- createTimeline(timelineName: string): Observable<TimelineInfo> {
- return from(
- getHttpTimelineClient().postTimeline({
- name: timelineName,
- })
- ).pipe(
- convertError(HttpTimelineNameConflictError, TimelineNameConflictError)
- );
- }
-
- changeTimelineProperty(
- timelineName: string,
- req: TimelineChangePropertyRequest
- ): Promise<void> {
- return getHttpTimelineClient()
- .patchTimeline(timelineName, req)
- .then(() => {
- void this.syncTimeline(timelineName);
- });
- }
-
- deleteTimeline(timelineName: string): Observable<unknown> {
- return from(getHttpTimelineClient().deleteTimeline(timelineName));
- }
-
- addMember(timelineName: string, username: string): Promise<void> {
- return getHttpTimelineClient()
- .memberPut(timelineName, username)
- .then(() => {
- void this.syncTimeline(timelineName);
- });
- }
-
- removeMember(timelineName: string, username: string): Promise<void> {
- return getHttpTimelineClient()
- .memberDelete(timelineName, username)
- .then(() => {
- void this.syncTimeline(timelineName);
- });
- }
-
- private generatePostsDataStorageKey(timelineName: string): string {
- return `timeline.${timelineName}.posts`;
- }
-
- readonly postsHub = new DataHub2<
- string,
- TimelinePostsInfo | "notexist" | "forbid"
- >({
- saveData: async (timelineName, data) => {
- if (data === "notexist" || data === "forbid") return;
-
- const savedData: TimelinePostsData = {
- ...data,
- posts: data.posts.map((p) => ({ ...p, author: p.author.username })),
- };
-
- data.posts.forEach((p) => {
- void userInfoService.saveUser(p.author);
- });
-
- 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;
-
- 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 convertList = (
- posts: HttpTimelinePostInfo[]
- ): Promise<TimelinePostInfo[]> =>
- Promise.all(posts.map((p) => convert(p)));
-
- const now = new Date();
-
- try {
- if (
- savedData == null ||
- savedData === "forbid" ||
- savedData === "notexist"
- ) {
- const httpPosts = await getHttpTimelineClient().listPost(
- timelineName
- );
-
- return {
- lastUpdated: now,
- posts: await convertList(httpPosts),
- };
- } else {
- const httpPosts = await getHttpTimelineClient().listPost(
- timelineName,
- {
- modifiedSince: savedData.lastUpdated,
- includeDeleted: true,
- }
- );
-
- const deletedIds = httpPosts
- .filter((p) => p.deleted)
- .map((p) => p.id);
-
- const changed = await convertList(
- httpPosts.filter((p): p is HttpTimelinePostInfo => !p.deleted)
- );
-
- 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(await convert(changedPost));
- } else {
- posts[savedChangedPostIndex] = await convert(changedPost);
- }
- }
-
- return { lastUpdated: now, posts };
- }
- } catch (e) {
- if (e instanceof HttpTimelineNotExistError) {
- return "notexist";
- } else if (e instanceof HttpForbiddenError) {
- return "forbid";
- } else if (e instanceof HttpNetworkError) {
- return null;
- } else {
- throw e;
- }
- }
- },
- });
-
- syncPosts(timelineName: string): Promise<void> {
- return this.postsHub.getLine(timelineName).sync();
- }
-
- createPost(
- timelineName: string,
- request: TimelineCreatePostRequest
- ): Promise<void> {
- return getHttpTimelineClient()
- .postPost(timelineName, request)
- .then(() => {
- void this.syncPosts(timelineName);
- });
- }
-
- deletePost(timelineName: string, postId: number): Promise<void> {
- return getHttpTimelineClient()
- .deletePost(timelineName, postId)
- .then(() => {
- void this.syncPosts(timelineName);
- });
- }
-
- isMemberOf(username: string, timeline: TimelineInfo): boolean {
- return timeline.members.findIndex((m) => m.username == username) >= 0;
- }
-
- hasReadPermission(
- user: AuthUser | null | undefined,
- timeline: TimelineInfo
- ): boolean {
- if (user != null && user.hasAllTimelineAdministrationPermission)
- return true;
-
- const { visibility } = timeline;
- if (visibility === "Public") {
- return true;
- } else if (visibility === "Register") {
- if (user != null) return true;
- } else if (visibility === "Private") {
- if (
- user != null &&
- (user.username === timeline.owner.username ||
- this.isMemberOf(user.username, timeline))
- ) {
- return true;
- }
- }
- return false;
- }
-
- hasPostPermission(
- user: AuthUser | null | undefined,
- timeline: TimelineInfo
- ): boolean {
- if (user != null && user.hasAllTimelineAdministrationPermission)
- return true;
-
- return (
- user != null &&
- (timeline.owner.username === user.username ||
- this.isMemberOf(user.username, timeline))
- );
- }
-
- hasManagePermission(
- user: AuthUser | null | undefined,
- timeline: TimelineInfo
- ): boolean {
- if (user != null && user.hasAllTimelineAdministrationPermission)
- return true;
-
- return user != null && user.username == timeline.owner.username;
- }
-
- hasModifyPostPermission(
- user: AuthUser | null | undefined,
- timeline: TimelineInfo,
- post: TimelinePostInfo
- ): boolean {
- if (user != null && user.hasAllTimelineAdministrationPermission)
- return true;
-
- return (
- user != null &&
- (user.username === timeline.owner.username ||
- user.username === post.author.username)
- );
- }
-
- setHighlight(timelineName: string, highlight: boolean): Promise<void> {
- const client = getHttpHighlightClient();
- const promise = highlight
- ? client.put(timelineName)
- : client.delete(timelineName);
- return promise.then(() => {
- void timelineService.syncTimeline(timelineName);
- });
- }
-
- setBookmark(timelineName: string, bookmark: boolean): Promise<void> {
- const client = getHttpBookmarkClient();
- const promise = bookmark
- ? client.put(timelineName)
- : client.delete(timelineName);
- return promise.then(() => {
- void timelineService.syncTimeline(timelineName);
- });
- }
-}
-
-export const timelineService = new TimelineService();
-
-const timelineNameReg = XRegExp("^[-_\\p{L}]*$", "u");
-
-export function validateTimelineName(name: string): boolean {
- return timelineNameReg.test(name);
-}
-
-export function useTimeline(
- timelineName: string
-): DataAndStatus<TimelineInfo | "notexist"> {
- const [state, setState] = React.useState<
- DataAndStatus<TimelineInfo | "notexist">
- >({
- status: "syncing",
- data: null,
- });
- React.useEffect(() => {
- const subscription = timelineService.timelineHub
- .getLine(timelineName)
- .getObservalble()
- .subscribe((data) => {
- setState(data);
- });
- return () => {
- subscription.unsubscribe();
- };
- }, [timelineName]);
- return state;
-}
-
-export function usePosts(
- timelineName: string
-): DataAndStatus<TimelinePostsInfo | "notexist" | "forbid"> {
- const [state, setState] = React.useState<
- DataAndStatus<TimelinePostsInfo | "notexist" | "forbid">
- >({ status: "syncing", data: null });
- React.useEffect(() => {
- const subscription = timelineService.postsHub
- .getLine(timelineName)
- .getObservalble()
- .subscribe((data) => {
- setState(data);
- });
- return () => {
- subscription.unsubscribe();
- };
- }, [timelineName]);
- return state;
-}
-
-export async function getAllCachedTimelineNames(): Promise<string[]> {
- const keys = await dataStorage.keys();
- return keys
- .filter(
- (key) =>
- key.startsWith("timeline.") && (key.match(/\./g) ?? []).length === 1
- )
- .map((key) => key.substr("timeline.".length));
-}
diff --git a/FrontEnd/src/app/services/user.ts b/FrontEnd/src/app/services/user.ts
index 611a86ae..4814bf7c 100644
--- a/FrontEnd/src/app/services/user.ts
+++ b/FrontEnd/src/app/services/user.ts
@@ -1,33 +1,23 @@
-import React, { useState, useEffect } from "react";
-import { BehaviorSubject, Observable, from } from "rxjs";
+import { useState, useEffect } from "react";
+import { BehaviorSubject, Observable } from "rxjs";
import { UiLogicError } from "@/common";
-import {
- HttpNetworkError,
- BlobWithEtag,
- NotModified,
- setHttpToken,
-} from "@/http/common";
+import { HttpNetworkError, setHttpToken } from "@/http/common";
import {
getHttpTokenClient,
HttpCreateTokenBadCredentialError,
} from "@/http/token";
-import {
- getHttpUserClient,
- HttpUserNotExistError,
- HttpUser,
- UserPermission,
-} from "@/http/user";
+import { getHttpUserClient, HttpUser, UserPermission } from "@/http/user";
-import { DataHub2 } from "./DataHub2";
-import { dataStorage } from "./common";
import { pushAlert } from "./alert";
-export type User = HttpUser;
+interface IAuthUser extends HttpUser {
+ token: string;
+}
-export class AuthUser implements User {
- constructor(user: User, public token: string) {
+export class AuthUser implements IAuthUser {
+ constructor(user: HttpUser, public token: string) {
this.uniqueId = user.uniqueId;
this.username = user.username;
this.permissions = user.permissions;
@@ -87,9 +77,17 @@ export class UserService {
console.warn("Already checked user. Can't check twice.");
}
- const savedUser = await dataStorage.getItem<AuthUser | null>(
- USER_STORAGE_KEY
- );
+ const savedUserString = localStorage.getItem(USER_STORAGE_KEY);
+
+ const savedAuthUserData =
+ savedUserString == null
+ ? null
+ : (JSON.parse(savedUserString) as IAuthUser);
+
+ const savedUser =
+ savedAuthUserData == null
+ ? null
+ : new AuthUser(savedAuthUserData, savedAuthUserData.token);
if (savedUser == null) {
this.userSubject.next(null);
@@ -102,7 +100,7 @@ export class UserService {
try {
const res = await getHttpTokenClient().verify({ token: savedToken });
const user = new AuthUser(res.user, savedToken);
- await dataStorage.setItem<AuthUser>(USER_STORAGE_KEY, user);
+ localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user));
this.userSubject.next(user);
pushAlert({
type: "success",
@@ -120,7 +118,7 @@ export class UserService {
});
return savedUser;
} else {
- await dataStorage.removeItem(USER_STORAGE_KEY);
+ localStorage.removeItem(USER_STORAGE_KEY);
this.userSubject.next(null);
pushAlert({
type: "danger",
@@ -145,7 +143,7 @@ export class UserService {
});
const user = new AuthUser(res.user, res.token);
if (rememberMe) {
- await dataStorage.setItem<AuthUser>(USER_STORAGE_KEY, user);
+ localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user));
}
this.userSubject.next(user);
} catch (e) {
@@ -157,34 +155,29 @@ export class UserService {
}
}
- async logout(): Promise<void> {
+ logout(): Promise<void> {
if (this.currentUser === undefined) {
throw new UiLogicError("Please check user first.");
}
if (this.currentUser === null) {
throw new UiLogicError("No login.");
}
- await dataStorage.removeItem(USER_STORAGE_KEY);
+ localStorage.removeItem(USER_STORAGE_KEY);
this.userSubject.next(null);
+ return Promise.resolve();
}
- changePassword(
- oldPassword: string,
- newPassword: string
- ): Observable<unknown> {
+ changePassword(oldPassword: string, newPassword: string): Promise<void> {
if (this.currentUser == undefined) {
throw new UiLogicError("Not login or checked now, can't log out.");
}
- const $ = from(
- getHttpUserClient().changePassword({
+
+ return getHttpUserClient()
+ .changePassword({
oldPassword,
newPassword,
})
- );
- $.subscribe(() => {
- void this.logout();
- });
- return $;
+ .then(() => this.logout());
}
}
@@ -236,156 +229,3 @@ export function useUserLoggedIn(): AuthUser {
}
return user;
}
-
-export function checkLogin(): AuthUser {
- const user = userService.currentUser;
- if (user == null) {
- throw new UiLogicError("You must login to perform the operation.");
- }
- return user;
-}
-
-export class UserNotExistError extends Error {}
-
-export class UserInfoService {
- saveUser(user: HttpUser): Promise<void> {
- return this.userHub.getLine(user.username).save(user);
- }
-
- saveUsers(users: HttpUser[]): Promise<void> {
- return Promise.all(users.map((user) => this.saveUser(user))).then();
- }
-
- async getCachedUser(username: string): Promise<HttpUser | null> {
- const user = await this.userHub.getLine(username).getSavedData();
- if (user == null || user === "notexist") return null;
- return user;
- }
-
- async getCachedUsers(usernames: string[]): Promise<HttpUser[] | null> {
- const users = await Promise.all(
- usernames.map((username) => this.userHub.getLine(username).getSavedData())
- );
-
- for (const u of users) {
- if (u == null || u === "notexist") {
- return null;
- }
- }
-
- return users as HttpUser[];
- }
-
- 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 {
- return await getHttpUserClient().get(username);
- } catch (e) {
- if (e instanceof HttpUserNotExistError) {
- return "notexist";
- } else if (e instanceof HttpNetworkError) {
- return null;
- }
- throw e;
- }
- },
- });
-
- private generateAvatarDataStorageKey(username: string): string {
- return `user.${username}.avatar`;
- }
-
- 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) {
- return savedData;
- } else {
- return res;
- }
- }
- } catch (e) {
- if (e instanceof HttpUserNotExistError) {
- return "notexist";
- } else if (e instanceof HttpNetworkError) {
- return null;
- } else {
- throw e;
- }
- }
- },
- });
-
- async setAvatar(username: string, blob: Blob): Promise<void> {
- const etag = await getHttpUserClient().putAvatar(username, blob);
- await this.avatarHub.getLine(username).save({ data: blob, etag });
- }
-
- async setNickname(username: string, nickname: string): Promise<void> {
- return getHttpUserClient()
- .patch(username, { nickname })
- .then((user) => this.saveUser(user));
- }
-}
-
-export const userInfoService = new UserInfoService();
-
-export function useAvatar(username?: string): Blob | undefined {
- const [state, setState] = React.useState<Blob | undefined>(undefined);
- React.useEffect(() => {
- if (username == null) {
- setState(undefined);
- return;
- }
-
- 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 21ad6304..4f2a6ecd 100644
--- a/FrontEnd/src/app/utilities/url.ts
+++ b/FrontEnd/src/app/utilities/url.ts
@@ -4,7 +4,8 @@ export function applyQueryParameters<T>(url: string, query: T): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
- if (typeof value === "string") params.set(key, value);
+ if (value == null) void 0;
+ else 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());
diff --git a/FrontEnd/src/app/views/admin/UserAdmin.tsx b/FrontEnd/src/app/views/admin/UserAdmin.tsx
index fbdfd5a3..369eaf1e 100644
--- a/FrontEnd/src/app/views/admin/UserAdmin.tsx
+++ b/FrontEnd/src/app/views/admin/UserAdmin.tsx
@@ -6,7 +6,7 @@ import OperationDialog, {
OperationBoolInputInfo,
} from "../common/OperationDialog";
-import { User, AuthUser } from "@/services/user";
+import { AuthUser } from "@/services/user";
import {
getHttpUserClient,
HttpUser,
@@ -199,7 +199,7 @@ type ContextMenuItem = TModify | TModifyPermission | TDelete;
interface UserItemProps {
on: { [key in ContextMenuItem]: () => void };
- user: User;
+ user: HttpUser;
}
const UserItem: React.FC<UserItemProps> = ({ user, on }) => {
@@ -273,7 +273,7 @@ const UserAdmin: React.FC<UserAdminProps> = (props) => {
}
| { type: TDelete; username: string };
- const [users, setUsers] = useState<User[] | null>(null);
+ const [users, setUsers] = useState<HttpUser[] | null>(null);
const [dialog, setDialog] = useState<DialogInfo>(null);
const [usersVersion, setUsersVersion] = useState<number>(0);
const updateUsers = (): void => {
diff --git a/FrontEnd/src/app/views/common/AppBar.tsx b/FrontEnd/src/app/views/common/AppBar.tsx
index d0e39f98..e682a308 100644
--- a/FrontEnd/src/app/views/common/AppBar.tsx
+++ b/FrontEnd/src/app/views/common/AppBar.tsx
@@ -4,14 +4,13 @@ import { LinkContainer } from "react-router-bootstrap";
import { Navbar, Nav } from "react-bootstrap";
import { NavLink } from "react-router-dom";
-import { useUser, useAvatar } from "@/services/user";
+import { useUser } from "@/services/user";
import TimelineLogo from "./TimelineLogo";
-import BlobImage from "./BlobImage";
+import UserAvatar from "./user/UserAvatar";
const AppBar: React.FC = (_) => {
const user = useUser();
- const avatar = useAvatar(user?.username);
const { t } = useTranslation();
@@ -70,10 +69,9 @@ const AppBar: React.FC = (_) => {
<Nav className="ml-auto mr-2 align-items-center">
{user != null ? (
<LinkContainer to={`/users/${user.username}`}>
- <BlobImage
+ <UserAvatar
+ username={user.username}
className="avatar small rounded-circle bg-white cursor-pointer ml-auto"
- onClick={collapse}
- blob={avatar}
/>
</LinkContainer>
) : (
diff --git a/FrontEnd/src/app/views/common/user/UserAvatar.tsx b/FrontEnd/src/app/views/common/user/UserAvatar.tsx
index 73273298..9e822528 100644
--- a/FrontEnd/src/app/views/common/user/UserAvatar.tsx
+++ b/FrontEnd/src/app/views/common/user/UserAvatar.tsx
@@ -1,8 +1,6 @@
import React from "react";
-import { useAvatar } from "@/services/user";
-
-import BlobImage from "../BlobImage";
+import { getHttpUserClient } from "@/http/user";
export interface UserAvatarProps
extends React.ImgHTMLAttributes<HTMLImageElement> {
@@ -10,9 +8,12 @@ export interface UserAvatarProps
}
const UserAvatar: React.FC<UserAvatarProps> = ({ username, ...otherProps }) => {
- const avatar = useAvatar(username);
-
- return <BlobImage blob={avatar} {...otherProps} />;
+ return (
+ <img
+ src={getHttpUserClient().generateAvatarUrl(username)}
+ {...otherProps}
+ />
+ );
};
export default UserAvatar;
diff --git a/FrontEnd/src/app/views/home/OfflineBoard.tsx b/FrontEnd/src/app/views/home/OfflineBoard.tsx
deleted file mode 100644
index fc05bd74..00000000
--- a/FrontEnd/src/app/views/home/OfflineBoard.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import React from "react";
-import { Link } from "react-router-dom";
-import { Trans } from "react-i18next";
-
-import { getAllCachedTimelineNames } from "@/services/timeline";
-import UserTimelineLogo from "../common/UserTimelineLogo";
-import TimelineLogo from "../common/TimelineLogo";
-
-export interface OfflineBoardProps {
- onReload: () => void;
-}
-
-const OfflineBoard: React.FC<OfflineBoardProps> = ({ onReload }) => {
- const [timelines, setTimelines] = React.useState<string[]>([]);
-
- React.useEffect(() => {
- let subscribe = true;
- void getAllCachedTimelineNames().then((t) => {
- if (subscribe) setTimelines(t);
- });
- return () => {
- subscribe = false;
- };
- });
-
- return (
- <>
- <Trans i18nKey="home.offlinePrompt">
- 0
- <a
- href="#"
- onClick={(e) => {
- onReload();
- e.preventDefault();
- }}
- >
- 1
- </a>
- 2
- </Trans>
- {timelines.map((timeline) => {
- const isPersonal = timeline.startsWith("@");
- const url = isPersonal
- ? `/users/${timeline.slice(1)}`
- : `/timelines/${timeline}`;
- return (
- <div key={timeline} className="timeline-board-item">
- {isPersonal ? (
- <UserTimelineLogo className="icon" />
- ) : (
- <TimelineLogo className="icon" />
- )}
- <Link to={url}>{timeline}</Link>
- </div>
- );
- })}
- </>
- );
-};
-
-export default OfflineBoard;
diff --git a/FrontEnd/src/app/views/home/TimelineBoard.tsx b/FrontEnd/src/app/views/home/TimelineBoard.tsx
index c3f01aed..58988b17 100644
--- a/FrontEnd/src/app/views/home/TimelineBoard.tsx
+++ b/FrontEnd/src/app/views/home/TimelineBoard.tsx
@@ -4,10 +4,10 @@ import { Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { Spinner } from "react-bootstrap";
-import { TimelineInfo } from "@/services/timeline";
+import { HttpTimelineInfo } from "@/http/timeline";
+
import TimelineLogo from "../common/TimelineLogo";
import UserTimelineLogo from "../common/UserTimelineLogo";
-import { HttpTimelineInfo } from "@/http/timeline";
interface TimelineBoardItemProps {
timeline: HttpTimelineInfo;
@@ -98,7 +98,7 @@ const TimelineBoardItem: React.FC<TimelineBoardItemProps> = ({
};
interface TimelineBoardItemContainerProps {
- timelines: TimelineInfo[];
+ timelines: HttpTimelineInfo[];
editHandler?: {
// offset may exceed index range plusing index.
onMove: (timeline: string, index: number, offset: number) => void;
@@ -206,7 +206,7 @@ const TimelineBoardItemContainer: React.FC<TimelineBoardItemContainerProps> = ({
interface TimelineBoardUIProps {
title?: string;
- timelines: TimelineInfo[] | "offline" | "loading";
+ timelines: HttpTimelineInfo[] | "offline" | "loading";
onReload: () => void;
className?: string;
editHandler?: {
@@ -304,7 +304,7 @@ const TimelineBoardUI: React.FC<TimelineBoardUIProps> = (props) => {
export interface TimelineBoardProps {
title?: string;
className?: string;
- load: () => Promise<TimelineInfo[]>;
+ load: () => Promise<HttpTimelineInfo[]>;
editHandler?: {
onMove: (timeline: string, index: number, offset: number) => Promise<void>;
onDelete: (timeline: string) => Promise<void>;
@@ -318,7 +318,7 @@ const TimelineBoard: React.FC<TimelineBoardProps> = ({
editHandler,
}) => {
const [timelines, setTimelines] = React.useState<
- TimelineInfo[] | "offline" | "loading"
+ HttpTimelineInfo[] | "offline" | "loading"
>("loading");
React.useEffect(() => {
diff --git a/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx
index 5dcba612..b4e25ba1 100644
--- a/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx
+++ b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx
@@ -1,12 +1,9 @@
import React from "react";
import { useHistory } from "react-router";
-import {
- validateTimelineName,
- timelineService,
- TimelineInfo,
-} from "@/services/timeline";
+import { validateTimelineName } from "@/services/timeline";
import OperationDialog from "../common/OperationDialog";
+import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
interface TimelineCreateDialogProps {
open: boolean;
@@ -42,10 +39,10 @@ const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => {
return null;
}
}}
- onProcess={([name]): Promise<TimelineInfo> => {
- return timelineService.createTimeline(name).toPromise();
- }}
- onSuccessAndClose={(timeline: TimelineInfo) => {
+ onProcess={([name]): Promise<HttpTimelineInfo> =>
+ getHttpTimelineClient().postTimeline({ name })
+ }
+ onSuccessAndClose={(timeline: HttpTimelineInfo) => {
history.push(`timelines/${timeline.name}`);
}}
failurePrompt={(e) => `${e as string}`}
diff --git a/FrontEnd/src/app/views/search/index.tsx b/FrontEnd/src/app/views/search/index.tsx
index 41f1e6b6..8401f26c 100644
--- a/FrontEnd/src/app/views/search/index.tsx
+++ b/FrontEnd/src/app/views/search/index.tsx
@@ -6,15 +6,14 @@ import { Link } from "react-router-dom";
import { HttpNetworkError } from "@/http/common";
import { getHttpSearchClient } from "@/http/search";
-
-import { TimelineInfo } from "@/services/timeline";
+import { HttpTimelineInfo } from "@/http/timeline";
import SearchInput from "../common/SearchInput";
import UserAvatar from "../common/user/UserAvatar";
-const TimelineSearchResultItemView: React.FC<{ timeline: TimelineInfo }> = ({
- timeline,
-}) => {
+const TimelineSearchResultItemView: React.FC<{
+ timeline: HttpTimelineInfo;
+}> = ({ timeline }) => {
const link = timeline.name.startsWith("@")
? `users/${timeline.owner.username}`
: `timelines/${timeline.name}`;
@@ -51,7 +50,7 @@ const SearchPage: React.FC = () => {
const [searchText, setSearchText] = React.useState<string>("");
const [state, setState] = React.useState<
- TimelineInfo[] | "init" | "loading" | "network-error" | "error"
+ HttpTimelineInfo[] | "init" | "loading" | "network-error" | "error"
>("init");
const [forceResearchKey, setForceResearchKey] = React.useState<number>(0);
diff --git a/FrontEnd/src/app/views/settings/index.tsx b/FrontEnd/src/app/views/settings/index.tsx
index 0a85df83..ccba59b7 100644
--- a/FrontEnd/src/app/views/settings/index.tsx
+++ b/FrontEnd/src/app/views/settings/index.tsx
@@ -53,8 +53,7 @@ const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => {
return result;
}}
onProcess={async ([oldPassword, newPassword]) => {
- await userService.changePassword(oldPassword, newPassword).toPromise();
- await userService.logout();
+ await userService.changePassword(oldPassword, newPassword);
setRedirect(true);
}}
close={() => {
diff --git a/FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx b/FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx
deleted file mode 100644
index e67cfb43..00000000
--- a/FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-import { useTranslation } from "react-i18next";
-
-import { UiLogicError } from "@/common";
-
-export type TimelineSyncStatus = "syncing" | "synced" | "offline";
-
-const SyncStatusBadge: React.FC<{
- status: TimelineSyncStatus;
- style?: React.CSSProperties;
- className?: string;
-}> = ({ status, style, className }) => {
- const { t } = useTranslation();
-
- return (
- <div style={style} className={clsx("timeline-sync-state-badge", className)}>
- {(() => {
- switch (status) {
- case "syncing": {
- return (
- <>
- <span className="timeline-sync-state-badge-pin bg-warning" />
- <span className="text-warning">
- {t("timeline.postSyncState.syncing")}
- </span>
- </>
- );
- }
- case "synced": {
- return (
- <>
- <span className="timeline-sync-state-badge-pin bg-success" />
- <span className="text-success">
- {t("timeline.postSyncState.synced")}
- </span>
- </>
- );
- }
- case "offline": {
- return (
- <>
- <span className="timeline-sync-state-badge-pin bg-danger" />
- <span className="text-danger">
- {t("timeline.postSyncState.offline")}
- </span>
- </>
- );
- }
- default:
- throw new UiLogicError("Unknown sync state.");
- }
- })()}
- </div>
- );
-};
-
-export default SyncStatusBadge;
diff --git a/FrontEnd/src/app/views/timeline-common/Timeline.tsx b/FrontEnd/src/app/views/timeline-common/Timeline.tsx
index 288be141..d41588bb 100644
--- a/FrontEnd/src/app/views/timeline-common/Timeline.tsx
+++ b/FrontEnd/src/app/views/timeline-common/Timeline.tsx
@@ -1,116 +1,98 @@
import React from "react";
-import clsx from "clsx";
import {
- TimelineInfo,
- TimelinePostInfo,
- timelineService,
-} from "@/services/timeline";
-import { useUser } from "@/services/user";
-import { pushAlert } from "@/services/alert";
+ HttpForbiddenError,
+ HttpNetworkError,
+ HttpNotFoundError,
+} from "@/http/common";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
-import TimelineItem from "./TimelineItem";
-import TimelineTop from "./TimelineTop";
-import TimelineDateItem from "./TimelineDateItem";
-
-function dateEqual(left: Date, right: Date): boolean {
- return (
- left.getDate() == right.getDate() &&
- left.getMonth() == right.getMonth() &&
- left.getFullYear() == right.getFullYear()
- );
-}
+import TimelinePostListView from "./TimelinePostListView";
export interface TimelineProps {
className?: string;
style?: React.CSSProperties;
- timeline: TimelineInfo;
- posts: TimelinePostInfo[];
+ timelineName: string;
+ reloadKey: number;
+ onReload: () => void;
}
const Timeline: React.FC<TimelineProps> = (props) => {
- const { timeline, posts } = props;
+ const { timelineName, className, style, reloadKey, onReload } = props;
- const user = useUser();
+ const [posts, setPosts] = React.useState<
+ | HttpTimelinePostInfo[]
+ | "loading"
+ | "offline"
+ | "notexist"
+ | "forbid"
+ | "error"
+ >("loading");
- const [showMoreIndex, setShowMoreIndex] = React.useState<number>(-1);
+ React.useEffect(() => {
+ let subscribe = true;
- const groupedPosts = React.useMemo<
- { date: Date; posts: (TimelinePostInfo & { index: number })[] }[]
- >(() => {
- const result: {
- date: Date;
- posts: (TimelinePostInfo & { index: number })[];
- }[] = [];
- let index = 0;
- for (const post of posts) {
- const { time } = post;
- if (result.length === 0) {
- result.push({ date: time, posts: [{ ...post, index }] });
- } else {
- const lastGroup = result[result.length - 1];
- if (dateEqual(lastGroup.date, time)) {
- lastGroup.posts.push({ ...post, index });
- } else {
- result.push({ date: time, posts: [{ ...post, index }] });
+ setPosts("loading");
+
+ void getHttpTimelineClient()
+ .listPost(timelineName)
+ .then(
+ (data) => {
+ if (subscribe) setPosts(data);
+ },
+ (error) => {
+ if (error instanceof HttpNetworkError) {
+ setPosts("offline");
+ } else if (error instanceof HttpForbiddenError) {
+ setPosts("forbid");
+ } else if (error instanceof HttpNotFoundError) {
+ setPosts("notexist");
+ } else {
+ console.error(error);
+ setPosts("error");
+ }
}
- }
- index++;
- }
- return result;
- }, [posts]);
+ );
+
+ return () => {
+ subscribe = false;
+ };
+ }, [timelineName, reloadKey]);
- return (
- <div style={props.style} className={clsx("timeline", props.className)}>
- <TimelineTop height="56px" />
- {groupedPosts.map((group) => {
- return (
- <>
- <TimelineDateItem date={group.date} />
- {group.posts.map((post) => {
- const deletable = timelineService.hasModifyPostPermission(
- user,
- timeline,
- post
- );
- return (
- <TimelineItem
- post={post}
- key={post.id}
- current={posts.length - 1 === post.index}
- more={
- deletable
- ? {
- isOpen: showMoreIndex === post.index,
- toggle: () =>
- setShowMoreIndex((old) =>
- old === post.index ? -1 : post.index
- ),
- onDelete: () => {
- timelineService
- .deletePost(timeline.name, post.id)
- .catch(() => {
- pushAlert({
- type: "danger",
- message: {
- type: "i18n",
- key: "timeline.deletePostFailed",
- },
- });
- });
- },
- }
- : undefined
- }
- onClick={() => setShowMoreIndex(-1)}
- />
- );
- })}
- </>
- );
- })}
- </div>
- );
+ switch (posts) {
+ case "loading":
+ return (
+ <div className={className} style={style}>
+ Loading.
+ </div>
+ );
+ case "offline":
+ return (
+ <div className={className} style={style}>
+ Offline.
+ </div>
+ );
+ case "notexist":
+ return (
+ <div className={className} style={style}>
+ Not exist.
+ </div>
+ );
+ case "forbid":
+ return (
+ <div className={className} style={style}>
+ Forbid.
+ </div>
+ );
+ case "error":
+ return (
+ <div className={className} style={style}>
+ Error.
+ </div>
+ );
+ default:
+ return <TimelinePostListView posts={posts} onReload={onReload} />;
+ }
};
export default Timeline;
diff --git a/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx
index b9f296c5..d6eaa16c 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx
@@ -3,16 +3,15 @@ import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Dropdown, Button } from "react-bootstrap";
-import {
- timelineService,
- timelineVisibilityTooltipTranslationMap,
-} from "@/services/timeline";
+import { getHttpHighlightClient } from "@/http/highlight";
+import { getHttpBookmarkClient } from "@/http/bookmark";
-import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
-import SyncStatusBadge from "../timeline-common/SyncStatusBadge";
-import CollapseButton from "../timeline-common/CollapseButton";
import { useUser } from "@/services/user";
import { pushAlert } from "@/services/alert";
+import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
+
+import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
+import CollapseButton from "../timeline-common/CollapseButton";
export interface TimelineCardTemplateProps
extends Omit<TimelineCardComponentProps<"">, "operations"> {
@@ -39,7 +38,6 @@ function TimelineCardTemplate({
infoArea,
manageArea,
toggleCollapse,
- syncStatus,
className,
}: TimelineCardTemplateProps): React.ReactElement | null {
const { t } = useTranslation();
@@ -49,7 +47,6 @@ function TimelineCardTemplate({
return (
<div className={clsx("cru-card p-2 clearfix", className)}>
<div className="float-right d-flex align-items-center">
- <SyncStatusBadge status={syncStatus} className="mr-2" />
<CollapseButton collapse={collapse} onClick={toggleCollapse} />
</div>
<div style={{ display: collapse ? "none" : "block" }}>
@@ -67,8 +64,8 @@ function TimelineCardTemplate({
onClick={
user != null && user.hasHighlightTimelineAdministrationPermission
? () => {
- timelineService
- .setHighlight(timeline.name, !timeline.isHighlight)
+ getHttpHighlightClient()
+ [timeline.isHighlight ? "delete" : "put"](timeline.name)
.catch(() => {
pushAlert({
message: {
@@ -91,8 +88,8 @@ function TimelineCardTemplate({
"icon-button text-yellow mr-3"
)}
onClick={() => {
- timelineService
- .setBookmark(timeline.name, !timeline.isBookmark)
+ getHttpBookmarkClient()
+ [timeline.isBookmark ? "delete" : "put"](timeline.name)
.catch(() => {
pushAlert({
message: {
diff --git a/FrontEnd/src/app/views/timeline-common/TimelineDateItem.tsx b/FrontEnd/src/app/views/timeline-common/TimelineDateLabel.tsx
index bcc1530f..ae1b7386 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelineDateItem.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelineDateLabel.tsx
@@ -5,7 +5,7 @@ export interface TimelineDateItemProps {
date: Date;
}
-const TimelineDateItem: React.FC<TimelineDateItemProps> = ({ date }) => {
+const TimelineDateLabel: React.FC<TimelineDateItemProps> = ({ date }) => {
return (
<div className="timeline-date-item">
<TimelineLine center={null} />
@@ -16,4 +16,4 @@ const TimelineDateItem: React.FC<TimelineDateItemProps> = ({ date }) => {
);
};
-export default TimelineDateItem;
+export default TimelineDateLabel;
diff --git a/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
index 9660b2aa..51512f15 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
@@ -2,17 +2,17 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap";
-import { getHttpSearchClient } from "@/http/search";
+import { convertI18nText, I18nText } from "@/common";
-import { User } from "@/services/user";
-import { TimelineInfo, timelineService } from "@/services/timeline";
+import { HttpUser } from "@/http/user";
+import { getHttpSearchClient } from "@/http/search";
import SearchInput from "../common/SearchInput";
import UserAvatar from "../common/user/UserAvatar";
-import { convertI18nText, I18nText } from "@/common";
+import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
const TimelineMemberItem: React.FC<{
- user: User;
+ user: HttpUser;
add?: boolean;
onAction?: (username: string) => void;
}> = ({ user, add, onAction }) => {
@@ -46,16 +46,17 @@ const TimelineMemberItem: React.FC<{
);
};
-const TimelineMemberUserSearch: React.FC<{ timeline: TimelineInfo }> = ({
- timeline,
-}) => {
+const TimelineMemberUserSearch: React.FC<{
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
+}> = ({ timeline, onChange }) => {
const { t } = useTranslation();
const [userSearchText, setUserSearchText] = useState<string>("");
const [userSearchState, setUserSearchState] = useState<
| {
type: "users";
- data: User[];
+ data: HttpUser[];
}
| { type: "error"; data: I18nText }
| { type: "loading" }
@@ -115,11 +116,12 @@ const TimelineMemberUserSearch: React.FC<{ timeline: TimelineInfo }> = ({
user={user}
add
onAction={() => {
- void timelineService
- .addMember(timeline.name, user.username)
+ void getHttpTimelineClient()
+ .memberPut(timeline.name, user.username)
.then(() => {
setUserSearchText("");
setUserSearchState({ type: "init" });
+ onChange();
});
}}
/>
@@ -140,12 +142,12 @@ const TimelineMemberUserSearch: React.FC<{ timeline: TimelineInfo }> = ({
};
export interface TimelineMemberProps {
- timeline: TimelineInfo;
- editable: boolean;
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
}
const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
- const { timeline, editable } = props;
+ const { timeline, onChange } = props;
const members = [timeline.owner, ...timeline.members];
return (
@@ -156,19 +158,20 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
key={member.username}
user={member}
onAction={
- editable && index !== 0
+ timeline.manageable && index !== 0
? () => {
- void timelineService.removeMember(
- timeline.name,
- member.username
- );
+ void getHttpTimelineClient()
+ .memberDelete(timeline.name, member.username)
+ .then(onChange);
}
: undefined
}
/>
))}
</ListGroup>
- {editable ? <TimelineMemberUserSearch timeline={timeline} /> : null}
+ {timeline.manageable ? (
+ <TimelineMemberUserSearch timeline={timeline} onChange={onChange} />
+ ) : null}
</Container>
);
};
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
index 9b76635e..6a8dd63c 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
@@ -1,21 +1,13 @@
import React from "react";
import { UiLogicError } from "@/common";
-import { useUser } from "@/services/user";
-import {
- TimelinePostInfo,
- timelineService,
- usePosts,
- useTimeline,
-} from "@/services/timeline";
-import { mergeDataStatus } from "@/services/DataHub2";
+
+import { HttpNetworkError, HttpNotFoundError } from "@/http/common";
+import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
import { TimelineMemberDialog } from "./TimelineMember";
import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
-import {
- TimelinePageTemplateUIOperations,
- TimelinePageTemplateUIProps,
-} from "./TimelinePageTemplateUI";
+import { TimelinePageTemplateUIProps } from "./TimelinePageTemplateUI";
export interface TimelinePageTemplateProps<TManageItem> {
name: string;
@@ -24,102 +16,67 @@ export interface TimelinePageTemplateProps<TManageItem> {
Omit<TimelinePageTemplateUIProps<TManageItem>, "CardComponent">
>;
notFoundI18nKey: string;
+ reloadKey: number;
+ onReload: () => void;
}
export default function TimelinePageTemplate<TManageItem>(
props: TimelinePageTemplateProps<TManageItem>
): React.ReactElement | null {
- const { name } = props;
-
- const service = timelineService;
-
- const user = useUser();
+ const { name, reloadKey, onReload } = props;
const [dialog, setDialog] = React.useState<null | "property" | "member">(
null
);
- const [scrollBottomKey, setScrollBottomKey] = React.useState<number>(0);
+ // TODO: Auto scroll.
+ // const [scrollBottomKey, _setScrollBottomKey] = React.useState<number>(0);
- React.useEffect(() => {
- if (scrollBottomKey > 0) {
- window.scrollTo(0, document.body.scrollHeight);
- }
- }, [scrollBottomKey]);
+ // React.useEffect(() => {
+ // if (scrollBottomKey > 0) {
+ // window.scrollTo(0, document.body.scrollHeight);
+ // }
+ // }, [scrollBottomKey]);
- const timelineAndStatus = useTimeline(name);
- const postsAndState = usePosts(name);
-
- const [
- scrollToBottomNextSyncKey,
- setScrollToBottomNextSyncKey,
- ] = React.useState<number>(0);
-
- const scrollToBottomNextSync = (): void => {
- setScrollToBottomNextSyncKey((old) => old + 1);
- };
+ const [timeline, setTimeline] = React.useState<
+ HttpTimelineInfo | "loading" | "offline" | "notexist" | "error"
+ >("loading");
React.useEffect(() => {
+ setTimeline("loading");
+
let subscribe = true;
- void timelineService.syncPosts(name).then(() => {
- if (subscribe) {
- setScrollBottomKey((old) => old + 1);
- }
- });
+ void getHttpTimelineClient()
+ .getTimeline(name)
+ .then(
+ (data) => {
+ if (subscribe) {
+ setTimeline(data);
+ }
+ },
+ (error) => {
+ if (subscribe) {
+ if (error instanceof HttpNetworkError) {
+ setTimeline("offline");
+ } else if (error instanceof HttpNotFoundError) {
+ setTimeline("notexist");
+ } else {
+ console.error(error);
+ setTimeline("error");
+ }
+ }
+ }
+ );
return () => {
subscribe = false;
};
- }, [name, scrollToBottomNextSyncKey]);
-
- const uiTimelineProp = ((): TimelinePageTemplateUIProps<TManageItem>["timeline"] => {
- const { status, data: timeline } = timelineAndStatus;
- if (timeline == null) {
- if (status === "offline") {
- return "offline";
- } else {
- return undefined;
- }
- } else if (timeline === "notexist") {
- return "notexist";
- } else {
- const operations: TimelinePageTemplateUIOperations<TManageItem> = {
- onPost: service.hasPostPermission(user, timeline)
- ? (req) =>
- service.createPost(name, req).then(() => scrollToBottomNextSync())
- : undefined,
- onManage: service.hasManagePermission(user, timeline)
- ? (item) => {
- if (item === "property") {
- setDialog(item);
- } else {
- props.onManage(item);
- }
- }
- : undefined,
- onMember: () => setDialog("member"),
- };
-
- const posts = ((): TimelinePostInfo[] | "forbid" | undefined => {
- const { data: postsInfo } = postsAndState;
- if (postsInfo === "forbid") {
- return "forbid";
- } else if (postsInfo == null || postsInfo === "notexist") {
- return undefined;
- } else {
- return postsInfo.posts;
- }
- })();
+ }, [name, reloadKey]);
- return { ...timeline, operations, posts };
- }
- })();
-
- const timeline = timelineAndStatus?.data;
let dialogElement: React.ReactElement | undefined;
const closeDialog = (): void => setDialog(null);
if (dialog === "property") {
- if (timeline == null || timeline === "notexist") {
+ if (typeof timeline !== "object") {
throw new UiLogicError(
"Timeline is null but attempt to open change property dialog."
);
@@ -130,11 +87,11 @@ export default function TimelinePageTemplate<TManageItem>(
open
close={closeDialog}
timeline={timeline}
- onProcess={(req) => service.changeTimelineProperty(name, req)}
+ onChange={onReload}
/>
);
} else if (dialog === "member") {
- if (timeline == null || timeline === "notexist") {
+ if (typeof timeline !== "object") {
throw new UiLogicError(
"Timeline is null but attempt to open change property dialog."
);
@@ -145,7 +102,7 @@ export default function TimelinePageTemplate<TManageItem>(
open
onClose={closeDialog}
timeline={timeline}
- editable={service.hasManagePermission(user, timeline)}
+ onChange={onReload}
/>
);
}
@@ -155,11 +112,25 @@ export default function TimelinePageTemplate<TManageItem>(
return (
<>
<UiComponent
- timeline={uiTimelineProp}
- syncStatus={mergeDataStatus([
- timelineAndStatus.status,
- postsAndState.status,
- ])}
+ timeline={
+ typeof timeline === "object"
+ ? {
+ ...timeline,
+ operations: {
+ onManage: timeline.manageable
+ ? (item) => {
+ if (item === "property") {
+ setDialog(item);
+ } else {
+ props.onManage(item);
+ }
+ }
+ : undefined,
+ onMember: () => setDialog("member"),
+ },
+ }
+ : timeline
+ }
notExistMessageI18nKey={props.notFoundI18nKey}
/>
{dialogElement}
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
index ed21d6b5..d133bd34 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
@@ -3,15 +3,14 @@ import { useTranslation } from "react-i18next";
import { Spinner } from "react-bootstrap";
import { getAlertHost } from "@/services/alert";
-import { TimelineInfo, TimelinePostInfo } from "@/services/timeline";
+
+import { HttpTimelineInfo } from "@/http/timeline";
import Timeline from "./Timeline";
-import TimelinePostEdit, { TimelinePostSendCallback } from "./TimelinePostEdit";
-import { TimelineSyncStatus } from "./SyncStatusBadge";
+import TimelinePostEdit from "./TimelinePostEdit";
export interface TimelineCardComponentProps<TManageItems> {
- timeline: TimelineInfo;
- syncStatus: TimelineSyncStatus;
+ timeline: HttpTimelineInfo;
operations: {
onManage?: (item: TManageItems | "property") => void;
onMember: () => void;
@@ -26,18 +25,17 @@ export interface TimelinePageTemplateUIOperations<TManageItems> {
onMember: () => void;
onBookmark?: () => void;
onHighlight?: () => void;
- onPost?: TimelinePostSendCallback;
}
export interface TimelinePageTemplateUIProps<TManageItems> {
- timeline?:
- | (TimelineInfo & {
+ timeline:
+ | (HttpTimelineInfo & {
operations: TimelinePageTemplateUIOperations<TManageItems>;
- posts?: TimelinePostInfo[] | "forbid";
})
| "notexist"
- | "offline";
- syncStatus: TimelineSyncStatus;
+ | "offline"
+ | "loading"
+ | "error";
notExistMessageI18nKey: string;
CardComponent: React.ComponentType<TimelineCardComponentProps<TManageItems>>;
}
@@ -45,12 +43,15 @@ export interface TimelinePageTemplateUIProps<TManageItems> {
export default function TimelinePageTemplateUI<TManageItems>(
props: TimelinePageTemplateUIProps<TManageItems>
): React.ReactElement | null {
- const { timeline, syncStatus, CardComponent } = props;
+ const { timeline, CardComponent } = props;
const { t } = useTranslation();
const [bottomSpaceHeight, setBottomSpaceHeight] = React.useState<number>(0);
+ const [timelineReloadKey, setTimelineReloadKey] = React.useState<number>(0);
+ const reloadTimeline = (): void => setTimelineReloadKey((old) => old + 1);
+
const onPostEditHeightChange = React.useCallback((height: number): void => {
setBottomSpaceHeight(height);
if (height === 0) {
@@ -93,7 +94,7 @@ export default function TimelinePageTemplateUI<TManageItems>(
let body: React.ReactElement;
- if (timeline == null) {
+ if (timeline == "loading") {
body = (
<div className="full-viewport-center-child">
<Spinner variant="primary" animation="grow" />
@@ -104,37 +105,33 @@ export default function TimelinePageTemplateUI<TManageItems>(
body = <p className="text-danger">Offline!</p>;
} else if (timeline === "notexist") {
body = <p className="text-danger">{t(props.notExistMessageI18nKey)}</p>;
+ } else if (timeline === "error") {
+ // TODO: i18n
+ body = <p className="text-danger">Error!</p>;
} else {
- const { operations, posts } = timeline;
+ const { operations } = timeline;
body = (
<>
<CardComponent
className="timeline-template-card"
timeline={timeline}
operations={operations}
- syncStatus={syncStatus}
collapse={cardCollapse}
toggleCollapse={toggleCardCollapse}
/>
- {posts != null ? (
- posts === "forbid" ? (
- <div>{t("timeline.messageCantSee")}</div>
- ) : (
- <div
- className="timeline-container"
- style={{
- minHeight: `calc(100vh - ${56 + bottomSpaceHeight}px)`,
- }}
- >
- <Timeline timeline={timeline} posts={posts} />
- </div>
- )
- ) : (
- <div className="full-viewport-center-child">
- <Spinner variant="primary" animation="grow" />
- </div>
- )}
- {operations.onPost != null ? (
+ <div
+ className="timeline-container"
+ style={{
+ minHeight: `calc(100vh - ${56 + bottomSpaceHeight}px)`,
+ }}
+ >
+ <Timeline
+ timelineName={timeline.name}
+ reloadKey={timelineReloadKey}
+ onReload={reloadTimeline}
+ />
+ </div>
+ {timeline.postable ? (
<>
<div
style={{ height: bottomSpaceHeight }}
@@ -142,9 +139,9 @@ export default function TimelinePageTemplateUI<TManageItems>(
/>
<TimelinePostEdit
className="fixed-bottom"
- onPost={operations.onPost}
+ timeline={timeline}
onHeightChange={onPostEditHeightChange}
- timelineUniqueId={timeline.uniqueId}
+ onPosted={reloadTimeline}
/>
</>
) : null}
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx
new file mode 100644
index 00000000..69954040
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx
@@ -0,0 +1,114 @@
+import React from "react";
+import { Spinner } from "react-bootstrap";
+
+import { HttpNetworkError } from "@/http/common";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
+
+import { useUser } from "@/services/user";
+
+const TextView: React.FC<TimelinePostContentViewProps> = (props) => {
+ const { post, className, style } = props;
+
+ const [text, setText] = React.useState<string | null>(null);
+ const [error, setError] = React.useState<"offline" | "error" | null>(null);
+
+ React.useEffect(() => {
+ let subscribe = true;
+
+ setText(null);
+ setError(null);
+
+ void getHttpTimelineClient()
+ .getPostDataAsString(post.timelineName, post.id)
+ .then(
+ (data) => {
+ if (subscribe) setText(data);
+ },
+ (error) => {
+ if (subscribe) {
+ if (error instanceof HttpNetworkError) {
+ setError("offline");
+ } else {
+ setError("error");
+ }
+ }
+ }
+ );
+
+ return () => {
+ subscribe = false;
+ };
+ }, [post]);
+
+ if (error != null) {
+ // TODO: i18n
+ return (
+ <div className={className} style={style}>
+ Error!
+ </div>
+ );
+ } else if (text == null) {
+ return <Spinner variant="primary" animation="grow" />;
+ } else {
+ return (
+ <div className={className} style={style}>
+ {text}
+ </div>
+ );
+ }
+};
+
+const ImageView: React.FC<TimelinePostContentViewProps> = (props) => {
+ const { post, className, style } = props;
+
+ useUser();
+
+ return (
+ <img
+ src={getHttpTimelineClient().generatePostDataUrl(
+ post.timelineName,
+ post.id
+ )}
+ className={className}
+ style={style}
+ />
+ );
+};
+
+const MarkdownView: React.FC<TimelinePostContentViewProps> = (_props) => {
+ // TODO: Implement this.
+ return <div>Unsupported now!</div>;
+};
+
+export interface TimelinePostContentViewProps {
+ post: HttpTimelinePostInfo;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const viewMap: Record<string, React.FC<TimelinePostContentViewProps>> = {
+ "text/plain": TextView,
+ "text/markdown": MarkdownView,
+ "image/png": ImageView,
+ "image/jpeg": ImageView,
+ "image/gif": ImageView,
+ "image/webp": ImageView,
+};
+
+const TimelinePostContentView: React.FC<TimelinePostContentViewProps> = (
+ props
+) => {
+ const { post, className, style } = props;
+
+ const type = post.dataList[0].kind;
+
+ if (type in viewMap) {
+ const View = viewMap[type];
+ return <View post={post} className={className} style={style} />;
+ } else {
+ // TODO: i18n
+ return <div>Error, unknown post type!</div>;
+ }
+};
+
+export default TimelinePostContentView;
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx
index 207bf6af..7c49e5bb 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx
@@ -5,8 +5,14 @@ import { Button, Spinner, Row, Col, Form } from "react-bootstrap";
import { UiLogicError } from "@/common";
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePostPostRequestData,
+} from "@/http/timeline";
+
import { pushAlert } from "@/services/alert";
-import { TimelineCreatePostRequest } from "@/services/timeline";
+import { base64 } from "@/http/common";
interface TimelinePostEditImageProps {
onSelect: (blob: Blob | null) => void;
@@ -74,19 +80,15 @@ const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
);
};
-export type TimelinePostSendCallback = (
- content: TimelineCreatePostRequest
-) => Promise<void>;
-
export interface TimelinePostEditProps {
className?: string;
- onPost: TimelinePostSendCallback;
+ timeline: HttpTimelineInfo;
+ onPosted: () => void;
onHeightChange?: (height: number) => void;
- timelineUniqueId: string;
}
const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
- const { onPost } = props;
+ const { timeline, onHeightChange, className, onPosted } = props;
const { t } = useTranslation();
@@ -95,7 +97,7 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
const [text, setText] = React.useState<string>("");
const [imageBlob, setImageBlob] = React.useState<Blob | null>(null);
- const draftLocalStorageKey = `timeline.${props.timelineUniqueId}.postDraft`;
+ const draftLocalStorageKey = `timeline.${timeline.name}.postDraft`;
React.useEffect(() => {
setText(window.localStorage.getItem(draftLocalStorageKey) ?? "");
@@ -107,18 +109,18 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
const containerRef = React.useRef<HTMLDivElement>(null!);
const notifyHeightChange = (): void => {
- if (props.onHeightChange) {
- props.onHeightChange(containerRef.current.clientHeight);
+ if (onHeightChange) {
+ onHeightChange(containerRef.current.clientHeight);
}
};
React.useEffect(() => {
- if (props.onHeightChange) {
- props.onHeightChange(containerRef.current.clientHeight);
+ if (onHeightChange) {
+ onHeightChange(containerRef.current.clientHeight);
}
return () => {
- if (props.onHeightChange) {
- props.onHeightChange(0);
+ if (onHeightChange) {
+ onHeightChange(0);
}
};
});
@@ -128,53 +130,55 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
setImageBlob(null);
}, []);
- const onSend = React.useCallback(() => {
+ const onSend = async (): Promise<void> => {
setState("process");
- const req: TimelineCreatePostRequest = (() => {
- switch (kind) {
- case "text":
- return {
- content: {
- type: "text",
- text: text,
- },
- } as TimelineCreatePostRequest;
- case "image":
- if (imageBlob == null) {
- throw new UiLogicError(
- "Content type is image but image blob is null."
- );
- }
- return {
- content: {
- type: "image",
- data: imageBlob,
- },
- } as TimelineCreatePostRequest;
- default:
- throw new UiLogicError("Unknown content type.");
- }
- })();
+ let requestData: HttpTimelinePostPostRequestData;
+ switch (kind) {
+ case "text":
+ requestData = {
+ contentType: "text/plain",
+ data: await base64(new Blob([text])),
+ };
+ break;
+ case "image":
+ if (imageBlob == null) {
+ throw new UiLogicError(
+ "Content type is image but image blob is null."
+ );
+ }
+ requestData = {
+ contentType: imageBlob.type,
+ data: await base64(imageBlob),
+ };
+ break;
+ default:
+ throw new UiLogicError("Unknown content type.");
+ }
- onPost(req).then(
- (_) => {
- if (kind === "text") {
- setText("");
- window.localStorage.removeItem(draftLocalStorageKey);
+ getHttpTimelineClient()
+ .postPost(timeline.name, {
+ dataList: [requestData],
+ })
+ .then(
+ (_) => {
+ if (kind === "text") {
+ setText("");
+ window.localStorage.removeItem(draftLocalStorageKey);
+ }
+ setState("input");
+ setKind("text");
+ onPosted();
+ },
+ (_) => {
+ pushAlert({
+ type: "danger",
+ message: t("timeline.sendPostFailed"),
+ });
+ setState("input");
}
- setState("input");
- setKind("text");
- },
- (_) => {
- pushAlert({
- type: "danger",
- message: t("timeline.sendPostFailed"),
- });
- setState("input");
- }
- );
- }, [onPost, kind, text, imageBlob, t, draftLocalStorageKey]);
+ );
+ };
const onImageSelect = React.useCallback((blob: Blob | null) => {
setImageBlob(blob);
@@ -183,7 +187,7 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
return (
<div
ref={containerRef}
- className={clsx("container-fluid bg-light", props.className)}
+ className={clsx("container-fluid bg-light", className)}
>
<Row>
<Col className="px-1 py-1">
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostListView.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostListView.tsx
new file mode 100644
index 00000000..63255619
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePostListView.tsx
@@ -0,0 +1,75 @@
+import React from "react";
+import clsx from "clsx";
+
+import { HttpTimelinePostInfo } from "@/http/timeline";
+
+import TimelinePostView from "./TimelinePostView";
+import TimelineDateLabel from "./TimelineDateLabel";
+
+function dateEqual(left: Date, right: Date): boolean {
+ return (
+ left.getDate() == right.getDate() &&
+ left.getMonth() == right.getMonth() &&
+ left.getFullYear() == right.getFullYear()
+ );
+}
+
+export interface TimelinePostListViewProps {
+ className?: string;
+ style?: React.CSSProperties;
+ posts: HttpTimelinePostInfo[];
+ onReload: () => void;
+}
+
+const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => {
+ const { className, style, posts, onReload } = props;
+
+ const groupedPosts = React.useMemo<
+ { date: Date; posts: (HttpTimelinePostInfo & { index: number })[] }[]
+ >(() => {
+ const result: {
+ date: Date;
+ posts: (HttpTimelinePostInfo & { index: number })[];
+ }[] = [];
+ let index = 0;
+ for (const post of posts) {
+ const time = new Date(post.time);
+ if (result.length === 0) {
+ result.push({ date: time, posts: [{ ...post, index }] });
+ } else {
+ const lastGroup = result[result.length - 1];
+ if (dateEqual(lastGroup.date, time)) {
+ lastGroup.posts.push({ ...post, index });
+ } else {
+ result.push({ date: time, posts: [{ ...post, index }] });
+ }
+ }
+ index++;
+ }
+ return result;
+ }, [posts]);
+
+ return (
+ <div style={style} className={clsx("timeline", className)}>
+ {groupedPosts.map((group) => {
+ return (
+ <>
+ <TimelineDateLabel date={group.date} />
+ {group.posts.map((post) => {
+ return (
+ <TimelinePostView
+ key={post.id}
+ post={post}
+ current={posts.length - 1 === post.index}
+ onDeleted={onReload}
+ />
+ );
+ })}
+ </>
+ );
+ })}
+ </div>
+ );
+};
+
+export default TimelinePostListView;
diff --git a/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostView.tsx
index a5b6d04a..7fd98310 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePostView.tsx
@@ -2,46 +2,45 @@ import React from "react";
import clsx from "clsx";
import { Link } from "react-router-dom";
-import { TimelinePostInfo } from "@/services/timeline";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
+
+import { pushAlert } from "@/services/alert";
-import BlobImage from "../common/BlobImage";
import UserAvatar from "../common/user/UserAvatar";
import TimelineLine from "./TimelineLine";
+import TimelinePostContentView from "./TimelinePostContentView";
import TimelinePostDeleteConfirmDialog from "./TimelinePostDeleteConfirmDialog";
-export interface TimelineItemProps {
- post: TimelinePostInfo;
+export interface TimelinePostViewProps {
+ post: HttpTimelinePostInfo;
current?: boolean;
- more?: {
- isOpen: boolean;
- toggle: () => void;
- onDelete: () => void;
- };
- onClick?: () => void;
className?: string;
style?: React.CSSProperties;
+ onDeleted?: () => void;
}
-const TimelineItem: React.FC<TimelineItemProps> = (props) => {
+const TimelinePostView: React.FC<TimelinePostViewProps> = (props) => {
+ const { post, className, style, onDeleted } = props;
const current = props.current === true;
- const { post, more } = props;
-
+ const [
+ operationMaskVisible,
+ setOperationMaskVisible,
+ ] = React.useState<boolean>(false);
const [deleteDialog, setDeleteDialog] = React.useState<boolean>(false);
return (
<div
- className={clsx("timeline-item", current && "current", props.className)}
- onClick={props.onClick}
- style={props.style}
+ className={clsx("timeline-item", current && "current", className)}
+ style={style}
>
<TimelineLine center="node" current={current} />
<div className="timeline-item-card">
- {more != null ? (
+ {post.editable ? (
<i
className="bi-chevron-down text-info icon-button float-right"
onClick={(e) => {
- more.toggle();
+ setOperationMaskVisible(true);
e.stopPropagation();
}}
/>
@@ -57,30 +56,20 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => {
</Link>
<small className="text-dark mr-2">{post.author.nickname}</small>
<small className="text-secondary white-space-no-wrap">
- {post.time.toLocaleTimeString()}
+ {new Date(post.time).toLocaleTimeString()}
</small>
</span>
</span>
</div>
<div className="timeline-content">
- {(() => {
- const { content } = post;
- if (content.type === "text") {
- return content.text;
- } else {
- return (
- <BlobImage
- blob={content.data}
- className="timeline-content-image"
- />
- );
- }
- })()}
+ <TimelinePostContentView post={post} />
</div>
- {more != null && more.isOpen ? (
+ {operationMaskVisible ? (
<div
className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center"
- onClick={more.toggle}
+ onClick={() => {
+ setOperationMaskVisible(false);
+ }}
>
<i
className="bi-trash text-danger icon-button large"
@@ -92,17 +81,29 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => {
</div>
) : null}
</div>
- {deleteDialog && more != null ? (
+ {deleteDialog ? (
<TimelinePostDeleteConfirmDialog
onClose={() => {
setDeleteDialog(false);
- more.toggle();
+ setOperationMaskVisible(false);
+ }}
+ onConfirm={() => {
+ void getHttpTimelineClient()
+ .deletePost(post.timelineName, post.id)
+ .then(onDeleted, () => {
+ pushAlert({
+ type: "danger",
+ message: {
+ type: "i18n",
+ key: "timeline.deletePostFailed",
+ },
+ });
+ });
}}
- onConfirm={more.onDelete}
/>
) : null}
</div>
);
};
-export default TimelineItem;
+export default TimelinePostView;
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
index ab3285f5..a5628a9a 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
@@ -1,19 +1,20 @@
import React from "react";
import {
- TimelineVisibility,
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePatchRequest,
kTimelineVisibilities,
- TimelineChangePropertyRequest,
- TimelineInfo,
-} from "@/services/timeline";
+ TimelineVisibility,
+} from "@/http/timeline";
import OperationDialog from "../common/OperationDialog";
export interface TimelinePropertyChangeDialogProps {
open: boolean;
close: () => void;
- timeline: TimelineInfo;
- onProcess: (request: TimelineChangePropertyRequest) => Promise<void>;
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
}
const labelMap: { [key in TimelineVisibility]: string } = {
@@ -25,7 +26,7 @@ const labelMap: { [key in TimelineVisibility]: string } = {
const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> = (
props
) => {
- const { timeline } = props;
+ const { timeline, onChange } = props;
return (
<OperationDialog
@@ -54,7 +55,7 @@ const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps>
open={props.open}
close={props.close}
onProcess={([newTitle, newVisibility, newDescription]) => {
- const req: TimelineChangePropertyRequest = {};
+ const req: HttpTimelinePatchRequest = {};
if (newTitle !== timeline.title) {
req.title = newTitle;
}
@@ -64,7 +65,9 @@ const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps>
if (newDescription !== timeline.description) {
req.description = newDescription;
}
- return props.onProcess(req);
+ return getHttpTimelineClient()
+ .patchTimeline(timeline.name, req)
+ .then(onChange);
}}
/>
);
diff --git a/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx
index 0d3199d6..f472c16a 100644
--- a/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx
+++ b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx
@@ -2,7 +2,7 @@ import React from "react";
import { useHistory } from "react-router";
import { Trans } from "react-i18next";
-import { timelineService } from "@/services/timeline";
+import { getHttpTimelineClient } from "@/http/timeline";
import OperationDialog from "../common/OperationDialog";
@@ -43,7 +43,7 @@ const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
}
}}
onProcess={() => {
- return timelineService.deleteTimeline(name).toPromise();
+ return getHttpTimelineClient().deleteTimeline(name);
}}
onSuccessAndClose={() => {
history.replace("/");
diff --git a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx
index 920f504d..63da6f3c 100644
--- a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx
+++ b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx
@@ -1,12 +1,10 @@
import React from "react";
-import { useAvatar } from "@/services/user";
-
-import BlobImage from "../common/BlobImage";
import TimelineCardTemplate, {
TimelineCardTemplateProps,
} from "../timeline-common/TimelineCardTemplate";
import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
+import UserAvatar from "../common/user/UserAvatar";
export type OrdinaryTimelineManageItem = "delete";
@@ -16,8 +14,6 @@ const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
const { timeline, operations } = props;
const { onManage, onMember } = operations;
- const avatar = useAvatar(timeline?.owner?.username);
-
return (
<TimelineCardTemplate
infoArea={
@@ -27,8 +23,8 @@ const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
<small className="ml-3 text-secondary">{timeline.name}</small>
</h3>
<div className="align-middle">
- <BlobImage
- blob={avatar}
+ <UserAvatar
+ username={timeline.owner.username}
className="avatar small rounded-circle mr-3"
/>
{timeline.owner.nickname}
diff --git a/FrontEnd/src/app/views/timeline/index.tsx b/FrontEnd/src/app/views/timeline/index.tsx
index 225a1a59..8048dd12 100644
--- a/FrontEnd/src/app/views/timeline/index.tsx
+++ b/FrontEnd/src/app/views/timeline/index.tsx
@@ -7,12 +7,13 @@ import TimelinePageUI from "./TimelinePageUI";
import { OrdinaryTimelineManageItem } from "./TimelineInfoCard";
import TimelineDeleteDialog from "./TimelineDeleteDialog";
-const TimelinePage: React.FC = (_) => {
+const TimelinePage: React.FC = () => {
const { name } = useParams<{ name: string }>();
const [dialog, setDialog] = React.useState<OrdinaryTimelineManageItem | null>(
null
);
+ const [reloadKey, setReloadKey] = React.useState<number>(0);
let dialogElement: React.ReactElement | undefined;
if (dialog === "delete") {
@@ -28,6 +29,8 @@ const TimelinePage: React.FC = (_) => {
UiComponent={TimelinePageUI}
onManage={(item) => setDialog(item)}
notFoundI18nKey="timeline.timelineNotExist"
+ reloadKey={reloadKey}
+ onReload={() => setReloadKey(reloadKey + 1)}
/>
{dialogElement}
</>
diff --git a/FrontEnd/src/app/views/user/UserInfoCard.tsx b/FrontEnd/src/app/views/user/UserInfoCard.tsx
index 01d2c096..24b7b979 100644
--- a/FrontEnd/src/app/views/user/UserInfoCard.tsx
+++ b/FrontEnd/src/app/views/user/UserInfoCard.tsx
@@ -1,12 +1,10 @@
import React from "react";
-import { useAvatar } from "@/services/user";
-
-import BlobImage from "../common/BlobImage";
import TimelineCardTemplate, {
TimelineCardTemplateProps,
} from "../timeline-common/TimelineCardTemplate";
import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
+import UserAvatar from "../common/user/UserAvatar";
export type PersonalTimelineManageItem = "avatar" | "nickname";
@@ -16,8 +14,6 @@ const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
const { timeline, operations } = props;
const { onManage, onMember } = operations;
- const avatar = useAvatar(timeline?.owner?.username);
-
return (
<TimelineCardTemplate
infoArea={
@@ -27,8 +23,8 @@ const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
<small className="ml-3 text-secondary">{timeline.name}</small>
</h3>
<div className="align-middle">
- <BlobImage
- blob={avatar}
+ <UserAvatar
+ username={timeline.owner.username}
className="avatar small rounded-circle mr-3"
/>
{timeline.owner.nickname}
diff --git a/FrontEnd/src/app/views/user/index.tsx b/FrontEnd/src/app/views/user/index.tsx
index bb986178..9b5acbba 100644
--- a/FrontEnd/src/app/views/user/index.tsx
+++ b/FrontEnd/src/app/views/user/index.tsx
@@ -1,10 +1,9 @@
import React, { useState } from "react";
import { useParams } from "react-router";
-import { userInfoService } from "@/services/user";
+import { getHttpUserClient } from "@/http/user";
import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate";
-
import UserPageUI from "./UserPageUI";
import { PersonalTimelineManageItem } from "./UserInfoCard";
import ChangeNicknameDialog from "./ChangeNicknameDialog";
@@ -15,6 +14,8 @@ const UserPage: React.FC = (_) => {
const [dialog, setDialog] = useState<null | PersonalTimelineManageItem>(null);
+ const [reloadKey, setReloadKey] = React.useState<number>(0);
+
let dialogElement: React.ReactElement | undefined;
const closeDialog = (): void => setDialog(null);
@@ -24,9 +25,10 @@ const UserPage: React.FC = (_) => {
<ChangeNicknameDialog
open
close={closeDialog}
- onProcess={(newNickname) =>
- userInfoService.setNickname(username, newNickname)
- }
+ onProcess={async (newNickname) => {
+ await getHttpUserClient().patch(username, { nickname: newNickname });
+ setReloadKey(reloadKey + 1);
+ }}
/>
);
} else if (dialog === "avatar") {
@@ -34,7 +36,10 @@ const UserPage: React.FC = (_) => {
<ChangeAvatarDialog
open
close={closeDialog}
- process={(file) => userInfoService.setAvatar(username, file)}
+ process={async (file) => {
+ await getHttpUserClient().putAvatar(username, file);
+ setReloadKey(reloadKey + 1);
+ }}
/>
);
}
@@ -46,6 +51,8 @@ const UserPage: React.FC = (_) => {
UiComponent={UserPageUI}
onManage={(item) => setDialog(item)}
notFoundI18nKey="timeline.userNotExist"
+ reloadKey={reloadKey}
+ onReload={() => setReloadKey(reloadKey + 1)}
/>
{dialogElement}
</>