From bf0a10c780d8f2294870f8e40f877bbeee5ae731 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:54:05 +0530 Subject: [PATCH 01/21] feat: introduce voice call dashboard (#14954) --- app/javascript/dashboard/api/calls.js | 14 + .../components-next/Calls/CallListItem.vue | 237 +++++++++++++++++ .../Calls/CallRecordingPlayer.vue | 158 +++++++++++ .../components-next/Calls/CallStatusBadge.vue | 56 ++++ .../components-next/Calls/CallsEmptyState.vue | 34 +++ .../components-next/Calls/CallsFilterBar.vue | 250 ++++++++++++++++++ .../components-next/Calls/constants.js | 51 ++++ .../components-next/audio/AudioPlayer.vue | 158 +++++++++++ .../components-next/sidebar/Sidebar.vue | 19 ++ .../dashboard/i18n/locale/en/calls.json | 45 ++++ .../dashboard/i18n/locale/en/index.js | 2 + .../dashboard/i18n/locale/en/settings.json | 1 + .../dashboard/calls/pages/CallsIndex.vue | 168 ++++++++++++ .../routes/dashboard/calls/routes.js | 22 ++ .../routes/dashboard/dashboard.routes.js | 2 + .../dashboard/stores/callHistory.js | 40 +++ .../stores/specs/callHistory.spec.js | 117 ++++++++ .../stores/{ => specs}/companies.spec.js | 2 +- .../shared/helpers/specs/timeHelper.spec.js | 36 ++- app/javascript/shared/helpers/timeHelper.js | 19 ++ 20 files changed, 1426 insertions(+), 5 deletions(-) create mode 100644 app/javascript/dashboard/api/calls.js create mode 100644 app/javascript/dashboard/components-next/Calls/CallListItem.vue create mode 100644 app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue create mode 100644 app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue create mode 100644 app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue create mode 100644 app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue create mode 100644 app/javascript/dashboard/components-next/Calls/constants.js create mode 100644 app/javascript/dashboard/components-next/audio/AudioPlayer.vue create mode 100644 app/javascript/dashboard/i18n/locale/en/calls.json create mode 100644 app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue create mode 100644 app/javascript/dashboard/routes/dashboard/calls/routes.js create mode 100644 app/javascript/dashboard/stores/callHistory.js create mode 100644 app/javascript/dashboard/stores/specs/callHistory.spec.js rename app/javascript/dashboard/stores/{ => specs}/companies.spec.js (99%) diff --git a/app/javascript/dashboard/api/calls.js b/app/javascript/dashboard/api/calls.js new file mode 100644 index 000000000..aa220bdee --- /dev/null +++ b/app/javascript/dashboard/api/calls.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class CallsAPI extends ApiClient { + constructor() { + super('calls', { accountScoped: true }); + } + + get(params = {}) { + return axios.get(this.url, { params }); + } +} + +export default new CallsAPI(); diff --git a/app/javascript/dashboard/components-next/Calls/CallListItem.vue b/app/javascript/dashboard/components-next/Calls/CallListItem.vue new file mode 100644 index 000000000..8b0a56bfd --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallListItem.vue @@ -0,0 +1,237 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue b/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue new file mode 100644 index 000000000..6eaa81b7b --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue @@ -0,0 +1,158 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue b/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue new file mode 100644 index 000000000..b0da9c41e --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue @@ -0,0 +1,56 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue b/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue new file mode 100644 index 000000000..f57d154eb --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue @@ -0,0 +1,34 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue new file mode 100644 index 000000000..4d01b0500 --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue @@ -0,0 +1,250 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/constants.js b/app/javascript/dashboard/components-next/Calls/constants.js new file mode 100644 index 000000000..f7399bfef --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/constants.js @@ -0,0 +1,51 @@ +import { + VOICE_CALL_STATUS, + VOICE_CALL_DIRECTION, +} from 'dashboard/components-next/message/constants'; + +export const CALL_KIND = { + ONGOING: 'ongoing', + INCOMING: 'incoming', + OUTGOING: 'outgoing', + MISSED: 'missed', + NO_REPLY: 'no_reply', + FAILED: 'failed', +}; + +// The API returns display values: status (ringing/in-progress/completed/ +// no-answer/failed) and direction (inbound/outbound). The list UI presents +// them as a single "kind" per row. +export const getCallKind = call => { + if ( + [VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes( + call.status + ) + ) { + return CALL_KIND.ONGOING; + } + if ( + [VOICE_CALL_STATUS.FAILED, VOICE_CALL_STATUS.REJECTED].includes(call.status) + ) { + return CALL_KIND.FAILED; + } + const isInbound = call.direction === VOICE_CALL_DIRECTION.INBOUND; + if (call.status === VOICE_CALL_STATUS.NO_ANSWER) { + return isInbound ? CALL_KIND.MISSED : CALL_KIND.NO_REPLY; + } + return isInbound ? CALL_KIND.INCOMING : CALL_KIND.OUTGOING; +}; + +// Filter chips map to the status/direction params supported by CallFinder. +export const CALL_ACTIVITY_PARAMS = { + missed: { + status: VOICE_CALL_STATUS.NO_ANSWER, + direction: VOICE_CALL_DIRECTION.INBOUND, + }, + no_reply: { + status: VOICE_CALL_STATUS.NO_ANSWER, + direction: VOICE_CALL_DIRECTION.OUTBOUND, + }, + incoming: { direction: VOICE_CALL_DIRECTION.INBOUND }, + outgoing: { direction: VOICE_CALL_DIRECTION.OUTBOUND }, + in_progress: { status: VOICE_CALL_STATUS.IN_PROGRESS }, +}; diff --git a/app/javascript/dashboard/components-next/audio/AudioPlayer.vue b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue new file mode 100644 index 000000000..c716ffbfb --- /dev/null +++ b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue @@ -0,0 +1,158 @@ + + + diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 294e0dd5d..909a44c69 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -2,6 +2,7 @@ import { h, ref, computed, onMounted, watch } from 'vue'; import { provideSidebarContext, useSidebarResize } from './provider'; import { useAccount } from 'dashboard/composables/useAccount'; +import { useConfig } from 'dashboard/composables/useConfig'; import { useKbd } from 'dashboard/composables/utils/useKbd'; import { useMapGetter } from 'dashboard/composables/store'; import { useStore } from 'vuex'; @@ -43,7 +44,14 @@ const emit = defineEmits([ ]); const { accountScopedRoute, isOnChatwootCloud } = useAccount(); +const { isEnterprise } = useConfig(); const store = useStore(); + +// Calls run on the enterprise-only API (cloud runs enterprise); hide the entry +// on community so it doesn't lead to a dashboard/CTA the backend can't serve. +const isCallsAvailable = computed( + () => isOnChatwootCloud.value || isEnterprise +); const searchShortcut = useKbd([`$mod`, 'k']); const { t } = useI18n(); @@ -563,6 +571,17 @@ const menuItems = computed(() => { }, ], }, + ...(isCallsAvailable.value + ? [ + { + name: 'Calls', + label: t('SIDEBAR.CALLS'), + icon: 'i-lucide-phone', + to: accountScopedRoute('calls_dashboard_index'), + activeOn: ['calls_dashboard_index'], + }, + ] + : []), { name: 'Contacts', label: t('SIDEBAR.CONTACTS'), diff --git a/app/javascript/dashboard/i18n/locale/en/calls.json b/app/javascript/dashboard/i18n/locale/en/calls.json new file mode 100644 index 000000000..0ca5441f0 --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/calls.json @@ -0,0 +1,45 @@ +{ + "CALLS_PAGE": { + "HEADER": "Calls", + "ALL_CALLS": "All Calls", + "ALL_CALLS_COUNT": "All Calls ({count})", + "EMPTY_STATE": "No calls found", + "SETUP": { + "TITLE": "Make and receive calls in one place", + "SUBTITLE": "Set up a voice channel to start handling calls with your team. Every call, along with its recording, will appear here.", + "ACTION": "Set up voice channel" + }, + "FILTERS": { + "MISSED": "Missed", + "NO_REPLY": "No reply", + "OTHER_ACTIVITY": "Other activity", + "INCOMING": "Incoming", + "OUTGOING": "Outgoing", + "IN_PROGRESS": "In progress", + "ASSIGNEE": "Assignee", + "ALL_ASSIGNEES": "All assignees", + "MORE_FILTERS": "More filters", + "INBOX": "Inbox", + "ALL_INBOXES": "All inboxes" + }, + "STATUS": { + "ONGOING": "Ongoing", + "INCOMING": "Incoming", + "OUTGOING": "Outgoing", + "MISSED": "Missed", + "NO_REPLY": "No reply", + "FAILED": "Failed" + }, + "ROW": { + "PICKED_BY": "Picked by", + "DIALED_BY": "Dialed by", + "ANSWERED": "Answered", + "RINGING": "Ringing", + "IN_PROGRESS": "In progress", + "NO_AGENT": "No agent answered this call", + "NO_CONTACT_ANSWER": "Contact did not answer", + "FAILED": "This call could not be connected", + "YESTERDAY": "Yesterday" + } + } +} diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js index 12db16ba7..990ab9835 100644 --- a/app/javascript/dashboard/i18n/locale/en/index.js +++ b/app/javascript/dashboard/i18n/locale/en/index.js @@ -5,6 +5,7 @@ import attributesMgmt from './attributesMgmt.json'; import auditLogs from './auditLogs.json'; import automation from './automation.json'; import bulkActions from './bulkActions.json'; +import calls from './calls.json'; import campaign from './campaign.json'; import cannedMgmt from './cannedMgmt.json'; import chatlist from './chatlist.json'; @@ -51,6 +52,7 @@ export default { ...auditLogs, ...automation, ...bulkActions, + ...calls, ...campaign, ...cannedMgmt, ...chatlist, diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index ceb0438b1..c013a177f 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -324,6 +324,7 @@ "COMPANIES": "Companies", "ALL_COMPANIES": "All Companies", "CAPTAIN": "Captain", + "CALLS": "Calls", "CAPTAIN_ASSISTANTS": "Assistants", "CAPTAIN_OVERVIEW": "Overview", "CAPTAIN_DOCUMENTS": "Documents", diff --git a/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue new file mode 100644 index 000000000..80f8e08c5 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue @@ -0,0 +1,168 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/calls/routes.js b/app/javascript/dashboard/routes/dashboard/calls/routes.js new file mode 100644 index 000000000..fa952a749 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/calls/routes.js @@ -0,0 +1,22 @@ +import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes'; +import { + CONVERSATION_PERMISSIONS, + ROLES, +} from 'dashboard/constants/permissions'; +import { frontendURL } from '../../../helper/URLHelper'; +import CallsIndex from './pages/CallsIndex.vue'; + +export const routes = [ + { + path: frontendURL('accounts/:accountId/calls'), + name: 'calls_dashboard_index', + component: CallsIndex, + meta: { + permissions: [...ROLES, ...CONVERSATION_PERMISSIONS], + installationTypes: [ + INSTALLATION_TYPES.CLOUD, + INSTALLATION_TYPES.ENTERPRISE, + ], + }, + }, +]; diff --git a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js index 04d11c621..4611aad38 100644 --- a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js +++ b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js @@ -1,6 +1,7 @@ import settings from './settings/settings.routes'; import conversation from './conversation/conversation.routes'; import { routes as searchRoutes } from '../../modules/search/search.routes'; +import { routes as callRoutes } from './calls/routes'; import { routes as contactRoutes } from './contacts/routes'; import { routes as companyRoutes } from './companies/routes'; import { routes as notificationRoutes } from './notifications/routes'; @@ -25,6 +26,7 @@ export default { ...inboxRoutes, ...conversation.routes, ...settings.routes, + ...callRoutes, ...contactRoutes, ...companyRoutes, ...searchRoutes, diff --git a/app/javascript/dashboard/stores/callHistory.js b/app/javascript/dashboard/stores/callHistory.js new file mode 100644 index 000000000..c0a2c0fa9 --- /dev/null +++ b/app/javascript/dashboard/stores/callHistory.js @@ -0,0 +1,40 @@ +import camelcaseKeys from 'camelcase-keys'; +import CallsAPI from 'dashboard/api/calls'; +import { throwErrorMessage } from 'dashboard/store/utils/api'; +import { defineStore } from 'pinia'; + +export const useCallHistoryStore = defineStore('callHistory', { + state: () => ({ + records: [], + meta: { count: 0, currentPage: 1, totalPages: 0 }, + uiFlags: { isFetching: false }, + fetchRequestToken: 0, + }), + + actions: { + async fetchCalls(params = {}) { + this.uiFlags.isFetching = true; + this.fetchRequestToken += 1; + const requestToken = this.fetchRequestToken; + try { + const { data } = await CallsAPI.get(params); + // A newer fetch (filter/page change) superseded this one; drop the result. + if (this.fetchRequestToken !== requestToken) return this.records; + this.records = camelcaseKeys(data.payload, { deep: true }); + this.meta = camelcaseKeys(data.meta); + return this.records; + } catch (error) { + // Don't surface errors from a fetch that a newer request already replaced. + if (this.fetchRequestToken !== requestToken) return this.records; + // Drop the previous results so stale rows aren't shown under the new view. + this.records = []; + this.meta = { count: 0, currentPage: 1, totalPages: 0 }; + return throwErrorMessage(error); + } finally { + if (this.fetchRequestToken === requestToken) { + this.uiFlags.isFetching = false; + } + } + }, + }, +}); diff --git a/app/javascript/dashboard/stores/specs/callHistory.spec.js b/app/javascript/dashboard/stores/specs/callHistory.spec.js new file mode 100644 index 000000000..834ae8ab5 --- /dev/null +++ b/app/javascript/dashboard/stores/specs/callHistory.spec.js @@ -0,0 +1,117 @@ +import { setActivePinia, createPinia } from 'pinia'; +import CallsAPI from 'dashboard/api/calls'; +import { throwErrorMessage } from 'dashboard/store/utils/api'; +import { useCallHistoryStore } from '../callHistory'; + +vi.mock('dashboard/api/calls', () => ({ + default: { + get: vi.fn(), + }, +})); + +vi.mock('dashboard/store/utils/api', () => ({ + throwErrorMessage: vi.fn(error => error), +})); + +const createDeferred = () => { + let resolve; + const promise = new Promise(res => { + resolve = res; + }); + + return { promise, resolve }; +}; + +const buildResponse = (payload, meta) => ({ data: { payload, meta } }); + +describe('callHistory store', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it('fetches calls and stores camelized records and meta', async () => { + CallsAPI.get.mockResolvedValue( + buildResponse( + [{ id: 1, recording_url: 'rec.mp3', contact: { phone_number: '+1' } }], + { count: 44, current_page: 1, total_pages: 2 } + ) + ); + const store = useCallHistoryStore(); + + await store.fetchCalls({ page: 1, status: 'no-answer' }); + + expect(CallsAPI.get).toHaveBeenCalledWith({ page: 1, status: 'no-answer' }); + expect(store.records).toEqual([ + { id: 1, recordingUrl: 'rec.mp3', contact: { phoneNumber: '+1' } }, + ]); + expect(store.meta).toEqual({ count: 44, currentPage: 1, totalPages: 2 }); + expect(store.uiFlags.isFetching).toBe(false); + }); + + it('drops a superseded response that resolves after the latest one', async () => { + const firstRequest = createDeferred(); + const secondRequest = createDeferred(); + CallsAPI.get + .mockImplementationOnce(() => firstRequest.promise) + .mockImplementationOnce(() => secondRequest.promise); + const store = useCallHistoryStore(); + + const staleFetch = store.fetchCalls({ page: 1 }); + const currentFetch = store.fetchCalls({ page: 2 }); + + secondRequest.resolve( + buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 }) + ); + await currentFetch; + + firstRequest.resolve( + buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 }) + ); + await staleFetch; + + expect(store.records).toEqual([{ id: 2 }]); + expect(store.meta.count).toBe(1); + expect(store.uiFlags.isFetching).toBe(false); + }); + + it('keeps fetching state when a superseded response resolves first', async () => { + const firstRequest = createDeferred(); + const secondRequest = createDeferred(); + CallsAPI.get + .mockImplementationOnce(() => firstRequest.promise) + .mockImplementationOnce(() => secondRequest.promise); + const store = useCallHistoryStore(); + + const staleFetch = store.fetchCalls({ page: 1 }); + const currentFetch = store.fetchCalls({ page: 2 }); + + firstRequest.resolve( + buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 }) + ); + await staleFetch; + + expect(store.records).toEqual([]); + expect(store.uiFlags.isFetching).toBe(true); + + secondRequest.resolve( + buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 }) + ); + await currentFetch; + + expect(store.records).toEqual([{ id: 2 }]); + expect(store.uiFlags.isFetching).toBe(false); + }); + + it('surfaces the error and resets fetching state on failure', async () => { + const error = new Error('Request failed'); + CallsAPI.get.mockRejectedValue(error); + const store = useCallHistoryStore(); + + await store.fetchCalls(); + + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(store.records).toEqual([]); + expect(store.uiFlags.isFetching).toBe(false); + }); +}); diff --git a/app/javascript/dashboard/stores/companies.spec.js b/app/javascript/dashboard/stores/specs/companies.spec.js similarity index 99% rename from app/javascript/dashboard/stores/companies.spec.js rename to app/javascript/dashboard/stores/specs/companies.spec.js index 1c44d4292..98d9fd9b6 100644 --- a/app/javascript/dashboard/stores/companies.spec.js +++ b/app/javascript/dashboard/stores/specs/companies.spec.js @@ -1,6 +1,6 @@ import { setActivePinia, createPinia } from 'pinia'; import CompanyAPI from 'dashboard/api/companies'; -import { useCompaniesStore } from './companies'; +import { useCompaniesStore } from '../companies'; vi.mock('dashboard/api/companies', () => ({ default: { diff --git a/app/javascript/shared/helpers/specs/timeHelper.spec.js b/app/javascript/shared/helpers/specs/timeHelper.spec.js index d12a42bc8..8a4b50b17 100644 --- a/app/javascript/shared/helpers/specs/timeHelper.spec.js +++ b/app/javascript/shared/helpers/specs/timeHelper.spec.js @@ -1,11 +1,12 @@ import { - messageStamp, - messageTimestamp, - dynamicTime, dateFormat, - shortTimestamp, + dynamicTime, getDayDifferenceFromNow, hasOneDayPassed, + messageStamp, + messageTimestamp, + relativeDayTimestamp, + shortTimestamp, } from 'shared/helpers/timeHelper'; beforeEach(() => { @@ -37,6 +38,33 @@ describe('#messageTimestamp', () => { }); }); +describe('#relativeDayTimestamp', () => { + // System time is mocked to May 5, 2023 00:00 UTC. + const toUnix = date => Math.floor(date / 1000); + + it('returns the time for timestamps from today', () => { + const today = toUnix(Date.UTC(2023, 4, 5, 15, 35, 0)); + expect(relativeDayTimestamp(today, 'Yesterday')).toEqual('3:35 PM'); + }); + + it('returns the supplied label for timestamps from yesterday', () => { + const yesterday = toUnix(Date.UTC(2023, 4, 4, 9, 0, 0)); + expect(relativeDayTimestamp(yesterday, 'Yesterday')).toEqual('Yesterday'); + }); + + it('returns a day and month for older timestamps in the current year', () => { + const earlierThisYear = toUnix(Date.UTC(2023, 1, 10, 12, 0, 0)); + expect(relativeDayTimestamp(earlierThisYear, 'Yesterday')).toEqual( + 'Feb 10' + ); + }); + + it('returns a full date for timestamps from a previous year', () => { + const lastYear = toUnix(Date.UTC(2021, 1, 10, 12, 0, 0)); + expect(relativeDayTimestamp(lastYear, 'Yesterday')).toEqual('Feb 10, 2021'); + }); +}); + describe('#dynamicTime', () => { it('returns correct value', () => { Date.now = vi.fn(() => new Date(Date.UTC(2023, 1, 14)).valueOf()); diff --git a/app/javascript/shared/helpers/timeHelper.js b/app/javascript/shared/helpers/timeHelper.js index db5609d89..cde5ccf89 100644 --- a/app/javascript/shared/helpers/timeHelper.js +++ b/app/javascript/shared/helpers/timeHelper.js @@ -1,6 +1,9 @@ import { format, isSameYear, + isThisYear, + isToday, + isYesterday, fromUnixTime, formatDistanceToNow, differenceInDays, @@ -33,6 +36,22 @@ export const messageTimestamp = (time, dateFormat = 'MMM d, yyyy') => { return messageDate; }; +/** + * Formats a Unix timestamp relative to today: the time for today, a caller- + * supplied label for yesterday, and a date otherwise. The yesterday label is + * passed in so the caller keeps ownership of translation. + * @param {number} time - Unix timestamp. + * @param {string} yesterdayLabel - Localized label shown for yesterday. + * @returns {string} Formatted timestamp string. + */ +export const relativeDayTimestamp = (time, yesterdayLabel) => { + const date = fromUnixTime(time); + if (isToday(date)) return format(date, 'h:mm a'); + if (isYesterday(date)) return yesterdayLabel; + if (isThisYear(date)) return format(date, 'MMM d'); + return format(date, 'MMM d, yyyy'); +}; + /** * Converts a Unix timestamp to a relative time string (e.g., 3 hours ago). * @param {number} time - Unix timestamp. From 08f49f5896f8d2959c10c2434553705e5668570d Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:28:48 +0530 Subject: [PATCH 02/21] fix: prevent channel list crash on hard reload (#15074) --- .../routes/dashboard/settings/inbox/ChannelList.vue | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue index de0c6059b..fd509d350 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -1,5 +1,5 @@