mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 02:32:18 +00:00
feat: Add sort functionality to Media Asset Panel (#6695)
## Overview Adds sort functionality to the Media Asset Panel. Users can sort assets by creation time in Cloud environments. ## Key Changes ### 1. Sort Functionality (Cloud Only) - "Newest first" (most recent) - "Oldest first" (oldest) - Sorting based on `create_time` field (output assets) - Sorting based on `created_at` field (input assets) - Sort button is only displayed in Cloud environments ### 2. create_time Field Integration **Related PR**: #6092 Implemented sort functionality using the `create_time` field introduced in PR #6092. Applied the code from that PR directly to the following files: - `src/schemas/apiSchema.ts`: Added `create_time` field to `zExtraData` - `src/stores/queueStore.ts`: Added `createTime` getter to `TaskItemImpl` - `src/platform/remote/comfyui/history/types/historyV2Types.ts`: Added `create_time` to History V2 API response types - `src/platform/remote/comfyui/history/adapters/v2ToV1Adapter.ts`: Pass through `create_time` in V2→V1 adapter - `src/platform/assets/composables/media/assetMappers.ts`: Include `create_time` in asset metadata ### 3. Component Structure Improvements Created new components following existing component styles for consistency: - **`MediaAssetSearchBar.vue`**: Component combining existing SearchBox with sort button - **`AssetSortButton.vue`**: Same structure as `MoreButton.vue` (IconButton + Popover) - **`MediaAssetSortMenu.vue`**: Same style as `MediaAssetMoreMenu.vue` (using IconTextButton) - **`AssetsSidebarTab.vue`**: Refactored to use `MediaAssetSearchBar` ### 4. Utility Usage - Improved sort logic using `es-toolkit`'s `sortBy` - Follows project guidelines (CLAUDE.md) ## Technical Details ### History V2 API's create_time - Cloud backend provides `create_time` (in milliseconds) through History V2 API - Enables accurate sorting by creation time - For input assets, uses existing `created_at` (ISO string) ### Sort Implementation Uses `es-toolkit`'s `sortBy` in `useMediaAssetFiltering` composable: ```typescript // Get timestamp from asset (either create_time or created_at) const getAssetTime = (asset: AssetItem): number => { return ( (asset.user_metadata?.create_time as number) ?? (asset.created_at ? new Date(asset.created_at).getTime() : 0) ) } // Sort by time if (sortBy.value === 'oldest') { return sortByUtil(searchFiltered.value, [getAssetTime]) } else { return sortByUtil(searchFiltered.value, [(asset) => -getAssetTime(asset)]) } ``` ## Testing - ✅ Typecheck passed - ✅ Lint passed - ✅ Format passed 🤖 Generated with [Claude Code](https://claude.com/claude-code) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6695-feat-Add-sort-functionality-to-Media-Asset-Panel-2ab6d73d3650818c818ff3559875d869) by [Unito](https://www.unito.io) Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
<AssetsSidebarTemplate>
|
<AssetsSidebarTemplate>
|
||||||
<template #top>
|
<template #top>
|
||||||
<span v-if="!isInFolderView" class="font-bold">
|
<span v-if="!isInFolderView" class="font-bold">
|
||||||
{{ $t('sideToolbar.mediaAssets') }}
|
{{ $t('sideToolbar.mediaAssets.title') }}
|
||||||
</span>
|
</span>
|
||||||
<div v-else class="flex w-full items-center justify-between gap-2">
|
<div v-else class="flex w-full items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -39,14 +39,11 @@
|
|||||||
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
|
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
|
||||||
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
|
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<!-- Search Bar -->
|
<!-- Filter Bar -->
|
||||||
<div class="pt-2">
|
<MediaAssetFilterBar
|
||||||
<SearchBox
|
v-model:search-query="searchQuery"
|
||||||
v-model="searchQuery"
|
v-model:sort-by="sortBy"
|
||||||
:placeholder="$t('sideToolbar.searchAssets')"
|
/>
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #body>
|
<template #body>
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
@@ -165,12 +162,12 @@ import IconTextButton from '@/components/button/IconTextButton.vue'
|
|||||||
import TextButton from '@/components/button/TextButton.vue'
|
import TextButton from '@/components/button/TextButton.vue'
|
||||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||||
import SearchBox from '@/components/input/SearchBox.vue'
|
|
||||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||||
import Tab from '@/components/tab/Tab.vue'
|
import Tab from '@/components/tab/Tab.vue'
|
||||||
import TabList from '@/components/tab/TabList.vue'
|
import TabList from '@/components/tab/TabList.vue'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||||
|
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||||
@@ -247,7 +244,8 @@ const baseAssets = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Use media asset filtering composable
|
// Use media asset filtering composable
|
||||||
const { searchQuery, filteredAssets } = useMediaAssetFiltering(baseAssets)
|
const { searchQuery, sortBy, filteredAssets } =
|
||||||
|
useMediaAssetFiltering(baseAssets)
|
||||||
|
|
||||||
const displayAssets = computed(() => {
|
const displayAssets = computed(() => {
|
||||||
return filteredAssets.value
|
return filteredAssets.value
|
||||||
|
|||||||
@@ -618,7 +618,11 @@
|
|||||||
"workflows": "Workflows",
|
"workflows": "Workflows",
|
||||||
"templates": "Templates",
|
"templates": "Templates",
|
||||||
"assets": "Assets",
|
"assets": "Assets",
|
||||||
"mediaAssets": "Media Assets",
|
"mediaAssets": {
|
||||||
|
"title": "Media Assets",
|
||||||
|
"sortNewestFirst": "Newest first",
|
||||||
|
"sortOldestFirst": "Oldest first"
|
||||||
|
},
|
||||||
"backToAssets": "Back to all assets",
|
"backToAssets": "Back to all assets",
|
||||||
"searchAssets": "Search assets...",
|
"searchAssets": "Search assets...",
|
||||||
"labels": {
|
"labels": {
|
||||||
|
|||||||
51
src/platform/assets/components/MediaAssetFilterBar.vue
Normal file
51
src/platform/assets/components/MediaAssetFilterBar.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<SearchBox
|
||||||
|
:model-value="searchQuery"
|
||||||
|
:placeholder="$t('sideToolbar.searchAssets')"
|
||||||
|
size="lg"
|
||||||
|
@update:model-value="handleSearchChange"
|
||||||
|
/>
|
||||||
|
<AssetSortButton
|
||||||
|
v-if="isCloud"
|
||||||
|
v-tooltip.top="{ value: $t('assetBrowser.sortBy') }"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<template #default="{ close }">
|
||||||
|
<MediaAssetSortMenu
|
||||||
|
:sort-by="sortBy"
|
||||||
|
:close="close"
|
||||||
|
@update:sort-by="handleSortChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</AssetSortButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import SearchBox from '@/components/input/SearchBox.vue'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
|
|
||||||
|
import AssetSortButton from './MediaAssetSortButton.vue'
|
||||||
|
import MediaAssetSortMenu from './MediaAssetSortMenu.vue'
|
||||||
|
|
||||||
|
interface MediaAssetSearchBarProps {
|
||||||
|
searchQuery: string
|
||||||
|
sortBy: 'newest' | 'oldest'
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<MediaAssetSearchBarProps>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:searchQuery': [value: string]
|
||||||
|
'update:sortBy': [value: 'newest' | 'oldest']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const handleSearchChange = (value: string | undefined) => {
|
||||||
|
emit('update:searchQuery', value ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSortChange = (value: 'newest' | 'oldest') => {
|
||||||
|
emit('update:sortBy', value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
65
src/platform/assets/components/MediaAssetSortButton.vue
Normal file
65
src/platform/assets/components/MediaAssetSortButton.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative inline-flex items-center">
|
||||||
|
<IconButton :size="size" :type="type" @click="toggle">
|
||||||
|
<i class="icon-[lucide--arrow-up-down] text-sm" />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
ref="popover"
|
||||||
|
:append-to="'body'"
|
||||||
|
:auto-z-index="true"
|
||||||
|
:base-z-index="1000"
|
||||||
|
:dismissable="true"
|
||||||
|
:close-on-escape="true"
|
||||||
|
unstyled
|
||||||
|
:pt="pt"
|
||||||
|
@show="$emit('menuOpened')"
|
||||||
|
@hide="$emit('menuClosed')"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-40 flex-col gap-2 p-2">
|
||||||
|
<slot :close="hide" />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Popover from 'primevue/popover'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import IconButton from '@/components/button/IconButton.vue'
|
||||||
|
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
interface AssetSortButtonProps extends BaseButtonProps {}
|
||||||
|
|
||||||
|
const popover = ref<InstanceType<typeof Popover>>()
|
||||||
|
|
||||||
|
const { size = 'md', type = 'secondary' } = defineProps<AssetSortButtonProps>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
menuOpened: []
|
||||||
|
menuClosed: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toggle = (event: Event) => {
|
||||||
|
popover.value?.toggle(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
popover.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
const pt = computed(() => ({
|
||||||
|
root: {
|
||||||
|
class: cn('absolute z-50')
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
class: cn(
|
||||||
|
'mt-1 rounded-lg',
|
||||||
|
'bg-base-background text-base-foreground border border-border-default',
|
||||||
|
'shadow-lg'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
43
src/platform/assets/components/MediaAssetSortMenu.vue
Normal file
43
src/platform/assets/components/MediaAssetSortMenu.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<IconTextButton
|
||||||
|
type="transparent"
|
||||||
|
icon-position="right"
|
||||||
|
:label="$t('sideToolbar.mediaAssets.sortNewestFirst')"
|
||||||
|
@click="handleSortChange('newest')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<i v-if="sortBy === 'newest'" class="icon-[lucide--check] size-4" />
|
||||||
|
</template>
|
||||||
|
</IconTextButton>
|
||||||
|
|
||||||
|
<IconTextButton
|
||||||
|
type="transparent"
|
||||||
|
icon-position="right"
|
||||||
|
:label="$t('sideToolbar.mediaAssets.sortOldestFirst')"
|
||||||
|
@click="handleSortChange('oldest')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<i v-if="sortBy === 'oldest'" class="icon-[lucide--check] size-4" />
|
||||||
|
</template>
|
||||||
|
</IconTextButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||||
|
|
||||||
|
const { sortBy, close } = defineProps<{
|
||||||
|
sortBy: 'newest' | 'oldest'
|
||||||
|
close: () => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:sortBy': [value: 'newest' | 'oldest']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const handleSortChange = (value: 'newest' | 'oldest') => {
|
||||||
|
emit('update:sortBy', value)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -32,7 +32,8 @@ export function mapTaskOutputToAssetItem(
|
|||||||
subfolder: output.subfolder,
|
subfolder: output.subfolder,
|
||||||
executionTimeInSeconds: taskItem.executionTimeInSeconds,
|
executionTimeInSeconds: taskItem.executionTimeInSeconds,
|
||||||
format: output.format,
|
format: output.format,
|
||||||
workflow: taskItem.workflow
|
workflow: taskItem.workflow,
|
||||||
|
create_time: taskItem.createTime
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import { refDebounced } from '@vueuse/core'
|
import { refDebounced } from '@vueuse/core'
|
||||||
|
import { sortBy as sortByUtil } from 'es-toolkit'
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
|
type SortOption = 'newest' | 'oldest'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get timestamp from asset (either create_time or created_at)
|
||||||
|
*/
|
||||||
|
const getAssetTime = (asset: AssetItem): number => {
|
||||||
|
return (
|
||||||
|
(asset.user_metadata?.create_time as number) ??
|
||||||
|
(asset.created_at ? new Date(asset.created_at).getTime() : 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Media Asset Filtering composable
|
* Media Asset Filtering composable
|
||||||
* Manages search, filter, and sort for media assets
|
* Manages search, filter, and sort for media assets
|
||||||
@@ -12,6 +25,7 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
|||||||
export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
|
export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const debouncedSearchQuery = refDebounced(searchQuery, 50)
|
const debouncedSearchQuery = refDebounced(searchQuery, 50)
|
||||||
|
const sortBy = ref<SortOption>('newest')
|
||||||
|
|
||||||
const fuseOptions = {
|
const fuseOptions = {
|
||||||
keys: ['name'],
|
keys: ['name'],
|
||||||
@@ -21,7 +35,7 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
|
|||||||
|
|
||||||
const fuse = computed(() => new Fuse(assets.value, fuseOptions))
|
const fuse = computed(() => new Fuse(assets.value, fuseOptions))
|
||||||
|
|
||||||
const filteredAssets = computed(() => {
|
const searchFiltered = computed(() => {
|
||||||
if (!debouncedSearchQuery.value.trim()) {
|
if (!debouncedSearchQuery.value.trim()) {
|
||||||
return assets.value
|
return assets.value
|
||||||
}
|
}
|
||||||
@@ -30,8 +44,20 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
|
|||||||
return results.map((result) => result.item)
|
return results.map((result) => result.item)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const filteredAssets = computed(() => {
|
||||||
|
// Sort by create_time (output assets) or created_at (input assets)
|
||||||
|
if (sortBy.value === 'oldest') {
|
||||||
|
// Ascending order (oldest first)
|
||||||
|
return sortByUtil(searchFiltered.value, [getAssetTime])
|
||||||
|
} else {
|
||||||
|
// Descending order (newest first) - negate for descending
|
||||||
|
return sortByUtil(searchFiltered.value, [(asset) => -getAssetTime(asset)])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
sortBy,
|
||||||
filteredAssets
|
filteredAssets
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,18 @@ import type {
|
|||||||
function mapPromptV2toV1(
|
function mapPromptV2toV1(
|
||||||
promptV2: TaskPromptV2,
|
promptV2: TaskPromptV2,
|
||||||
outputs: TaskOutput,
|
outputs: TaskOutput,
|
||||||
syntheticPriority: number
|
syntheticPriority: number,
|
||||||
|
createTime?: number
|
||||||
): TaskPrompt {
|
): TaskPrompt {
|
||||||
|
const extraData = {
|
||||||
|
...(promptV2.extra_data ?? {}),
|
||||||
|
...(typeof createTime === 'number' ? { create_time: createTime } : {})
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
syntheticPriority,
|
syntheticPriority,
|
||||||
promptV2.prompt_id,
|
promptV2.prompt_id,
|
||||||
{},
|
{},
|
||||||
promptV2.extra_data,
|
extraData,
|
||||||
Object.keys(outputs)
|
Object.keys(outputs)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -55,7 +60,12 @@ export function mapHistoryV2toHistory(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
taskType: 'History' as const,
|
taskType: 'History' as const,
|
||||||
prompt: mapPromptV2toV1(prompt, outputs, syntheticPriority),
|
prompt: mapPromptV2toV1(
|
||||||
|
prompt,
|
||||||
|
outputs,
|
||||||
|
syntheticPriority,
|
||||||
|
item.create_time
|
||||||
|
),
|
||||||
status,
|
status,
|
||||||
outputs,
|
outputs,
|
||||||
meta
|
meta
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ const zRawHistoryItemV2 = z.object({
|
|||||||
prompt: zTaskPromptV2,
|
prompt: zTaskPromptV2,
|
||||||
status: zStatus.optional(),
|
status: zStatus.optional(),
|
||||||
outputs: zTaskOutput,
|
outputs: zTaskOutput,
|
||||||
meta: zTaskMeta.optional()
|
meta: zTaskMeta.optional(),
|
||||||
|
create_time: z.number().int().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
const zHistoryResponseV2 = z.object({
|
const zHistoryResponseV2 = z.object({
|
||||||
|
|||||||
@@ -171,11 +171,16 @@ const zExtraPngInfo = z
|
|||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
||||||
export const zExtraData = z.object({
|
export const zExtraData = z
|
||||||
/** extra_pnginfo can be missing is backend execution gets a validation error. */
|
.object({
|
||||||
extra_pnginfo: zExtraPngInfo.optional(),
|
/** extra_pnginfo can be missing is backend execution gets a validation error. */
|
||||||
client_id: z.string().optional()
|
extra_pnginfo: zExtraPngInfo.optional(),
|
||||||
})
|
client_id: z.string().optional(),
|
||||||
|
// Cloud/Adapters: creation time in milliseconds when available
|
||||||
|
create_time: z.number().int().optional()
|
||||||
|
})
|
||||||
|
// Allow backend/adapters/extensions to add arbitrary metadata
|
||||||
|
.passthrough()
|
||||||
const zOutputsToExecute = z.array(zNodeId)
|
const zOutputsToExecute = z.array(zNodeId)
|
||||||
|
|
||||||
const zExecutionStartMessage = z.tuple([
|
const zExecutionStartMessage = z.tuple([
|
||||||
|
|||||||
@@ -313,6 +313,22 @@ export class TaskItemImpl {
|
|||||||
return this.status?.messages || []
|
return this.status?.messages || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-provided creation time in milliseconds, when available.
|
||||||
|
*
|
||||||
|
* Sources:
|
||||||
|
* - Queue: 5th tuple element may be a metadata object with { create_time }.
|
||||||
|
* - History (Cloud V2): Adapter injects create_time into prompt[3].extra_data.
|
||||||
|
*/
|
||||||
|
get createTime(): number | undefined {
|
||||||
|
const extra = (this.extraData as any) || {}
|
||||||
|
const fromExtra =
|
||||||
|
typeof extra.create_time === 'number' ? extra.create_time : undefined
|
||||||
|
if (typeof fromExtra === 'number') return fromExtra
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
get interrupted() {
|
get interrupted() {
|
||||||
return _.some(
|
return _.some(
|
||||||
this.messages,
|
this.messages,
|
||||||
|
|||||||
Reference in New Issue
Block a user