New draft preview UI
This commit is contained in:
@@ -36,7 +36,7 @@ export type RichTextProps = TextStyleProp &
|
||||
*
|
||||
* Use with care - only use if you're rendering facets you're generating yourself.
|
||||
*/
|
||||
validateMentionFacets?: boolean
|
||||
disableMentionFacetValidation?: true
|
||||
}
|
||||
|
||||
export function RichText({
|
||||
@@ -54,7 +54,7 @@ export function RichText({
|
||||
onLayout,
|
||||
onTextLayout,
|
||||
shouldProxyLinks,
|
||||
validateMentionFacets,
|
||||
disableMentionFacetValidation,
|
||||
}: RichTextProps) {
|
||||
const richText = useMemo(() => {
|
||||
if (value instanceof RichTextAPI) {
|
||||
@@ -116,7 +116,7 @@ export function RichText({
|
||||
|
||||
if (
|
||||
mention &&
|
||||
(!validateMentionFacets ||
|
||||
(disableMentionFacetValidation ||
|
||||
AppBskyRichtextFacet.validateMention(mention).success) &&
|
||||
!disableLinks
|
||||
) {
|
||||
|
||||
@@ -2,15 +2,17 @@ import * as Device from 'expo-device'
|
||||
|
||||
import * as env from '#/env'
|
||||
|
||||
export const FALLBACK_ANDROID = 'Android'
|
||||
export const FALLBACK_IOS = 'iOS'
|
||||
export const FALLBACK_WEB = 'Web'
|
||||
|
||||
export function getDeviceName(): string {
|
||||
const deviceName = Device.deviceName
|
||||
if (env.IS_ANDROID) {
|
||||
return deviceName || 'Android'
|
||||
return deviceName || FALLBACK_ANDROID
|
||||
} else if (env.IS_IOS) {
|
||||
// we need an entitlement to get the real device name on iOS, so just
|
||||
// return a generic name for now
|
||||
return 'iOS'
|
||||
return deviceName || FALLBACK_IOS
|
||||
} else {
|
||||
return 'Web' // could append browser info here
|
||||
return FALLBACK_WEB // could append browser info here
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import {useCallback, useEffect, useState} from 'react'
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import * as VideoThumbnails from 'expo-video-thumbnails'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
|
||||
import * as device from '#/lib/deviceName'
|
||||
import {logger} from '#/view/com/composer/drafts/state/logger'
|
||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme, select} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {atoms as a, select, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlusIcon} from '#/components/icons/CirclePlus'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
|
||||
import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import * as MediaPreview from '#/components/MediaPreview'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
@@ -33,6 +34,29 @@ export function DraftItem({
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const discardPromptControl = Prompt.usePromptControl()
|
||||
const post = draft.posts[0]
|
||||
|
||||
const mediaExistsOnOtherDevice =
|
||||
!draft.meta.isOriginatingDevice && draft.meta.hasMissingMedia
|
||||
const mediaIsMissing =
|
||||
draft.meta.isOriginatingDevice && draft.meta.hasMissingMedia
|
||||
const hasMetadata =
|
||||
draft.meta.replyCount > 0 ||
|
||||
mediaExistsOnOtherDevice ||
|
||||
draft.meta.hasQuotes
|
||||
|
||||
const deviceName = useMemo(() => {
|
||||
const raw = draft.draft.deviceName
|
||||
let name = raw
|
||||
switch (raw) {
|
||||
case device.FALLBACK_IOS:
|
||||
case device.FALLBACK_ANDROID:
|
||||
case device.FALLBACK_WEB:
|
||||
name = _(msg`another device`)
|
||||
break
|
||||
}
|
||||
return name
|
||||
}, [_, draft])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
onDelete(draft)
|
||||
@@ -48,7 +72,11 @@ export function DraftItem({
|
||||
onPress={() => onSelect(draft)}
|
||||
style={({pressed, hovered}) => [
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.shadow_sm,
|
||||
pressed || hovered
|
||||
? t.atoms.border_contrast_medium
|
||||
: t.atoms.border_contrast_low,
|
||||
{
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.atoms.bg.backgroundColor,
|
||||
@@ -56,21 +84,63 @@ export function DraftItem({
|
||||
dim: t.atoms.bg_contrast_25.backgroundColor,
|
||||
}),
|
||||
},
|
||||
(pressed || hovered) && t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.p_lg,
|
||||
a.pb_md,
|
||||
a.gap_sm,
|
||||
{
|
||||
paddingTop: 20 + a.pt_md.paddingTop,
|
||||
},
|
||||
]}>
|
||||
<RichText
|
||||
style={[a.text_md, a.leading_snug, a.pointer_events_none]}
|
||||
value={post.text}
|
||||
enableTags
|
||||
disableMentionFacetValidation
|
||||
/>
|
||||
|
||||
{!mediaExistsOnOtherDevice && <DraftMediaPreview post={post} />}
|
||||
|
||||
{hasMetadata && (
|
||||
<View style={[a.gap_xs]}>
|
||||
{mediaExistsOnOtherDevice && (
|
||||
<DraftMetadataTag
|
||||
icon={WarningIcon}
|
||||
text={_(msg`Media stored on ${deviceName}`)}
|
||||
/>
|
||||
)}
|
||||
{mediaIsMissing && (
|
||||
<DraftMetadataTag
|
||||
display="warning"
|
||||
icon={WarningIcon}
|
||||
text={_(msg`Missing media`)}
|
||||
/>
|
||||
)}
|
||||
{draft.meta.hasQuotes && (
|
||||
<DraftMetadataTag
|
||||
icon={CloseQuoteIcon}
|
||||
text={_(msg`Quote post`)}
|
||||
/>
|
||||
)}
|
||||
{draft.meta.replyCount > 0 && (
|
||||
<DraftMetadataTag
|
||||
icon={CirclePlusIcon}
|
||||
text={plural(draft.meta.replyCount, {
|
||||
one: '1 more post',
|
||||
other: '# more posts',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Timestamp */}
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
@@ -85,9 +155,9 @@ export function DraftItem({
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.text_sm,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
a.leading_tight,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{timeElapsed}
|
||||
@@ -96,6 +166,7 @@ export function DraftItem({
|
||||
</TimeElapsed>
|
||||
</View>
|
||||
|
||||
{/* Menu button */}
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
@@ -151,71 +222,6 @@ export function DraftItem({
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function DraftItemOld({
|
||||
draft,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: {
|
||||
draft: DraftSummary
|
||||
onSelect: (draft: DraftSummary) => void
|
||||
onDelete: (draft: DraftSummary) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const discardPromptControl = Prompt.usePromptControl()
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
onDelete(draft)
|
||||
}, [onDelete, draft])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Open draft`)}
|
||||
accessibilityHint={_(msg`Opens this draft in the composer`)}
|
||||
onPress={() => onSelect(draft)}
|
||||
style={({pressed, hovered}) => [
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.shadow_sm,
|
||||
(pressed || hovered) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={[a.p_md, a.gap_sm]}>
|
||||
{draft.hasMissingMedia && (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.px_sm,
|
||||
a.py_xs,
|
||||
a.mb_xs,
|
||||
t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Some media unavailable (saved on another device)</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{draft.posts.map((post, index) => (
|
||||
<DraftPostRow
|
||||
key={post.id}
|
||||
post={post}
|
||||
isFirst={index === 0}
|
||||
isLast={index === draft.posts.length - 1}
|
||||
timestamp={draft.updatedAt}
|
||||
discardPromptControl={discardPromptControl}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<Prompt.Basic
|
||||
control={discardPromptControl}
|
||||
@@ -229,128 +235,28 @@ export function DraftItemOld({
|
||||
)
|
||||
}
|
||||
|
||||
function DraftPostRow({
|
||||
post,
|
||||
isFirst,
|
||||
isLast,
|
||||
timestamp,
|
||||
discardPromptControl,
|
||||
function DraftMetadataTag({
|
||||
display = 'info',
|
||||
icon: Icon,
|
||||
text,
|
||||
}: {
|
||||
post: DraftPostDisplay
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
timestamp: string
|
||||
discardPromptControl: Prompt.PromptControlProps
|
||||
display?: 'info' | 'warning'
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
text: string
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const profile = useCurrentAccountProfile()
|
||||
|
||||
const color = {
|
||||
info: t.atoms.text_contrast_medium.color,
|
||||
warning: select(t.name, {
|
||||
light: '#C99A00',
|
||||
dark: '#FFC404',
|
||||
dim: '#FFC404',
|
||||
}),
|
||||
}[display]
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<View style={[a.align_center]}>
|
||||
<UserAvatar type="user" size={42} avatar={profile?.avatar} />
|
||||
{!isLast && (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mt_xs,
|
||||
{
|
||||
width: 2,
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
minHeight: 8,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<View style={[a.flex_row, a.align_center, a.flex_1, a.gap_xs]}>
|
||||
{profile && (
|
||||
<>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
t.atoms.text,
|
||||
a.leading_snug,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{createSanitizedDisplayName(profile)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeHandle(profile.handle)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
]}>
|
||||
·
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<TimeElapsed timestamp={timestamp}>
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{timeElapsed}
|
||||
</Text>
|
||||
)}
|
||||
</TimeElapsed>
|
||||
</View>
|
||||
|
||||
{isFirst && (
|
||||
<Button
|
||||
label={_(msg`More options`)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
size="tiny"
|
||||
onPress={e => {
|
||||
e.stopPropagation()
|
||||
discardPromptControl.open()
|
||||
}}>
|
||||
<ButtonIcon icon={DotsIcon} />
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{post.text ? (
|
||||
<RichText
|
||||
style={[a.text_md, a.leading_snug, a.pointer_events_none]}
|
||||
value={post.text}
|
||||
enableTags
|
||||
validateMentionFacets={false}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.italic,
|
||||
]}>
|
||||
<Trans>(No text)</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<DraftMediaPreview post={post} />
|
||||
</View>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<Icon size="sm" fill={color} />
|
||||
<Text style={[a.text_sm, a.leading_tight, {color}]}>{text}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -409,7 +315,7 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaPreview.Outer style={[a.pt_xs]}>
|
||||
<MediaPreview.Outer>
|
||||
{loadedImages.map((image, i) => (
|
||||
<MediaPreview.ImageItem key={i} thumbnail={image.url} alt={image.alt} />
|
||||
))}
|
||||
|
||||
@@ -18,9 +18,11 @@ import {
|
||||
type PostDraft,
|
||||
} from '#/view/com/composer/state/composer'
|
||||
import {type VideoState} from '#/view/com/composer/state/video'
|
||||
import {type AnalyticsContextType} from '#/analytics'
|
||||
import {getDeviceId} from '#/analytics/identifiers'
|
||||
import {logger} from './logger'
|
||||
import {type DraftPostDisplay, type DraftSummary} from './schema'
|
||||
import * as storage from './storage'
|
||||
|
||||
const TENOR_HOSTNAME = 'media.tenor.com'
|
||||
|
||||
@@ -269,16 +271,24 @@ function serializeGif(gifMedia: {
|
||||
* Convert server DraftView to DraftSummary for list display.
|
||||
* Also checks which media files exist locally.
|
||||
*/
|
||||
export function draftViewToSummary(
|
||||
view: AppBskyDraftDefs.DraftView,
|
||||
localMediaExists: (path: string) => boolean,
|
||||
): DraftSummary {
|
||||
const firstPost = view.draft.posts[0]
|
||||
const previewText = firstPost?.text?.slice(0, 100) || ''
|
||||
|
||||
let mediaCount = 0
|
||||
let hasMedia = false
|
||||
let hasMissingMedia = false
|
||||
export function draftViewToSummary({
|
||||
view,
|
||||
analytics,
|
||||
}: {
|
||||
view: AppBskyDraftDefs.DraftView
|
||||
analytics: AnalyticsContextType
|
||||
}): DraftSummary {
|
||||
const meta = {
|
||||
isOriginatingDevice: view.draft.deviceId === getDeviceId(),
|
||||
postCount: view.draft.posts.length,
|
||||
// minus anchor post
|
||||
replyCount: view.draft.posts.length - 1,
|
||||
hasMedia: false,
|
||||
hasMissingMedia: false,
|
||||
mediaCount: 0,
|
||||
hasQuotes: false,
|
||||
quoteCount: 0,
|
||||
}
|
||||
|
||||
const posts: DraftPostDisplay[] = view.draft.posts.map((post, index) => {
|
||||
const images: DraftPostDisplay['images'] = []
|
||||
@@ -288,11 +298,11 @@ export function draftViewToSummary(
|
||||
// Process images
|
||||
if (post.embedImages) {
|
||||
for (const img of post.embedImages) {
|
||||
mediaCount++
|
||||
hasMedia = true
|
||||
const exists = localMediaExists(img.localRef.path)
|
||||
meta.mediaCount++
|
||||
meta.hasMedia = true
|
||||
const exists = storage.mediaExists(img.localRef.path)
|
||||
if (!exists) {
|
||||
hasMissingMedia = true
|
||||
meta.hasMissingMedia = true
|
||||
}
|
||||
images.push({
|
||||
localPath: img.localRef.path,
|
||||
@@ -305,11 +315,11 @@ export function draftViewToSummary(
|
||||
// Process videos
|
||||
if (post.embedVideos) {
|
||||
for (const vid of post.embedVideos) {
|
||||
mediaCount++
|
||||
hasMedia = true
|
||||
const exists = localMediaExists(vid.localRef.path)
|
||||
meta.mediaCount++
|
||||
meta.hasMedia = true
|
||||
const exists = storage.mediaExists(vid.localRef.path)
|
||||
if (!exists) {
|
||||
hasMissingMedia = true
|
||||
meta.hasMissingMedia = true
|
||||
}
|
||||
videos.push({
|
||||
localPath: vid.localRef.path,
|
||||
@@ -324,13 +334,18 @@ export function draftViewToSummary(
|
||||
for (const ext of post.embedExternals) {
|
||||
const gifData = parseGifFromUrl(ext.uri)
|
||||
if (gifData) {
|
||||
mediaCount++
|
||||
hasMedia = true
|
||||
meta.mediaCount++
|
||||
meta.hasMedia = true
|
||||
gif = gifData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (post.embedRecords && post.embedRecords.length > 0) {
|
||||
meta.quoteCount += post.embedRecords.length
|
||||
meta.hasQuotes = true
|
||||
}
|
||||
|
||||
return {
|
||||
id: `post-${index}`,
|
||||
text: post.text || '',
|
||||
@@ -340,17 +355,17 @@ export function draftViewToSummary(
|
||||
}
|
||||
})
|
||||
|
||||
if (meta.isOriginatingDevice && meta.hasMissingMedia) {
|
||||
analytics.logger.warn(`Draft is missing media on originating device`, {})
|
||||
}
|
||||
|
||||
return {
|
||||
id: view.id,
|
||||
draft: view.draft,
|
||||
previewText,
|
||||
hasMedia,
|
||||
hasMissingMedia,
|
||||
mediaCount,
|
||||
postCount: view.draft.posts.length,
|
||||
createdAt: view.createdAt,
|
||||
updatedAt: view.updatedAt,
|
||||
draft: view.draft,
|
||||
posts,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {type ComposerState} from '#/view/com/composer/state/composer'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {getDeviceId} from '#/analytics/identifiers'
|
||||
import {composerStateToDraft, draftViewToSummary} from './api'
|
||||
import {logger} from './logger'
|
||||
@@ -20,6 +21,7 @@ const DRAFTS_QUERY_KEY = ['drafts']
|
||||
*/
|
||||
export function useDraftsQuery() {
|
||||
const agent = useAgent()
|
||||
const ax = useAnalytics()
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: DRAFTS_QUERY_KEY,
|
||||
@@ -30,7 +32,10 @@ export function useDraftsQuery() {
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
drafts: res.data.drafts.map(view =>
|
||||
draftViewToSummary(view, path => storage.mediaExists(path)),
|
||||
draftViewToSummary({
|
||||
view,
|
||||
analytics: ax,
|
||||
}),
|
||||
),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -50,22 +50,31 @@ export type DraftPostDisplay = {
|
||||
*/
|
||||
export type DraftSummary = {
|
||||
id: string
|
||||
/** The full draft data from the server */
|
||||
draft: AppBskyDraftDefs.Draft
|
||||
/** First ~100 chars of first post */
|
||||
previewText: string
|
||||
/** Whether the draft has media */
|
||||
hasMedia: boolean
|
||||
/** Whether some media is missing (saved on another device) */
|
||||
hasMissingMedia?: boolean
|
||||
/** Number of media items */
|
||||
mediaCount: number
|
||||
/** Number of posts in thread */
|
||||
postCount: number
|
||||
/** ISO timestamp of creation */
|
||||
createdAt: string
|
||||
/** ISO timestamp of last update */
|
||||
updatedAt: string
|
||||
/** The full draft data from the server */
|
||||
draft: AppBskyDraftDefs.Draft
|
||||
/** All posts in the draft for full display */
|
||||
posts: DraftPostDisplay[]
|
||||
/** Metadata about the draft for display purposes */
|
||||
meta: {
|
||||
/** Whether this device is the originating device for the draft */
|
||||
isOriginatingDevice: boolean
|
||||
/** Number of posts in thread */
|
||||
postCount: number
|
||||
/** Number of replies to anchor post */
|
||||
replyCount: number
|
||||
/** Whether the draft has media */
|
||||
hasMedia: boolean
|
||||
/** Whether some media is missing (saved on another device) */
|
||||
hasMissingMedia?: boolean
|
||||
/** Number of media items */
|
||||
mediaCount: number
|
||||
/** Whether any posts in the draft has quotes */
|
||||
hasQuotes: boolean
|
||||
/** Number of quotes in the draft */
|
||||
quoteCount: number
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user