From cb60488bbe2b85e93b2cdf45aa448c76f2df9763 Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Fri, 2 Aug 2024 12:19:32 +0800 Subject: [PATCH] fix: replace wasm with axios (#5856) * fix: replace wasm with axios * fix: login redirect * fix: flag emoji on windows --- frontend/appflowy_web_app/deploy/nginx.conf | 5 +- frontend/appflowy_web_app/deploy/server.ts | 19 +- frontend/appflowy_web_app/package.json | 1 - frontend/appflowy_web_app/pnpm-lock.yaml | 7 - .../js-services/__tests__/fetch.test.ts | 4 +- .../js-services/__tests__/index.test.ts | 2 +- .../services/js-services/cache/index.ts | 9 +- .../application/services/js-services/fetch.ts | 2 +- .../services/js-services/http/gotrue.ts | 94 ++++ .../services/js-services/http/http_api.ts | 489 ++++++++++++++++++ .../services/js-services/http/index.ts | 1 + .../services/js-services/http/utils.ts | 17 + .../application/services/js-services/index.ts | 66 +-- .../services/js-services/wasm/client_api.ts | 236 --------- .../services/js-services/wasm/index.ts | 1 - .../src/application/session/token.ts | 18 + .../_shared/emoji-picker/EmojiPicker.hooks.ts | 23 +- .../emoji-picker/EmojiPickerCategories.tsx | 25 +- .../components/_shared/emoji-picker/index.ts | 6 +- .../components/header/DatabaseHeader.tsx | 8 +- .../components/leaf/mention/MentionPage.tsx | 7 +- .../global-comment/GlobalComment.hooks.tsx | 4 +- .../global-comment/reactions/Reaction.tsx | 7 +- .../publish/header/BreadcrumbItem.tsx | 6 +- .../publish/header/PublishViewHeader.tsx | 4 +- .../components/view-meta/ViewMetaPreview.tsx | 7 +- .../appflowy_web_app/src/pages/LoginPage.tsx | 4 +- frontend/appflowy_web_app/src/styles/app.scss | 1 - frontend/appflowy_web_app/src/utils/emoji.ts | 4 + frontend/appflowy_web_app/vite.config.ts | 14 +- 30 files changed, 733 insertions(+), 358 deletions(-) create mode 100644 frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts create mode 100644 frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts create mode 100644 frontend/appflowy_web_app/src/application/services/js-services/http/index.ts create mode 100644 frontend/appflowy_web_app/src/application/services/js-services/http/utils.ts delete mode 100644 frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts delete mode 100644 frontend/appflowy_web_app/src/application/services/js-services/wasm/index.ts diff --git a/frontend/appflowy_web_app/deploy/nginx.conf b/frontend/appflowy_web_app/deploy/nginx.conf index e0a658c310..28b3d7fe49 100644 --- a/frontend/appflowy_web_app/deploy/nginx.conf +++ b/frontend/appflowy_web_app/deploy/nginx.conf @@ -54,10 +54,7 @@ http { root /usr/share/nginx/html; expires 30d; access_log off; - location ~* \.wasm$ { - types { application/wasm wasm; } - default_type application/wasm; - } + } location /appflowy.svg { diff --git a/frontend/appflowy_web_app/deploy/server.ts b/frontend/appflowy_web_app/deploy/server.ts index f2fb31619f..325268a6b2 100644 --- a/frontend/appflowy_web_app/deploy/server.ts +++ b/frontend/appflowy_web_app/deploy/server.ts @@ -68,14 +68,25 @@ const createServer = async (req: Request) => { logger.info(`Request URL: ${hostname}${reqUrl.pathname}`); - if (reqUrl.pathname === '/after-payment') { + if (['/after-payment', '/login'].includes(reqUrl.pathname)) { timer(); const htmlData = fs.readFileSync(indexPath, 'utf8'); const $ = load(htmlData); - $('title').text('Payment Success | AppFlowy'); - $('link[rel="icon"]').attr('href', '/appflowy.svg'); - setOrUpdateMetaTag($, 'meta[name="description"]', 'name', 'Payment success on AppFlowy'); + let title, description; + + if (reqUrl.pathname === '/after-payment') { + title = 'Payment Success | AppFlowy'; + description = 'Payment success on AppFlowy'; + } + + if (reqUrl.pathname === '/login') { + title = 'Login | AppFlowy'; + description = 'Login to AppFlowy'; + } + + if (title) $('title').text(title); + if (description) setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description); return new Response($.html(), { headers: { 'Content-Type': 'text/html' }, diff --git a/frontend/appflowy_web_app/package.json b/frontend/appflowy_web_app/package.json index 04c3f259ae..f397b04388 100644 --- a/frontend/appflowy_web_app/package.json +++ b/frontend/appflowy_web_app/package.json @@ -24,7 +24,6 @@ "coverage": "pnpm run test:unit && pnpm run test:components" }, "dependencies": { - "@appflowyinc/client-api-wasm": "0.1.4", "@atlaskit/primitives": "^5.5.3", "@emoji-mart/data": "^1.1.2", "@emoji-mart/react": "^1.1.1", diff --git a/frontend/appflowy_web_app/pnpm-lock.yaml b/frontend/appflowy_web_app/pnpm-lock.yaml index db5d7e333a..432a5676f0 100644 --- a/frontend/appflowy_web_app/pnpm-lock.yaml +++ b/frontend/appflowy_web_app/pnpm-lock.yaml @@ -5,9 +5,6 @@ settings: excludeLinksFromLockfile: false dependencies: - '@appflowyinc/client-api-wasm': - specifier: 0.1.4 - version: 0.1.4 '@atlaskit/primitives': specifier: ^5.5.3 version: 5.7.0(@types/react@18.2.66)(react@18.2.0) @@ -454,10 +451,6 @@ packages: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 - /@appflowyinc/client-api-wasm@0.1.4: - resolution: {integrity: sha512-3uBpy3n+aIG0fapPAroMfL8JLdAPtqPAkpV+LOxlRnMW4Au2JQcW8TW0P3K1YAe16tDZ62ZIZPoG6Bi40RDRoQ==} - dev: false - /@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0): resolution: {integrity: sha512-iO6+hIp09dF4iAZQarVz3vKY1kM5Ij5CExYcK9jgc2q+OH8nv8n+BPFeJTdzGOGopmbUZn5Opj9pYQvge1Gr4Q==} peerDependencies: diff --git a/frontend/appflowy_web_app/src/application/services/js-services/__tests__/fetch.test.ts b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/fetch.test.ts index 575efef159..b80434a93d 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/__tests__/fetch.test.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/fetch.test.ts @@ -1,8 +1,8 @@ import { expect } from '@jest/globals'; import { fetchPublishView, fetchPublishViewMeta, fetchViewInfo } from '../fetch'; -import { APIService } from '@/application/services/js-services/wasm'; +import { APIService } from '@/application/services/js-services/http'; -jest.mock('@/application/services/js-services/wasm', () => { +jest.mock('@/application/services/js-services/http', () => { return { APIService: { getPublishView: jest.fn(), diff --git a/frontend/appflowy_web_app/src/application/services/js-services/__tests__/index.test.ts b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/index.test.ts index be4341be4b..9bab9c9352 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/__tests__/index.test.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/index.test.ts @@ -5,7 +5,7 @@ import { fetchViewInfo } from '@/application/services/js-services/fetch'; import { expect, jest } from '@jest/globals'; import { getPublishView, getPublishViewMeta } from '@/application/services/js-services/cache'; -jest.mock('@/application/services/js-services/wasm/client_api', () => { +jest.mock('@/application/services/js-services/http/http_api', () => { return { initAPIService: jest.fn(), }; diff --git a/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts index 83d8226603..2ab02a750f 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts @@ -114,7 +114,7 @@ export async function getPublishViewMeta< export async function getPublishView< T extends { - data: number[]; + data: Uint8Array; rows?: Record; visibleViewIds?: ViewId[]; relations?: Record; @@ -219,7 +219,7 @@ export async function revalidatePublishViewMeta< export async function revalidatePublishView< T extends { - data: number[]; + data: Uint8Array; rows?: Record; visibleViewIds?: ViewId[]; relations?: Record; @@ -260,10 +260,9 @@ export async function revalidatePublishView< } } - console.log('====', rows); - const state = new Uint8Array(data); + console.log('====', data); - applyYDoc(collab, state); + applyYDoc(collab, data); } export async function deleteViewMeta(name: string) { diff --git a/frontend/appflowy_web_app/src/application/services/js-services/fetch.ts b/frontend/appflowy_web_app/src/application/services/js-services/fetch.ts index 3f9ca9873b..4725195ead 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/fetch.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/fetch.ts @@ -1,4 +1,4 @@ -import { APIService } from '@/application/services/js-services/wasm'; +import { APIService } from '@/application/services/js-services/http'; const pendingRequests = new Map(); diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts new file mode 100644 index 0000000000..3ce200bd5d --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts @@ -0,0 +1,94 @@ +import { refreshToken as refreshSessionToken } from '@/application/session/token'; +import axios, { AxiosInstance } from 'axios'; + +let axiosInstance: AxiosInstance | null = null; + +export function initGrantService(baseURL: string) { + if (axiosInstance) { + return; + } + + axiosInstance = axios.create({ + baseURL, + }); + + axiosInstance.interceptors.request.use((config) => { + Object.assign(config.headers, { + 'Content-Type': 'application/json', + }); + + return config; + }); +} + +export async function refreshToken(refresh_token: string) { + const response = await axiosInstance?.post<{ + access_token: string; + expires_at: number; + refresh_token: string; + }>('/token?grant_type=refresh_token', { + refresh_token, + }); + + const newToken = response?.data; + + if (newToken) { + refreshSessionToken(JSON.stringify(newToken)); + } + + return newToken; +} + +export async function signInWithMagicLink(email: string, authUrl: string) { + const res = await axiosInstance?.post( + '/magiclink', + { + code_challenge: '', + code_challenge_method: '', + data: {}, + email, + }, + { + headers: { + Redirect_to: authUrl, + }, + } + ); + + return res?.data; +} + +export async function settings() { + const res = await axiosInstance?.get('/settings'); + + return res?.data; +} + +export function signInGoogle(authUrl: string) { + const provider = 'google'; + const redirectTo = encodeURIComponent(authUrl); + const accessType = 'offline'; + const prompt = 'consent'; + const baseURL = axiosInstance?.defaults.baseURL; + const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}&access_type=${accessType}&prompt=${prompt}`; + + window.open(url, '_current'); +} + +export function signInGithub(authUrl: string) { + const provider = 'github'; + const redirectTo = encodeURIComponent(authUrl); + const baseURL = axiosInstance?.defaults.baseURL; + const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}`; + + window.open(url, '_current'); +} + +export function signInDiscord(authUrl: string) { + const provider = 'discord'; + const redirectTo = encodeURIComponent(authUrl); + const baseURL = axiosInstance?.defaults.baseURL; + const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}`; + + window.open(url, '_current'); +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts new file mode 100644 index 0000000000..6905d8fd02 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts @@ -0,0 +1,489 @@ +import { DatabaseId, RowId, ViewId, ViewLayout } from '@/application/collab.type'; +import { GlobalComment, Reaction } from '@/application/comment.type'; +import { initGrantService, refreshToken } from '@/application/services/js-services/http/gotrue'; +import { blobToBytes } from '@/application/services/js-services/http/utils'; +import { AFCloudConfig } from '@/application/services/services.type'; +import { getTokenParsed, invalidToken } from '@/application/session/token'; +import { FolderView, User, Workspace } from '@/application/types'; +import axios, { AxiosInstance } from 'axios'; +import dayjs from 'dayjs'; + +export * from './gotrue'; + +let axiosInstance: AxiosInstance | null = null; + +export function initAPIService(config: AFCloudConfig) { + if (axiosInstance) { + return; + } + + axiosInstance = axios.create({ + baseURL: config.baseURL, + }); + + initGrantService(config.gotrueURL); + + axiosInstance.interceptors.request.use( + async (config) => { + const token = getTokenParsed(); + + Object.assign(config.headers, { + 'Content-Type': 'application/json', + }); + + if (!token) { + return config; + } + + const isExpired = dayjs().isAfter(dayjs.unix(token.expires_at)); + + let access_token = token.access_token; + const refresh_token = token.refresh_token; + + if (isExpired) { + const newToken = await refreshToken(refresh_token); + + access_token = newToken?.access_token || ''; + } + + if (access_token) { + Object.assign(config.headers, { + Authorization: `Bearer ${access_token}`, + }); + } + + return config; + }, + (error) => { + return Promise.reject(error); + } + ); + + axiosInstance.interceptors.response.use(async (response) => { + const status = response.status; + + if (status === 401) { + const token = getTokenParsed(); + + if (!token) { + invalidToken(); + return response; + } + + const refresh_token = token.refresh_token; + + try { + await refreshToken(refresh_token); + } catch (e) { + invalidToken(); + } + } + + return response; + }); +} + +export async function signInWithUrl(url: string) { + const hash = new URL(url).hash; + + if (!hash) { + return Promise.reject('No hash found'); + } + + const params = new URLSearchParams(hash.slice(1)); + const refresh_token = params.get('refresh_token'); + + if (!refresh_token) { + return Promise.reject('No access_token found'); + } + + await refreshToken(refresh_token); +} + +export async function verifyToken(accessToken: string) { + const url = `/api/user/verify/${accessToken}`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + is_new: boolean; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function getCurrentUser(): Promise { + const url = '/api/user/profile'; + const response = await axiosInstance?.get<{ + code: number; + data?: { + uid: number; + uuid: string; + email: string; + name: string; + metadata: { + icon_url: string; + }; + encryption_sign: null; + latest_workspace_id: string; + updated_at: number; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + const { uid, uuid, email, name, metadata } = data.data; + + return { + uid: String(uid), + uuid, + email, + name, + avatar: metadata.icon_url, + }; + } + + return Promise.reject(data); +} + +export async function getPublishViewMeta(namespace: string, publishName: string) { + const url = `/api/workspace/published/${namespace}/${publishName}`; + const response = await axiosInstance?.get(url); + + return response?.data; +} + +export async function getPublishViewBlob(namespace: string, publishName: string) { + const url = `/api/workspace/published/${namespace}/${publishName}/blob`; + const response = await axiosInstance?.get(url, { + responseType: 'blob', + }); + + return blobToBytes(response?.data); +} + +export async function getPublishView(publishNamespace: string, publishName: string) { + const meta = await getPublishViewMeta(publishNamespace, publishName); + const blob = await getPublishViewBlob(publishNamespace, publishName); + + if (meta.view.layout === ViewLayout.Document) { + return { + data: blob, + meta, + }; + } + + try { + const decoder = new TextDecoder('utf-8'); + + const jsonStr = decoder.decode(blob); + + const res = JSON.parse(jsonStr) as { + database_collab: Uint8Array; + database_row_collabs: Record; + database_row_document_collabs: Record; + visible_database_view_ids: ViewId[]; + database_relations: Record; + }; + + return { + data: new Uint8Array(res.database_collab), + rows: res.database_row_collabs, + visibleViewIds: res.visible_database_view_ids, + relations: res.database_relations, + meta, + }; + } catch (e) { + return Promise.reject(e); + } +} + +export async function getPublishInfoWithViewId(viewId: string) { + const url = `/api/workspace/published-info/${viewId}`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + namespace: string; + publish_name: string; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function getPublishViewComments(viewId: string): Promise { + const url = `/api/workspace/published-info/${viewId}/comment`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + comments: { + comment_id: string; + user: { + uuid: string; + name: string; + avatar_url: string | null; + }; + content: string; + created_at: string; + last_updated_at: string; + reply_comment_id: string | null; + is_deleted: boolean; + can_be_deleted: boolean; + }[]; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + const { comments } = data.data; + + return comments.map((comment) => { + return { + commentId: comment.comment_id, + user: { + uuid: comment.user?.uuid || '', + name: comment.user?.name || '', + avatarUrl: comment.user?.avatar_url || null, + }, + content: comment.content, + createdAt: comment.created_at, + lastUpdatedAt: comment.last_updated_at, + replyCommentId: comment.reply_comment_id, + isDeleted: comment.is_deleted, + canDeleted: comment.can_be_deleted, + }; + }); + } + + return Promise.reject(data); +} + +export async function getReactions(viewId: string, commentId?: string): Promise> { + let url = `/api/workspace/published-info/${viewId}/reaction`; + + if (commentId) { + url += `?comment_id=${commentId}`; + } + + const response = await axiosInstance?.get<{ + code: number; + data?: { + reactions: { + reaction_type: string; + react_users: { + uuid: string; + name: string; + avatar_url: string | null; + }[]; + comment_id: string; + }[]; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + const { reactions } = data.data; + const reactionsMap: Record = {}; + + for (const reaction of reactions) { + if (!reactionsMap[reaction.comment_id]) { + reactionsMap[reaction.comment_id] = []; + } + + reactionsMap[reaction.comment_id].push({ + reactionType: reaction.reaction_type, + commentId: reaction.comment_id, + reactUsers: reaction.react_users.map((user) => ({ + uuid: user.uuid, + name: user.name, + avatarUrl: user.avatar_url, + })), + }); + } + + return reactionsMap; + } + + return Promise.reject(data); +} + +export async function createGlobalCommentOnPublishView(viewId: string, content: string, replyCommentId?: string) { + const url = `/api/workspace/published-info/${viewId}/comment`; + const response = await axiosInstance?.post<{ code: number; message: string }>(url, { + content, + reply_comment_id: replyCommentId, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function deleteGlobalCommentOnPublishView(viewId: string, commentId: string) { + const url = `/api/workspace/published-info/${viewId}/comment`; + const response = await axiosInstance?.delete<{ code: number; message: string }>(url, { + data: { + comment_id: commentId, + }, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function addReaction(viewId: string, commentId: string, reactionType: string) { + const url = `/api/workspace/published-info/${viewId}/reaction`; + const response = await axiosInstance?.post<{ code: number; message: string }>(url, { + comment_id: commentId, + reaction_type: reactionType, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function removeReaction(viewId: string, commentId: string, reactionType: string) { + const url = `/api/workspace/published-info/${viewId}/reaction`; + const response = await axiosInstance?.delete<{ code: number; message: string }>(url, { + data: { + comment_id: commentId, + reaction_type: reactionType, + }, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function getWorkspaces(): Promise { + const query = new URLSearchParams({ + include_member_count: 'true', + }); + + const url = `/api/workspace?${query.toString()}`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + workspace_id: string; + workspace_name: string; + member_count: number; + icon: string; + }[]; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.map((workspace) => { + return { + id: workspace.workspace_id, + name: workspace.workspace_name, + memberCount: workspace.member_count, + icon: workspace.icon, + }; + }); + } + + return Promise.reject(data); +} + +export interface WorkspaceFolder { + view_id: string; + icon: string | null; + name: string; + is_space: boolean; + is_private: boolean; + extra: { + is_space: boolean; + space_created_at: number; + space_icon: string; + space_icon_color: string; + space_permission: number; + }; + + children: WorkspaceFolder[]; +} + +function iterateFolder(folder: WorkspaceFolder): FolderView { + return { + id: folder.view_id, + name: folder.name, + icon: folder.icon, + isSpace: folder.is_space, + extra: folder.extra ? JSON.stringify(folder.extra) : null, + isPrivate: folder.is_private, + children: folder.children.map((child: WorkspaceFolder) => { + return iterateFolder(child); + }), + }; +} + +export async function getWorkspaceFolder(workspaceId: string): Promise { + const url = `/api/workspace/${workspaceId}/folder`; + const response = await axiosInstance?.get<{ + code: number; + data?: WorkspaceFolder; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return iterateFolder(data.data); + } + + return Promise.reject(data); +} + +export interface DuplicatePublishViewPayload { + published_collab_type: 0 | 1 | 2 | 3 | 4 | 5 | 6; + published_view_id: string; + dest_view_id: string; +} + +export async function duplicatePublishView(workspaceId: string, payload: DuplicatePublishViewPayload) { + const url = `/api/workspace/${workspaceId}/published-duplicate`; + + const res = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, payload); + + if (res?.data.code === 0) { + return; + } + + return Promise.reject(res?.data.message); +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/index.ts new file mode 100644 index 0000000000..e170c830a4 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/index.ts @@ -0,0 +1 @@ +export * as APIService from './http_api'; diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/utils.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/utils.ts new file mode 100644 index 0000000000..172f233d2f --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/utils.ts @@ -0,0 +1,17 @@ +export function blobToBytes(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onloadend = () => { + if (!(reader.result instanceof ArrayBuffer)) { + reject(new Error('Failed to convert blob to bytes')); + return; + } + + resolve(new Uint8Array(reader.result)); + }; + + reader.onerror = reject; + reader.readAsArrayBuffer(blob); + }); +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/index.ts index 93e1cc7993..04f9778edf 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/index.ts @@ -8,24 +8,8 @@ import { } from '@/application/services/js-services/cache'; import { StrategyType } from '@/application/services/js-services/cache/types'; import { fetchPublishView, fetchPublishViewMeta, fetchViewInfo } from '@/application/services/js-services/fetch'; -import { - initAPIService, - signInGoogle, - signInWithMagicLink, - signInGithub, - signInDiscord, - signInWithUrl, - createGlobalCommentOnPublishView, - deleteGlobalCommentOnPublishView, - getPublishViewComments, - getWorkspaces, - getWorkspaceFolder, - getCurrentUser, - duplicatePublishView, - getReactions, - addReaction, - removeReaction, -} from '@/application/services/js-services/wasm/client_api'; +import { APIService } from '@/application/services/js-services/http'; + import { AFService, AFServiceConfig } from '@/application/services/services.type'; import { emit, EventType } from '@/application/session'; import { afterAuth, AUTH_CALLBACK_URL, withSignIn } from '@/application/session/sign_in'; @@ -53,11 +37,7 @@ export class AFClientService implements AFService { private cacheDatabaseRowFolder: Map> = new Map(); constructor(config: AFServiceConfig) { - initAPIService({ - ...config.cloudConfig, - deviceId: this.deviceId, - clientId: this.clientId, - }); + APIService.initAPIService(config.cloudConfig); } getClientId() { @@ -182,7 +162,7 @@ export class AFClientService implements AFService { async loginAuth(url: string) { try { console.log('loginAuth', url); - await signInWithUrl(url); + await APIService.signInWithUrl(url); emit(EventType.SESSION_VALID); afterAuth(); return; @@ -194,51 +174,45 @@ export class AFClientService implements AFService { @withSignIn() async signInMagicLink({ email }: { email: string; redirectTo: string }) { - return await signInWithMagicLink(email, AUTH_CALLBACK_URL); + return await APIService.signInWithMagicLink(email, AUTH_CALLBACK_URL); } @withSignIn() async signInGoogle(_: { redirectTo: string }) { - return await signInGoogle(AUTH_CALLBACK_URL); + return APIService.signInGoogle(AUTH_CALLBACK_URL); } @withSignIn() async signInGithub(_: { redirectTo: string }) { - return await signInGithub(AUTH_CALLBACK_URL); + return APIService.signInGithub(AUTH_CALLBACK_URL); } @withSignIn() async signInDiscord(_: { redirectTo: string }) { - return await signInDiscord(AUTH_CALLBACK_URL); + return APIService.signInDiscord(AUTH_CALLBACK_URL); } async getWorkspaces() { - const data = getWorkspaces(); + const data = APIService.getWorkspaces(); return data; } async getWorkspaceFolder(workspaceId: string) { - const data = await getWorkspaceFolder(workspaceId); + const data = await APIService.getWorkspaceFolder(workspaceId); return data; } async getCurrentUser() { - const data = await getCurrentUser(); + const data = await APIService.getCurrentUser(); - return { - uid: data.uid, - email: data.email, - name: data.name, - avatar: data.icon_url, - uuid: data.uuid, - }; + await APIService.getWorkspaces(); + return data; } async duplicatePublishView(params: DuplicatePublishView) { - return duplicatePublishView({ - workspace_id: params.workspaceId, + return APIService.duplicatePublishView(params.workspaceId, { dest_view_id: params.spaceViewId, published_view_id: params.viewId, published_collab_type: params.collabType, @@ -246,26 +220,26 @@ export class AFClientService implements AFService { } createCommentOnPublishView(viewId: string, content: string, replyCommentId: string | undefined): Promise { - return createGlobalCommentOnPublishView(viewId, content, replyCommentId); + return APIService.createGlobalCommentOnPublishView(viewId, content, replyCommentId); } deleteCommentOnPublishView(viewId: string, commentId: string): Promise { - return deleteGlobalCommentOnPublishView(viewId, commentId); + return APIService.deleteGlobalCommentOnPublishView(viewId, commentId); } getPublishViewGlobalComments(viewId: string): Promise { - return getPublishViewComments(viewId); + return APIService.getPublishViewComments(viewId); } getPublishViewReactions(viewId: string, commentId?: string): Promise> { - return getReactions(viewId, commentId); + return APIService.getReactions(viewId, commentId); } addPublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise { - return addReaction(viewId, commentId, reactionType); + return APIService.addReaction(viewId, commentId, reactionType); } removePublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise { - return removeReaction(viewId, commentId, reactionType); + return APIService.removeReaction(viewId, commentId, reactionType); } } diff --git a/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts b/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts deleted file mode 100644 index 94075ade54..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { getToken, invalidToken, isTokenValid, refreshToken } from '@/application/session/token'; -import { ClientAPI, WorkspaceFolder, DuplicatePublishViewPayload } from '@appflowyinc/client-api-wasm'; -import { AFCloudConfig } from '@/application/services/services.type'; -import { DatabaseId, PublishViewMetaData, RowId, ViewId, ViewLayout } from '@/application/collab.type'; -import { FolderView } from '@/application/types'; -import { GlobalComment, Reaction } from '@/application/comment.type'; - -let client: ClientAPI; - -export function initAPIService( - config: AFCloudConfig & { - deviceId: string; - clientId: string; - } -) { - if (client) { - return; - } - - window.refresh_token = refreshToken; - - window.invalid_token = invalidToken; - - client = ClientAPI.new({ - base_url: config.baseURL, - ws_addr: config.wsURL, - gotrue_url: config.gotrueURL, - device_id: config.deviceId, - client_id: config.clientId, - configuration: { - compression_quality: 8, - compression_buffer_size: 10240, - }, - }); - - if (isTokenValid()) { - client.restore_token(getToken() || ''); - } - - client.subscribe(); -} - -export async function getPublishView(publishNamespace: string, publishName: string) { - const data = await client.get_publish_view(publishNamespace, publishName); - - const meta = JSON.parse(data.meta.data) as PublishViewMetaData; - - if (meta.view.layout === ViewLayout.Document) { - return { - data: data.data, - meta, - }; - } - - try { - const decoder = new TextDecoder('utf-8'); - - const jsonStr = decoder.decode(new Uint8Array(data.data)); - - const res = JSON.parse(jsonStr) as { - database_collab: number[]; - database_row_collabs: Record; - database_row_document_collabs: Record; - visible_database_view_ids: ViewId[]; - database_relations: Record; - }; - - return { - data: res.database_collab, - rows: res.database_row_collabs, - visibleViewIds: res.visible_database_view_ids, - relations: res.database_relations, - meta, - }; - } catch (e) { - return Promise.reject(e); - } -} - -export async function getPublishInfoWithViewId(viewId: string) { - return client.get_publish_info(viewId); -} - -export async function getPublishViewMeta(publishNamespace: string, publishName: string) { - const data = await client.get_publish_view_meta(publishNamespace, publishName); - const metadata = JSON.parse(data.data) as PublishViewMetaData; - - return metadata; -} - -export async function signInWithUrl(url: string) { - return client.sign_in_with_url(url); -} - -export async function signInWithMagicLink(email: string, redirectTo: string) { - return client.sign_in_with_magic_link(email, redirectTo); -} - -export async function signInGoogle(redirectTo: string) { - return signInProvider('google', redirectTo); -} - -export async function signInProvider(provider: string, redirectTo: string) { - try { - const { url } = await client.generate_oauth_url_with_provider(provider, redirectTo); - - window.open(url, '_current'); - } catch (e) { - return Promise.reject(e); - } -} - -export async function signInGithub(redirectTo: string) { - return signInProvider('github', redirectTo); -} - -export async function signInDiscord(redirectTo: string) { - return signInProvider('discord', redirectTo); -} - -export async function getWorkspaces() { - try { - const { data } = await client.get_workspaces(); - - return data.map((workspace) => ({ - id: workspace.workspace_id, - name: workspace.workspace_name, - icon: workspace.icon, - memberCount: workspace.member_count || 0, - })); - } catch (e) { - return Promise.reject(e); - } -} - -export async function getWorkspaceFolder(workspaceId: string): Promise { - try { - const data = await client.get_folder(workspaceId); - - // eslint-disable-next-line no-inner-declarations - function iterateFolder(folder: WorkspaceFolder): FolderView { - return { - id: folder.view_id, - name: folder.name, - icon: folder.icon, - isSpace: folder.is_space, - extra: folder.extra, - isPrivate: folder.is_private, - children: folder.children.map((child: WorkspaceFolder) => { - return iterateFolder(child); - }), - }; - } - - return iterateFolder(data); - } catch (e) { - return Promise.reject(e); - } -} - -export function getCurrentUser() { - return client.get_user(); -} - -export function duplicatePublishView(payload: DuplicatePublishViewPayload) { - return client.duplicate_publish_view(payload); -} - -export async function getPublishViewComments(viewId: string): Promise { - try { - const { comments } = await client.get_publish_view_comments(viewId); - - return comments.map((comment) => { - return { - commentId: comment.comment_id, - user: { - uuid: comment.user?.uuid || '', - name: comment.user?.name || '', - avatarUrl: comment.user?.avatar_url || null, - }, - content: comment.content, - createdAt: comment.created_at, - lastUpdatedAt: comment.last_updated_at, - replyCommentId: comment.reply_comment_id, - isDeleted: comment.is_deleted, - canDeleted: comment.can_be_deleted, - }; - }); - } catch (e) { - return Promise.reject(e); - } -} - -export async function createGlobalCommentOnPublishView(viewId: string, content: string, replyCommentId?: string) { - return client.create_comment_on_publish_view(viewId, content, replyCommentId); -} - -export async function deleteGlobalCommentOnPublishView(viewId: string, commentId: string) { - return client.delete_comment_on_publish_view(viewId, commentId); -} - -export async function getReactions(viewId: string, commentId?: string): Promise> { - try { - const { reactions } = await client.get_reactions(viewId, commentId); - - const reactionsMap: Record = {}; - - for (const reaction of reactions) { - if (!reactionsMap[reaction.comment_id]) { - reactionsMap[reaction.comment_id] = []; - } - - reactionsMap[reaction.comment_id].push({ - reactionType: reaction.reaction_type, - commentId: reaction.comment_id, - reactUsers: reaction.react_users.map((user) => ({ - uuid: user.uuid, - name: user.name, - avatarUrl: user.avatar_url, - })), - }); - } - - return reactionsMap; - } catch (e) { - return Promise.reject(e); - } -} - -export async function addReaction(viewId: string, commentId: string, reactionType: string) { - return client.create_reaction(viewId, commentId, reactionType); -} - -export async function removeReaction(viewId: string, commentId: string, reactionType: string) { - return client.delete_reaction(viewId, commentId, reactionType); -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/wasm/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/wasm/index.ts deleted file mode 100644 index b4f0b4f4cc..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/wasm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * as APIService from './client_api'; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/session/token.ts b/frontend/appflowy_web_app/src/application/session/token.ts index 174e350490..56f7a1c145 100644 --- a/frontend/appflowy_web_app/src/application/session/token.ts +++ b/frontend/appflowy_web_app/src/application/session/token.ts @@ -18,3 +18,21 @@ export function isTokenValid() { export function getToken() { return localStorage.getItem('token'); } + +export function getTokenParsed(): { + access_token: string; + expires_at: number; + refresh_token: string; +} | null { + const token = getToken(); + + if (!token) { + return null; + } + + try { + return JSON.parse(token); + } catch (e) { + return null; + } +} diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts index 2158061708..6c47eaac9e 100644 --- a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts @@ -1,9 +1,7 @@ -import { MAX_FREQUENTLY_ROW_COUNT, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const'; import { loadEmojiData } from '@/utils/emoji'; import { EmojiMartData } from '@emoji-mart/data'; import { PopoverProps } from '@mui/material/Popover'; import { PopoverOrigin } from '@mui/material/Popover/Popover'; -import { FrequentlyUsed, getEmojiDataFromNative, init, Store } from 'emoji-mart'; import chunk from 'lodash-es/chunk'; import React, { useCallback, useEffect, useState } from 'react'; @@ -22,12 +20,12 @@ export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: str const [searchValue, setSearchValue] = useState(''); const [emojiCategories, setEmojiCategories] = useState([]); const [skin, setSkin] = useState(() => { - return Number(Store.get('skin')) || 0; + return Number(localStorage.getItem('emoji-mart.skin')) || 0; }); const onSkinChange = useCallback((val: number) => { setSkin(val); - Store.set('skin', String(val)); + localStorage.setItem('emoji-mart.skin', String(val)); }, []); const searchEmojiData = useCallback( @@ -70,10 +68,6 @@ export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: str useEffect(() => { void (async () => { - await init({ - maxFrequentRows: MAX_FREQUENTLY_ROW_COUNT, - perLine: PER_ROW_EMOJI_COUNT, - }); await searchEmojiData(); })(); }, [searchEmojiData]); @@ -85,17 +79,6 @@ export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: str const onSelect = useCallback( async (native: string) => { onEmojiSelect(native); - if (!native) { - return; - } - - try { - const data = await getEmojiDataFromNative(native); - - FrequentlyUsed.add(data); - } catch (e) { - // do nothing - } }, [onEmojiSelect] ); @@ -156,6 +139,7 @@ export function getRowsWithCategories(emojiCategories: EmojiCategory[], rowSize: id: string; type: 'category' | 'emojis'; emojis?: Emoji[]; + category?: string; }[] = []; emojiCategories.forEach((category) => { @@ -165,6 +149,7 @@ export function getRowsWithCategories(emojiCategories: EmojiCategory[], rowSize: }); chunk(category.emojis, rowSize).forEach((chunk, index) => { rows.push({ + category: category.id, type: 'emojis', emojis: chunk, id: `${category.id}-${index}`, diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerCategories.tsx b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerCategories.tsx index 8cc1ee5947..0ea988ca8b 100644 --- a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerCategories.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerCategories.tsx @@ -67,17 +67,36 @@ function EmojiPickerCategories({ const renderRow = useCallback( ({ index, style }: { index: number; style: React.CSSProperties }) => { const item = rows[index]; + const tagName = getCategoryName(item.id); + const isFlags = item.category === 'flags'; return (
{item.type === 'category' ? ( -
{getCategoryName(item.id)}
+
{tagName}
) : null}
{item.emojis?.map((emoji, columnIndex) => { const isSelected = selectCell.row === index && selectCell.column === columnIndex; const isDefaultEmoji = defaultEmoji === emoji.native; + const classList = [ + 'flex cursor-pointer items-center justify-center rounded text-[20px] hover:bg-fill-list-hover', + ]; + + if (isSelected) { + classList.push('bg-fill-list-hover'); + } else { + classList.push('hover:bg-transparent'); + } + + if (isDefaultEmoji) { + classList.push('bg-fill-list-active'); + } + + if (isFlags) { + classList.push('icon'); + } return ( @@ -105,9 +124,7 @@ function EmojiPickerCategories({ mouseX.current = e.clientX; mouseY.current = e.clientY; }} - className={`flex cursor-pointer items-center justify-center rounded text-[20px] hover:bg-fill-list-hover ${ - isSelected ? 'bg-fill-list-hover' : 'hover:bg-transparent' - } ${isDefaultEmoji ? 'bg-fill-list-active' : ''}`} + className={classList.join(' ')} > {emoji.native}
diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/index.ts b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/index.ts index 2e8188b1cc..035bfce0b6 100644 --- a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/index.ts +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/index.ts @@ -1,3 +1,5 @@ -import { lazy } from 'react'; +// import { lazy } from 'react'; +// +// export const EmojiPicker = lazy(() => import('./EmojiPicker')); -export const EmojiPicker = lazy(() => import('./EmojiPicker')); +export * from './EmojiPicker'; diff --git a/frontend/appflowy_web_app/src/components/database/components/header/DatabaseHeader.tsx b/frontend/appflowy_web_app/src/components/database/components/header/DatabaseHeader.tsx index a98c434dd2..3ba5b004b9 100644 --- a/frontend/appflowy_web_app/src/components/database/components/header/DatabaseHeader.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/header/DatabaseHeader.tsx @@ -1,6 +1,7 @@ import { ViewLayout, ViewMetaIcon } from '@/application/collab.type'; import { ViewIcon } from '@/components/_shared/view-icon'; -import React from 'react'; +import { isFlagEmoji } from '@/utils/emoji'; +import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; function DatabaseHeader({ @@ -14,6 +15,9 @@ function DatabaseHeader({ layout?: ViewLayout; }) { const { t } = useTranslation(); + const isFlag = useMemo(() => { + return icon ? isFlagEmoji(icon.value) : false; + }, [icon]); return (
{icon?.value ? ( -
{icon?.value}
+
{icon?.value}
) : ( )} diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionPage.tsx b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionPage.tsx index f839e6f9bb..eb1c61625d 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionPage.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionPage.tsx @@ -2,6 +2,7 @@ import { ViewLayout } from '@/application/collab.type'; import { ViewMeta } from '@/application/db/tables/view_metas'; import { ViewIcon } from '@/components/_shared/view-icon'; import { useEditorContext } from '@/components/editor/EditorContext'; +import { isFlagEmoji } from '@/utils/emoji'; import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -32,6 +33,10 @@ function MentionPage({ pageId }: { pageId: string }) { const { t } = useTranslation(); + const isFlag = useMemo(() => { + return icon ? isFlagEmoji(icon.value) : false; + }, [icon]); + return ( { @@ -44,7 +49,7 @@ function MentionPage({ pageId }: { pageId: string }) { No Access ) : ( <> - + {icon?.value || } diff --git a/frontend/appflowy_web_app/src/components/global-comment/GlobalComment.hooks.tsx b/frontend/appflowy_web_app/src/components/global-comment/GlobalComment.hooks.tsx index f1529773f6..c71e4fd86d 100644 --- a/frontend/appflowy_web_app/src/components/global-comment/GlobalComment.hooks.tsx +++ b/frontend/appflowy_web_app/src/components/global-comment/GlobalComment.hooks.tsx @@ -185,7 +185,7 @@ export function useCommentRender(comment: GlobalComment) { }, [comment]); const timeFormat = useMemo(() => { - const time = dayjs.unix(Number(comment.lastUpdatedAt)); + const time = dayjs(comment.lastUpdatedAt); return time.format('YYYY-MM-DD HH:mm:ss'); }, [comment.lastUpdatedAt]); @@ -193,7 +193,7 @@ export function useCommentRender(comment: GlobalComment) { const time = useMemo(() => { if (!comment.lastUpdatedAt) return ''; const now = dayjs(); - const past = dayjs.unix(Number(comment.lastUpdatedAt)); + const past = dayjs(comment.lastUpdatedAt); const diffSec = now.diff(past, 'second'); const diffMin = now.diff(past, 'minute'); const diffHour = now.diff(past, 'hour'); diff --git a/frontend/appflowy_web_app/src/components/global-comment/reactions/Reaction.tsx b/frontend/appflowy_web_app/src/components/global-comment/reactions/Reaction.tsx index b3ae81c6d9..89b25ea22e 100644 --- a/frontend/appflowy_web_app/src/components/global-comment/reactions/Reaction.tsx +++ b/frontend/appflowy_web_app/src/components/global-comment/reactions/Reaction.tsx @@ -1,5 +1,6 @@ import { Reaction as ReactionType } from '@/application/comment.type'; import { AFConfigContext } from '@/components/app/AppConfig'; +import { isFlagEmoji } from '@/utils/emoji'; import { getPlatform } from '@/utils/platform'; import { Tooltip } from '@mui/material'; import React, { memo, useContext, useMemo } from 'react'; @@ -68,6 +69,10 @@ function Reaction({ reaction, onClick }: { reaction: ReactionType; onClick: (rea return getPlatform().isMobile; }, []); + const isFlag = useMemo(() => { + return isFlagEmoji(reaction.reactionType); + }, [reaction.reactionType]); + return ( - {reaction.reactionType} + {reaction.reactionType} {
{reactCount}
}
diff --git a/frontend/appflowy_web_app/src/components/publish/header/BreadcrumbItem.tsx b/frontend/appflowy_web_app/src/components/publish/header/BreadcrumbItem.tsx index d8c2f93b5b..9718f3414a 100644 --- a/frontend/appflowy_web_app/src/components/publish/header/BreadcrumbItem.tsx +++ b/frontend/appflowy_web_app/src/components/publish/header/BreadcrumbItem.tsx @@ -4,6 +4,7 @@ import { notify } from '@/components/_shared/notify'; import { ViewIcon } from '@/components/_shared/view-icon'; import SpaceIcon from '@/components/publish/header/SpaceIcon'; import { renderColor } from '@/utils/color'; +import { isFlagEmoji } from '@/utils/emoji'; import { Tooltip } from '@mui/material'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -34,6 +35,9 @@ function BreadcrumbItem({ crumb, disableClick = false }: { crumb: Crumb; disable const { t } = useTranslation(); const onNavigateToView = usePublishContext()?.toView; + const isFlag = useMemo(() => { + return icon ? isFlagEmoji(icon) : false; + }, [icon]); return ( @@ -59,7 +63,7 @@ function BreadcrumbItem({ crumb, disableClick = false }: { crumb: Crumb; disable ) : ( - + {icon || } )} diff --git a/frontend/appflowy_web_app/src/components/publish/header/PublishViewHeader.tsx b/frontend/appflowy_web_app/src/components/publish/header/PublishViewHeader.tsx index 90923e5dfd..121e0774e0 100644 --- a/frontend/appflowy_web_app/src/components/publish/header/PublishViewHeader.tsx +++ b/frontend/appflowy_web_app/src/components/publish/header/PublishViewHeader.tsx @@ -93,9 +93,7 @@ export function PublishViewHeader({ onOpenDrawer, openDrawer }: { onOpenDrawer:
- {/**/} - {/* */} - {/**/} + {/**/}