diff options
Diffstat (limited to 'FrontEnd/src')
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} </> |