import React from 'react'; import XRegExp from 'xregexp'; import { Observable, from } from 'rxjs'; import { map } from 'rxjs/operators'; import { pull } from 'lodash'; import { convertError } from '../utilities/rxjs'; import { BlobWithUrl, dataStorage } from './common'; import { SubscriptionHub, ISubscriptionHub } from './SubscriptionHub'; import { UserAuthInfo, checkLogin, userService } from './user'; export { kTimelineVisibilities } from '../http/timeline'; export type { TimelineVisibility } from '../http/timeline'; import { TimelineVisibility, HttpTimelineInfo, HttpTimelinePatchRequest, HttpTimelinePostPostRequest, HttpTimelinePostPostRequestContent, HttpTimelinePostPostRequestTextContent, HttpTimelinePostPostRequestImageContent, HttpTimelinePostInfo, HttpTimelinePostContent, HttpTimelinePostTextContent, HttpTimelinePostImageContent, getHttpTimelineClient, HttpTimelineNotExistError, HttpTimelineNameConflictError, HttpTimelineGenericPostInfo, } from '../http/timeline'; 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 interface TimelinePostInfo extends HttpTimelinePostInfo { timelineName: string; } export type TimelinePostContent = HttpTimelinePostContent; export type TimelinePostTextContent = HttpTimelinePostTextContent; export type TimelinePostImageContent = HttpTimelinePostImageContent; export const timelineVisibilityTooltipTranslationMap: Record< TimelineVisibility, string > = { Public: 'timeline.visibilityTooltip.public', Register: 'timeline.visibilityTooltip.register', Private: 'timeline.visibilityTooltip.private', }; export class TimelineNotExistError extends Error {} export class TimelineNameConflictError extends Error {} export interface PostKey { timelineName: string; postId: number; } export interface TimelinePostListState { state: | 'loading' // Loading posts from cache. `posts` is empty array. | 'syncing' // Cache loaded and syncing now. | 'synced' // Sync succeeded. | 'offline'; // Sync failed and use cache. posts: TimelinePostInfo[]; } interface PostListInfo { idList: number[]; lastUpdated: string; } export class TimelineService { // TODO: Remove this! This is currently only used to avoid multiple fetch of timeline. Because post list need to use the timeline id and call this method. But after timeline is also saved locally, this should be removed. private timelineCache = new Map>(); getTimeline(timelineName: string): Observable { const cache = this.timelineCache.get(timelineName); let promise: Promise; if (cache == null) { promise = getHttpTimelineClient().getTimeline(timelineName); this.timelineCache.set(timelineName, promise); } else { promise = cache; } return from(promise).pipe( convertError(HttpTimelineNotExistError, TimelineNotExistError) ); } createTimeline(timelineName: string): Observable { const user = checkLogin(); return from( getHttpTimelineClient().postTimeline( { name: timelineName, }, user.token ) ).pipe( convertError(HttpTimelineNameConflictError, TimelineNameConflictError) ); } changeTimelineProperty( timelineName: string, req: TimelineChangePropertyRequest ): Observable { const user = checkLogin(); return from( getHttpTimelineClient().patchTimeline(timelineName, req, user.token) ); } deleteTimeline(timelineName: string): Observable { const user = checkLogin(); return from( getHttpTimelineClient().deleteTimeline(timelineName, user.token) ); } addMember(timelineName: string, username: string): Observable { const user = checkLogin(); return from( getHttpTimelineClient().memberPut(timelineName, username, user.token) ); } removeMember(timelineName: string, username: string): Observable { const user = checkLogin(); return from( getHttpTimelineClient().memberDelete(timelineName, username, user.token) ); } getPosts(timelineName: string): Observable { const token = userService.currentUser?.token; return from(getHttpTimelineClient().listPost(timelineName, token)).pipe( map((posts) => { return posts.map((post) => ({ ...post, timelineName, })); }) ); } // post list storage structure: // each timeline has a PostListInfo saved with key created by getPostListInfoKey // each post of a timeline has a HttpTimelinePostInfo with key created by getPostKey private getPostListInfoKey(timelineUniqueId: string): string { return `timeline.${timelineUniqueId}.postListInfo`; } private getPostKey(timelineUniqueId: string, id: number): string { return `timeline.${timelineUniqueId}.post.${id}`; } private async getCachedPostList( timelineName: string ): Promise { const timeline = await this.getTimeline(timelineName).toPromise(); const postListInfo = await dataStorage.getItem( this.getPostListInfoKey(timeline.uniqueId) ); if (postListInfo == null) { return []; } else { return ( await Promise.all( postListInfo.idList.map((postId) => dataStorage.getItem( this.getPostKey(timeline.uniqueId, postId) ) ) ) ).map((post) => ({ ...post, timelineName })); } } async syncPostList(timelineName: string): Promise { const timeline = await this.getTimeline(timelineName).toPromise(); const postListInfoKey = this.getPostListInfoKey(timeline.uniqueId); const postListInfo = await dataStorage.getItem( postListInfoKey ); const now = new Date(); let posts: TimelinePostInfo[]; if (postListInfo == null) { let httpPosts: HttpTimelinePostInfo[]; try { httpPosts = await getHttpTimelineClient().listPost( timelineName, userService.currentUser?.token ); } catch (e) { this._postListSubscriptionHub.update(timelineName, (_, old) => Promise.resolve({ state: 'offline', posts: old.posts, }) ); throw e; } await dataStorage.setItem(postListInfoKey, { idList: httpPosts.map((post) => post.id), lastUpdated: now.toISOString(), }); for (const post of httpPosts) { await dataStorage.setItem( this.getPostKey(timeline.uniqueId, post.id), post ); } posts = httpPosts.map((post) => ({ ...post, timelineName, })); } else { let httpPosts: HttpTimelineGenericPostInfo[]; try { httpPosts = await getHttpTimelineClient().listPost( timelineName, userService.currentUser?.token, { modifiedSince: new Date(postListInfo.lastUpdated), includeDeleted: true, } ); } catch (e) { this._postListSubscriptionHub.update(timelineName, (_, old) => Promise.resolve({ state: 'offline', posts: old.posts, }) ); throw e; } const newPosts: HttpTimelinePostInfo[] = []; for (const post of httpPosts) { if (post.deleted) { pull(postListInfo.idList, post.id); await dataStorage.removeItem( this.getPostKey(timeline.uniqueId, post.id) ); } else { await dataStorage.setItem( this.getPostKey(timeline.uniqueId, post.id), post ); newPosts.push(post); } } const oldIdList = postListInfo.idList; postListInfo.idList = [...oldIdList, ...newPosts.map((post) => post.id)]; postListInfo.lastUpdated = now.toISOString(); await dataStorage.setItem(postListInfoKey, postListInfo); posts = [ ...(await Promise.all( oldIdList.map((postId) => dataStorage.getItem( this.getPostKey(timeline.uniqueId, postId) ) ) )), ...newPosts, ].map((post) => ({ ...post, timelineName })); } this._postListSubscriptionHub.update(timelineName, () => Promise.resolve({ state: 'synced', posts, }) ); return posts; } private _postListSubscriptionHub = new SubscriptionHub< string, TimelinePostListState >( (key) => key, () => ({ state: 'loading', posts: [], }), async (key) => { const state: TimelinePostListState = { state: 'syncing', posts: await this.getCachedPostList(key), }; void this.syncPostList(key); return state; } ); get postListSubscriptionHub(): ISubscriptionHub< string, TimelinePostListState > { return this._postListSubscriptionHub; } private _postDataSubscriptionHub = new SubscriptionHub< PostKey, BlobWithUrl | null >( (key) => `${key.timelineName}/${key.postId}`, () => null, async (key) => { const blob = ( await getHttpTimelineClient().getPostData( key.timelineName, key.postId, userService.currentUser?.token ) ).data; const url = URL.createObjectURL(blob); return { blob, url, }; }, (_key, data) => { if (data != null) URL.revokeObjectURL(data.url); } ); get postDataHub(): ISubscriptionHub { return this._postDataSubscriptionHub; } createPost( timelineName: string, request: TimelineCreatePostRequest ): Observable { const user = checkLogin(); return from( getHttpTimelineClient().postPost(timelineName, request, user.token) ).pipe(map((post) => ({ ...post, timelineName }))); } deletePost(timelineName: string, postId: number): Observable { const user = checkLogin(); return from( getHttpTimelineClient().deletePost(timelineName, postId, user.token) ); } isMemberOf(username: string, timeline: TimelineInfo): boolean { return timeline.members.findIndex((m) => m.username == username) >= 0; } hasReadPermission( user: UserAuthInfo | null | undefined, timeline: TimelineInfo ): boolean { if (user != null && user.administrator) 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 && this.isMemberOf(user.username, timeline)) { return true; } } return false; } hasPostPermission( user: UserAuthInfo | null | undefined, timeline: TimelineInfo ): boolean { if (user != null && user.administrator) return true; return ( user != null && (timeline.owner.username === user.username || this.isMemberOf(user.username, timeline)) ); } hasManagePermission( user: UserAuthInfo | null | undefined, timeline: TimelineInfo ): boolean { if (user != null && user.administrator) return true; return user != null && user.username == timeline.owner.username; } hasModifyPostPermission( user: UserAuthInfo | null | undefined, timeline: TimelineInfo, post: TimelinePostInfo ): boolean { if (user != null && user.administrator) return true; return ( user != null && (user.username === timeline.owner.username || user.username === post.author.username) ); } } export const timelineService = new TimelineService(); const timelineNameReg = XRegExp('^[-_\\p{L}]*$', 'u'); export function validateTimelineName(name: string): boolean { return timelineNameReg.test(name); } export function usePostDataUrl( enable: boolean, timelineName: string, postId: number ): string | undefined { const [url, setUrl] = React.useState(undefined); React.useEffect(() => { if (!enable) { setUrl(undefined); return; } const subscription = timelineService.postDataHub.subscribe( { timelineName, postId, }, (data) => { setUrl(data?.url); } ); return () => { subscription.unsubscribe(); }; }, [timelineName, postId, enable]); return url; }