165 lines
17 KiB
JavaScript
165 lines
17 KiB
JavaScript
document.querySelectorAll('[data-conversation-workspace]').forEach(workspace=>{
|
|
const scroller=workspace.querySelector('[data-conversation]');
|
|
const conversationEnd=workspace.querySelector('[data-conversation-end]');
|
|
const jump=workspace.querySelector('[data-jump-latest]');
|
|
const form=workspace.querySelector('[data-ticket-composer]');
|
|
const input=form?.querySelector('textarea');
|
|
const composer=workspace.querySelector('[data-composer-shell]');
|
|
const composerToggle=workspace.querySelector('[data-composer-toggle]');
|
|
const baseKey=workspace.dataset.draftKey||location.pathname;
|
|
let mode='reply';
|
|
|
|
const atBottom=()=>scroller.scrollHeight-scroller.scrollTop-scroller.clientHeight<80;
|
|
const scrollLatest=(smooth=false)=>{const top=scroller.scrollHeight;if(smooth&&typeof scroller.scrollTo==='function')scroller.scrollTo({top,behavior:'smooth'});else scroller.scrollTop=top};
|
|
let followLatest=true;
|
|
let initialPin=true;
|
|
const scheduleLatest=()=>{[0,40,120,300,700,1200].forEach(delay=>setTimeout(()=>{if(initialPin)scrollLatest(false)},delay))};
|
|
scheduleLatest();
|
|
window.addEventListener('load',scheduleLatest,{once:true});
|
|
window.addEventListener('pageshow',scheduleLatest);
|
|
document.fonts?.ready?.then(scheduleLatest);
|
|
scroller.querySelectorAll('img,audio').forEach(media=>{const event=media.tagName==='IMG'?'load':'loadedmetadata';media.addEventListener(event,()=>{if(initialPin)scrollLatest(false)},{once:true})});
|
|
const sizeObserver='ResizeObserver' in window?new ResizeObserver(()=>{if(initialPin)scrollLatest(false)}):null;
|
|
sizeObserver?.observe(scroller);if(conversationEnd?.parentElement)sizeObserver?.observe(conversationEnd.parentElement);
|
|
setTimeout(()=>{initialPin=false;sizeObserver?.disconnect()},1800);
|
|
['wheel','touchstart','pointerdown','keydown'].forEach(eventName=>scroller.addEventListener(eventName,()=>{initialPin=false;sizeObserver?.disconnect()},{once:true,passive:true}));
|
|
scroller.addEventListener('scroll',()=>{followLatest=atBottom();jump.hidden=followLatest});
|
|
jump?.addEventListener('click',()=>{scrollLatest(true);jump.hidden=true});
|
|
const layoutObserver='ResizeObserver' in window?new ResizeObserver(()=>{if(followLatest)requestAnimationFrame(()=>scrollLatest(false))}):null;
|
|
layoutObserver?.observe(scroller);
|
|
if(composer)layoutObserver?.observe(composer);
|
|
let stableViewportHeight=Math.max(window.visualViewport?.height||window.innerHeight,window.innerHeight);
|
|
let inputVisibilityFrame=0;
|
|
let keyboardWasOpen=false;
|
|
let suppressKeyboardCollapse=false;
|
|
const ensureInputVisible=()=>{
|
|
inputVisibilityFrame=0;
|
|
if(!composer||!input||document.activeElement!==input||composer.classList.contains('is-collapsed'))return;
|
|
const composerBox=composer.getBoundingClientRect(),inputBox=input.getBoundingClientRect(),padding=10;
|
|
if(inputBox.top<composerBox.top+padding)composer.scrollTop+=inputBox.top-composerBox.top-padding;
|
|
else if(inputBox.bottom>composerBox.bottom-padding)composer.scrollTop+=inputBox.bottom-composerBox.bottom+padding;
|
|
};
|
|
const scheduleInputVisibility=()=>{if(!inputVisibilityFrame)inputVisibilityFrame=requestAnimationFrame(()=>requestAnimationFrame(ensureInputVisible))};
|
|
const updateKeyboardState=height=>{
|
|
const inputFocused=document.activeElement===input;
|
|
if(!inputFocused)stableViewportHeight=Math.max(stableViewportHeight,height);
|
|
const keyboardOpen=inputFocused&&stableViewportHeight-height>120;
|
|
const keyboardClosed=keyboardWasOpen&&!keyboardOpen&&stableViewportHeight-height<80;
|
|
keyboardWasOpen=keyboardOpen||(keyboardWasOpen&&!keyboardClosed);
|
|
composer?.classList.toggle('is-keyboard-active',keyboardOpen);
|
|
composer?.setAttribute('data-keyboard-open',String(keyboardOpen));
|
|
if(keyboardOpen)scheduleInputVisibility();
|
|
if(keyboardClosed&&!suppressKeyboardCollapse&&!composer?.classList.contains('is-collapsed'))setTimeout(()=>setComposerOpen(false,false),0);
|
|
};
|
|
window.addEventListener('appviewportchange',event=>{if(followLatest)requestAnimationFrame(()=>scrollLatest(false));updateKeyboardState(event.detail?.height||window.visualViewport?.height||window.innerHeight)});
|
|
input?.addEventListener('focus',()=>{updateKeyboardState(window.visualViewport?.height||window.innerHeight);scheduleInputVisibility()});
|
|
input?.addEventListener('blur',()=>setTimeout(()=>{composer?.classList.remove('is-keyboard-active');composer?.setAttribute('data-keyboard-open','false')},0));
|
|
composer?.addEventListener('pointerdown',event=>{if(!composer.classList.contains('is-collapsed')&&!event.target.closest('[data-composer-close]')){suppressKeyboardCollapse=true;setTimeout(()=>{suppressKeyboardCollapse=false},1200)}},{capture:true});
|
|
|
|
const groupDates=()=>{scroller.querySelectorAll('.date-divider').forEach(item=>item.remove());let previousDate='';scroller.querySelectorAll('[data-message]').forEach(message=>{if(message.dataset.date!==previousDate){const divider=document.createElement('div');divider.className='date-divider';divider.innerHTML=`<span>${message.dataset.dayLabel}</span>`;message.before(divider);previousDate=message.dataset.date}})};
|
|
groupDates();
|
|
document.body.addEventListener('htmx:beforeSwap',event=>{if(event.detail.target?.matches('[data-load-older]')){scroller.dataset.previousHeight=scroller.scrollHeight}});
|
|
document.body.addEventListener('htmx:afterSwap',event=>{if(event.detail.target?.matches('[data-load-older]')){const previous=Number(scroller.dataset.previousHeight||0);groupDates();scroller.scrollTop+=scroller.scrollHeight-previous}});
|
|
|
|
const resize=()=>{if(!input)return;const maxHeight=window.matchMedia('(max-width:639px)').matches?144:192;input.style.height='0px';const contentHeight=input.scrollHeight;input.style.height=`${Math.min(Math.max(contentHeight,48),maxHeight)}px`;input.style.overflowY=contentHeight>maxHeight?'auto':'hidden';scheduleInputVisibility()};
|
|
const draftKey=()=>`${baseKey}-${mode}`;
|
|
const status=workspace.querySelector('[data-draft-status]');
|
|
const replyResult=workspace.dataset.replyResult||new URLSearchParams(location.search).get('reply_result')||'';
|
|
const submissionSucceeded=replyResult==='reply'||replyResult==='note';
|
|
if(submissionSucceeded){sessionStorage.removeItem(`${baseKey}-reply`);sessionStorage.removeItem(`${baseKey}-note`)}
|
|
const setComposerOpen=(open,focus=true)=>{const keepLatestVisible=followLatest||atBottom();composer?.classList.toggle('is-collapsed',!open);composerToggle?.setAttribute('aria-expanded',String(open));[0,80,240].forEach(delay=>setTimeout(()=>{if(keepLatestVisible)scrollLatest(false)},delay));if(open&&focus)setTimeout(()=>{input?.focus({preventScroll:true});scheduleInputVisibility()},80)};
|
|
const saveDraft=()=>{if(!input)return;sessionStorage.setItem(draftKey(),input.value);if(status){status.textContent=input.value?'پیشنویس ذخیره شد':'';clearTimeout(saveDraft.timer);saveDraft.timer=setTimeout(()=>status.textContent='',1800)}};
|
|
const loadDraft=()=>{if(!input)return;input.value=sessionStorage.getItem(draftKey())||'';resize();input.dispatchEvent(new Event('input',{bubbles:true}))};
|
|
if(input){input.setAttribute('rows','1');input.setAttribute('placeholder','پاسخ خود را بنویسید…');input.addEventListener('input',()=>{resize();saveDraft()});loadDraft()}
|
|
const hasDraft=Boolean(sessionStorage.getItem(`${baseKey}-reply`)||sessionStorage.getItem(`${baseKey}-note`));
|
|
if(submissionSucceeded){form?.reset();if(input){input.value='';resize()}setComposerOpen(false,false)}else setComposerOpen(hasDraft||replyResult==='error',false);
|
|
composerToggle?.addEventListener('click',()=>setComposerOpen(true));
|
|
workspace.querySelectorAll('[data-composer-close]').forEach(button=>button.addEventListener('click',()=>setComposerOpen(false,false)));
|
|
workspace.querySelector('[data-dismiss-dispatch-context]')?.addEventListener('click',event=>event.currentTarget.closest('.ticket-dispatch-context')?.remove());
|
|
composer?.addEventListener('keydown',event=>{if(event.key==='Escape'){setComposerOpen(false,false);composerToggle?.focus()}});
|
|
const primarySubmit=form?.querySelector('[data-primary-submit]');
|
|
const guideToPrimarySubmit=event=>{event.preventDefault();event.stopPropagation();if(!primarySubmit)return;primarySubmit.scrollIntoView({behavior:'smooth',block:'center'});primarySubmit.classList.remove('submit-attention');void primarySubmit.offsetWidth;primarySubmit.classList.add('submit-attention');setTimeout(()=>primarySubmit.focus({preventScroll:true}),450)};
|
|
workspace.querySelectorAll('[data-scroll-to-submit]').forEach(control=>{control.addEventListener('click',guideToPrimarySubmit);control.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){guideToPrimarySubmit(event)}})});
|
|
|
|
workspace.querySelectorAll('[data-composer-mode]').forEach(button=>button.addEventListener('click',()=>{saveDraft();mode=button.dataset.composerMode;loadDraft();setTimeout(()=>input?.focus(),0)}));
|
|
workspace.querySelector('[data-toggle-recorder]')?.addEventListener('click',()=>{const recorder=workspace.querySelector('[data-voice-recorder]');recorder.hidden=!recorder.hidden;if(!recorder.hidden)recorder.querySelector('[data-record]')?.focus()});
|
|
workspace.querySelector('[data-toggle-extra]')?.addEventListener('click',()=>{const extra=workspace.querySelector('[data-composer-extra]');extra.hidden=!extra.hidden});
|
|
form?.addEventListener('submit',()=>{form.dataset.submitting='true'});
|
|
window.addEventListener('pageshow',()=>{if(submissionSucceeded){form?.reset();if(input){input.value='';resize()}setComposerOpen(false,false)}});
|
|
|
|
const pollUrl=workspace.dataset.pollUrl;
|
|
const readUrl=workspace.dataset.readUrl;
|
|
const pollInterval=3000;
|
|
let pollController=null,pollRunning=false,pollGeneration=0,pollErrors=0,nextPollAt=0;
|
|
let readRunning=false;
|
|
const cookie=name=>document.cookie.split('; ').find(row=>row.startsWith(`${name}=`))?.split('=').slice(1).join('=')||'';
|
|
const acknowledgeVisibleMessages=async()=>{
|
|
if(!readUrl||readRunning||document.visibilityState!=='visible')return;
|
|
const messageIds=[...scroller.querySelectorAll('[data-message-id]')].map(item=>item.dataset.messageId).filter(Boolean);
|
|
if(!messageIds.length)return;
|
|
readRunning=true;
|
|
try{await fetch(readUrl,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':decodeURIComponent(cookie('csrftoken'))},body:JSON.stringify({message_ids:messageIds})})}catch(error){console.error('Message read acknowledgement failed',error)}finally{readRunning=false}
|
|
};
|
|
acknowledgeVisibleMessages();
|
|
// Keep the staff/customer conversation synchronized even while another tab is
|
|
// active. Browsers may throttle background timers, so visibility is used only
|
|
// to trigger an immediate catch-up, never as a hard polling gate.
|
|
// navigator.onLine only reports the browser's network-interface heuristic.
|
|
// It can be false while this Django origin is still reachable (notably on
|
|
// local networks, installed PWAs and some mobile browsers). Let fetch be the
|
|
// source of truth and use the existing error backoff when the origin really
|
|
// is unavailable.
|
|
const canPoll=()=>Boolean(pollUrl);
|
|
const schedulePoll=()=>{const delay=pollErrors?Math.min(30000,pollInterval*(2**Math.min(pollErrors,3))):pollInterval;nextPollAt=Date.now()+delay};
|
|
const applyPollUpdate=data=>{
|
|
if(!data.changed)return;
|
|
const wasAtBottom=atBottom();
|
|
const fragment=document.createRange().createContextualFragment(data.html||'');
|
|
if(data.replace)scroller.querySelectorAll('[data-message],.date-divider,.conversation-empty').forEach(item=>item.remove());
|
|
else scroller.querySelectorAll('.conversation-empty').forEach(item=>item.remove());
|
|
scroller.insertBefore(fragment,conversationEnd);
|
|
workspace.dataset.pollVersion=data.version||'';workspace.dataset.pollCursor=data.cursor||'';
|
|
const count=workspace.querySelector('[data-conversation-count]');if(count)count.textContent=String(data.count??'');
|
|
workspace.querySelectorAll('[data-ticket-status]').forEach(item=>item.textContent=data.status||item.textContent);
|
|
groupDates();
|
|
acknowledgeVisibleMessages();
|
|
scroller.querySelectorAll('img:not([data-poll-bound]),audio:not([data-poll-bound])').forEach(media=>{media.dataset.pollBound='true';const event=media.tagName==='IMG'?'load':'loadedmetadata';media.addEventListener(event,()=>{if(wasAtBottom)scrollLatest(false)},{once:true})});
|
|
if(wasAtBottom){scrollLatest(true);jump.hidden=true}else jump.hidden=false;
|
|
};
|
|
const poll=async()=>{
|
|
if(!canPoll()||pollRunning)return;
|
|
const generation=++pollGeneration;pollRunning=true;const controller=new AbortController();pollController=controller;
|
|
try{
|
|
const url=new URL(pollUrl,location.origin);url.searchParams.set('version',workspace.dataset.pollVersion||'');url.searchParams.set('cursor',workspace.dataset.pollCursor||'');
|
|
const response=await fetch(url,{method:'GET',signal:controller.signal,cache:'no-store',headers:{Accept:'application/json'}});
|
|
if(response.redirected){location.assign(response.url);return}
|
|
if(!response.ok)throw new Error(`HTTP ${response.status}`);
|
|
applyPollUpdate(await response.json());pollErrors=0;
|
|
}catch(error){if(error.name!=='AbortError'){pollErrors+=1;console.error('Ticket polling failed',error)}}finally{if(generation===pollGeneration){pollRunning=false;pollController=null;schedulePoll()}}
|
|
};
|
|
const startPolling=()=>{if(!canPoll()||pollRunning||Date.now()<nextPollAt)return;poll()};
|
|
const stopPolling=()=>{pollGeneration+=1;if(pollController){pollController.abort();pollController=null}pollRunning=false;nextPollAt=0};
|
|
const restartPolling=()=>{stopPolling();startPolling()};
|
|
document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='visible'){restartPolling();acknowledgeVisibleMessages()}});
|
|
window.addEventListener('focus',startPolling);
|
|
window.addEventListener('pageshow',restartPolling);
|
|
window.addEventListener('online',restartPolling);
|
|
// One independent heartbeat cannot be lost in the load/pageshow race that can
|
|
// cancel a recursively scheduled timeout. pollRunning prevents overlap and
|
|
// nextPollAt preserves the normal interval and network-error backoff.
|
|
const pollHeartbeat=setInterval(startPolling,1000);
|
|
window.addEventListener('beforeunload',()=>{clearInterval(pollHeartbeat);stopPolling();layoutObserver?.disconnect();if(inputVisibilityFrame)cancelAnimationFrame(inputVisibilityFrame)},{once:true});
|
|
startPolling();
|
|
|
|
const resetMessageMenu=menu=>{if(!menu)return;const editor=menu.querySelector('[data-message-editor]');const choices=menu.querySelector('[data-message-menu-choices]');const input=menu.querySelector('[data-message-edit-input]');if(editor)editor.hidden=true;if(choices)choices.hidden=false;if(input){input.value=input.dataset.originalValue||'';input.dispatchEvent(new Event('input',{bubbles:true}))}};
|
|
const closeMessageMenu=menu=>{if(!menu)return;menu.open=false;resetMessageMenu(menu)};
|
|
workspace.addEventListener('toggle',event=>{const menu=event.target.closest?.('[data-message-menu]');if(menu?.open)workspace.querySelectorAll('[data-message-menu][open]').forEach(other=>{if(other!==menu)closeMessageMenu(other)})},true);
|
|
workspace.addEventListener('click',event=>{const close=event.target.closest('[data-message-menu-close]');if(close){event.preventDefault();closeMessageMenu(close.closest('[data-message-menu]'));return}const edit=event.target.closest('[data-message-edit-open]');if(edit){const menu=edit.closest('[data-message-menu]');menu.querySelector('[data-message-menu-choices]').hidden=true;menu.querySelector('[data-message-editor]').hidden=false;const input=menu.querySelector('[data-message-edit-input]');input.focus();input.setSelectionRange(input.value.length,input.value.length);return}const back=event.target.closest('[data-message-edit-back]');if(back){event.preventDefault();resetMessageMenu(back.closest('[data-message-menu]'))}});
|
|
workspace.addEventListener('input',event=>{if(!event.target.matches('[data-message-edit-input]'))return;const count=event.target.closest('[data-message-editor]').querySelector('[data-message-edit-count]');if(count)count.textContent=`${event.target.value.length.toLocaleString('fa-IR')} / ۴۰۰۰`});
|
|
document.addEventListener('click',event=>{workspace.querySelectorAll('[data-message-menu][open]').forEach(menu=>{if(!menu.contains(event.target))closeMessageMenu(menu)})});
|
|
workspace.addEventListener('keydown',event=>{if(event.key==='Escape'){const menu=event.target.closest('[data-message-menu]')||workspace.querySelector('[data-message-menu][open]');if(menu){event.preventDefault();closeMessageMenu(menu);menu.querySelector(':scope > summary')?.focus()}}});
|
|
|
|
workspace.querySelectorAll('[data-toggle-panel]').forEach(button=>button.addEventListener('click',()=>{const panel=workspace.querySelector(`#${button.dataset.togglePanel}`);workspace.querySelectorAll('[data-workspace-panel]').forEach(item=>item.classList.toggle('panel-open',item===panel&&!item.classList.contains('panel-open')))}));
|
|
workspace.querySelectorAll('[data-close-panel]').forEach(button=>button.addEventListener('click',()=>button.closest('[data-workspace-panel]').classList.remove('panel-open')));
|
|
});
|