@kangsicj/dcinsideenhancer 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  // @name DCInside Enhancer
3
3
  // @name:ko 디시 인핸서
4
4
  // @namespace https://github.com/kangsicj/DCInsideEnhancer
5
- // @version 1.0.2
5
+ // @version 1.1.1
6
6
  // @description DCInside filter & enhancer — mobile & PC. Block users/words/heads/notices/images & GIFs across posts, infinite list, zoom viewer, theme sync, encrypted multi-device sync.
7
7
  // @description:ko 디시인사이드 차단·편의 기능 (모바일·PC) - 유저·단어(제목/본문)·말머리·공지 차단, 같은/비슷한 이미지·움짤 모든 글 차단, 디시콘 제거, 차단 미디어 데이터 절약, 목록 자동 보충·무한 스크롤·뒤로 가기 위치 복원, 스크랩, 이미지 확대뷰 개선, 야간모드 자동 연동, 암호화 기기 간 동기화
8
8
  // @author kangsicj
@@ -75,7 +75,7 @@
75
75
  // 0. 설정·상수
76
76
  // =========================================================
77
77
 
78
- const SCRIPT_VERSION = '1.0.2';
78
+ const SCRIPT_VERSION = '1.1.1';
79
79
  const PROJECT_URL = 'https://github.com/kangsicj/DCInsideEnhancer';
80
80
  const DONATE_URL = 'https://fairy.hada.io/@jinny#support';
81
81
 
@@ -104,6 +104,11 @@
104
104
  syncUndo: 'dc_sync_undo',
105
105
  syncLog: 'dc_sync_log',
106
106
  syncLock: 'dc_sync_lock',
107
+ syncRt: 'dc_sync_rt',
108
+ recent: 'dc_recent_galls',
109
+ recentNative: 'dc_recent_native',
110
+ searchHistory: 'dc_search_history',
111
+ alarmSeen: 'dc_alarm_marks',
107
112
  seenVersion: 'dc_seen_version'
108
113
  };
109
114
 
@@ -212,6 +217,13 @@
212
217
  hideHomeNewGall: true,
213
218
  hideHomeMedia: true,
214
219
  hideFooterInfo: true,
220
+ hideAdSlots: true,
221
+ hideNewsRows: true,
222
+ hideCommentAds: true,
223
+ hideSilbeAlarm: true,
224
+ alarmBubble: true,
225
+ alarmKeepPolling: true,
226
+ keyboardShortcuts: true,
215
227
  pcFitWidth: true,
216
228
  autoHideScrollbar: true,
217
229
  scrapIntercept: true,
@@ -265,6 +277,8 @@
265
277
  --dc-type-id: #6d538d;
266
278
  --dc-shadow: 0 8px 24px rgba(0,0,0,.16);
267
279
  --dc-shadow-sm: 0 2px 8px rgba(0,0,0,.12);
280
+ --dc-raised: #ffffff;
281
+ --dc-raised-shadow: 0 1px 2px rgba(0,0,0,.08), 0 2px 8px rgba(0,0,0,.08);
268
282
  --dc-overlay: rgba(0,0,0,.45);
269
283
  --dc-ghost-bg: rgba(128,128,128,.12);
270
284
  --dc-ghost-border: rgba(128,128,128,.38);
@@ -290,6 +304,8 @@
290
304
  --dc-type-id: #9c86b1;
291
305
  --dc-shadow: 0 8px 28px rgba(0,0,0,.55);
292
306
  --dc-shadow-sm: 0 2px 10px rgba(0,0,0,.45);
307
+ --dc-raised: #383b44;
308
+ --dc-raised-shadow: inset 0 0 0 1px rgba(255,255,255,.07), 0 1px 2px rgba(0,0,0,.3);
293
309
  --dc-overlay: rgba(0,0,0,.6);
294
310
  --dc-ghost-bg: rgba(150,150,150,.11);
295
311
  --dc-ghost-border: rgba(150,150,150,.30);
@@ -395,57 +411,77 @@
395
411
 
396
412
  html.dc-pc-fit { --dc-fit-gap: clamp(12px, 2vw, 32px); }
397
413
  html.dc-pc-fit body,
398
- html.dc-pc-fit .dcwrap.width1160 { min-width: 0 !important; }
399
- html.dc-pc-fit .dcwrap.width1160 .dchead,
400
- html.dc-pc-fit .dcwrap.width1160 .gnb,
401
- html.dc-pc-fit .dcwrap.width1160 .gnb_depth2_list,
402
- html.dc-pc-fit .dcwrap.width1160 .info_policy,
403
- html.dc-pc-fit .dcwrap.width1160 .copyright,
404
- html.dc-pc-fit .dcwrap.width1160 > .visit_bookmark {
414
+ html.dc-pc-fit .dcwrap { min-width: 0 !important; }
415
+ html.dc-pc-fit .dcwrap .dchead,
416
+ html.dc-pc-fit .dcwrap .gnb,
417
+ html.dc-pc-fit .dcwrap .gnb_depth2_list,
418
+ html.dc-pc-fit .dcwrap .info_policy,
419
+ html.dc-pc-fit .dcwrap .copyright,
420
+ html.dc-pc-fit .dcwrap .visit_bookmark {
405
421
  width: auto !important;
406
422
  max-width: none !important;
407
423
  margin-left: var(--dc-fit-gap) !important;
408
424
  margin-right: var(--dc-fit-gap) !important;
409
425
  }
410
- html.dc-pc-fit .dcwrap.width1160 .gnb_bar { min-width: 0 !important; }
411
- html.dc-pc-fit .dcwrap.width1160 .gnb_list,
412
- html.dc-pc-fit .dcwrap.width1160 .dc_all { width: auto !important; }
426
+
427
+ html.dc-pc-fit .dcwrap .gnb_bar { min-width: 0 !important; width: auto !important; margin: 0 !important; }
428
+ html.dc-pc-fit .dcwrap .gnb_list,
429
+ html.dc-pc-fit .dcwrap .dc_all { width: auto !important; }
413
430
  html.dc-pc-fit .dcwrap .wrap_inner { width: auto !important; }
414
- html.dc-pc-fit .dcwrap.width1160 #container {
431
+ html.dc-pc-fit .dcwrap #container {
432
+ width: auto !important;
433
+ margin-left: var(--dc-fit-gap) !important;
434
+ margin-right: var(--dc-fit-gap) !important;
435
+ }
436
+ html.dc-pc-fit .dcwrap #container:has(> .left_content),
437
+ html.dc-pc-fit .dcwrap #container > .main_content:has(> .left_content) {
415
438
  display: flex;
416
439
  flex-wrap: wrap;
417
440
  align-items: flex-start;
418
441
  gap: 20px;
419
442
  width: auto !important;
420
- margin: 20px var(--dc-fit-gap) 0 !important;
421
443
  }
422
- html.dc-pc-fit .dcwrap.width1160 #container::before,
423
- html.dc-pc-fit .dcwrap.width1160 #container::after { display: none !important; }
424
- html.dc-pc-fit .dcwrap.width1160 #container > :not(.left_content):not(.right_content) {
444
+ html.dc-pc-fit .dcwrap #container:has(> .left_content)::before,
445
+ html.dc-pc-fit .dcwrap #container:has(> .left_content)::after,
446
+ html.dc-pc-fit .dcwrap .main_content:has(> .left_content)::before,
447
+ html.dc-pc-fit .dcwrap .main_content:has(> .left_content)::after { display: none !important; }
448
+ html.dc-pc-fit .dcwrap #container:has(> .left_content) > :not(.left_content):not(.right_content),
449
+ html.dc-pc-fit .dcwrap .main_content:has(> .left_content) > :not(.left_content):not(.right_content) {
425
450
  flex: 0 0 100%;
426
451
  float: none !important;
427
452
  }
428
- html.dc-pc-fit .dcwrap.width1160 #container > .left_content {
453
+ html.dc-pc-fit .dcwrap :is(#container, .main_content) > .left_content {
429
454
  float: none !important;
430
455
  flex: 1 1 0;
431
456
  width: auto !important;
432
457
  min-width: 0;
433
458
  }
434
- html.dc-pc-fit .dcwrap.width1160 #container > .right_content {
459
+ html.dc-pc-fit .dcwrap :is(#container, .main_content) > .right_content {
435
460
  float: none !important;
436
461
  flex: 0 0 300px;
462
+ margin-left: 0 !important;
437
463
  }
438
- html.dc-pc-fit .dcwrap.width1160 .issuebox,
439
- html.dc-pc-fit .dcwrap.width1160 .newvisit_history,
440
- html.dc-pc-fit .dcwrap.width1160 .trendalign { width: auto !important; }
441
- html.dc-pc-fit .dcwrap.width1160 .list_array_option { width: 100% !important; }
464
+ html.dc-pc-fit .dcwrap .issuebox,
465
+ html.dc-pc-fit .dcwrap .newvisit_history,
466
+ html.dc-pc-fit .dcwrap .trendalign { width: auto !important; }
467
+ html.dc-pc-fit .dcwrap .list_array_option { width: 100% !important; }
442
468
  html.dc-pc-fit .dcwrap .stickyunit { display: none !important; }
443
469
  html.dc-pc-fit .dcwrap .darkmodebox .pop_tipbox { left: auto !important; right: 0 !important; }
444
470
 
445
471
  @media (max-width: 1240px) {
446
- html.dc-pc-fit .dcwrap.width1160 #container > .right_content { display: none !important; }
472
+ html.dc-pc-fit .dcwrap :is(#container, .main_content) > .right_content { display: none !important; }
447
473
  }
448
474
 
475
+ html.dc-footer-info-off .dcwrap footer.dcfoot,
476
+ html.dc-footer-info-off .dcwrap #container > .ad_bottom_list { display: none !important; }
477
+
478
+ html.dc-ad-slots-off .dcwrap :is(.stickyunit, .rightbanner, .rightbanner1, .banner_box, .ad_bottom_list, #ad_floating, .kakao_ad_area, .ad_top, .view_ad_wrap, .power_link, .con_banner.writing_banbox, #ad_nv_slot, ins.adsbygoogle),
479
+ html.dc-ad-slots-off :is(.adv-group, li.adv-inner, section.grid.clkhigh, #home-ad, .kakao_ad_area) { display: none !important; }
480
+ html.dc-news-rows-off :is(table.gall_list tr.ub-content[data-type="icon_fnews"], .gall-detail-lst > li:has(.sp-lst-fnews)) { display: none !important; }
481
+ html.dc-comment-ads-off :is(.comment_box li.ub-content.dory, .comment_box li.cmt_pw_ad, .all-comment-lst > li.dc-comment-boy) { display: none !important; }
482
+ html.dc-silbe-alarm-off :is(.alarm-popup.silbe-alarm, .rtbAlarmPopup) { display: none !important; }
483
+ html.dc-alarm-bubble :is(.alarm-popup.alarm-default, .alarmPopup) { display: none !important; }
484
+
449
485
  html.dc-top-concept-off:not(.dc-peek) .detail-top-concept,
450
486
  html.dc-top-concept-off:not(.dc-peek) .con-child-box#recom_box2,
451
487
  html:not(.dc-peek) .detail-top-lst.dc-top-empty {
@@ -919,6 +955,10 @@
919
955
 
920
956
  #dc-custom-modal .dc-head-btn {
921
957
  flex: 0 0 auto;
958
+ display: inline-flex;
959
+ align-items: center;
960
+ justify-content: center;
961
+ box-sizing: border-box;
922
962
  height: 30px;
923
963
  padding: 0 12px;
924
964
  border: 1px solid var(--dc-border);
@@ -927,7 +967,7 @@
927
967
  color: var(--dc-text-dim);
928
968
  font-size: 12px;
929
969
  font-weight: 700;
930
- line-height: 28px;
970
+ line-height: 1;
931
971
  cursor: pointer;
932
972
  }
933
973
 
@@ -939,7 +979,7 @@
939
979
 
940
980
  #dc-custom-modal .dc-modal-tabs {
941
981
  display: flex;
942
- gap: 4px;
982
+ gap: 2px;
943
983
  margin: 0;
944
984
  padding: 3px;
945
985
  border-radius: 12px;
@@ -948,24 +988,36 @@
948
988
 
949
989
  #dc-custom-modal .dc-modal-tab {
950
990
  flex: 1 1 0;
991
+ display: flex;
992
+ align-items: center;
993
+ justify-content: center;
994
+ gap: 3px;
995
+ min-width: 0;
996
+ box-sizing: border-box;
951
997
  height: 36px;
998
+ margin: 0;
999
+ line-height: 1;
952
1000
  padding: 0 2px;
953
1001
  border: none;
954
1002
  border-radius: 9px;
955
1003
  background: transparent;
956
1004
  color: var(--dc-text-dim);
957
- font-size: 12.5px;
1005
+ font-size: 12px;
958
1006
  font-weight: 700;
1007
+ letter-spacing: -.3px;
959
1008
  white-space: nowrap;
960
1009
  cursor: pointer;
961
1010
  }
962
1011
 
963
1012
  #dc-custom-modal .dc-modal-tab.active {
964
- background: var(--dc-surface);
1013
+ background: var(--dc-raised);
965
1014
  color: var(--dc-accent-ink);
966
- box-shadow: var(--dc-shadow-sm);
1015
+ box-shadow: var(--dc-raised-shadow);
967
1016
  }
968
1017
 
1018
+ #dc-custom-modal .dc-modal-tab .dc-tab-ico { flex: 0 0 auto; font-size: 13px; line-height: 1; }
1019
+ #dc-custom-modal .dc-modal-tab .dc-tab-txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; line-height: 1.2; }
1020
+
969
1021
  #dc-custom-modal .dc-section-title {
970
1022
  display: flex;
971
1023
  align-items: center;
@@ -1678,6 +1730,129 @@
1678
1730
 
1679
1731
  @keyframes dc-pop { from { transform: scale(0); } to { transform: scale(1); } }
1680
1732
 
1733
+ #dc-custom-fab[data-dc-alarm]::before {
1734
+ content: attr(data-dc-alarm);
1735
+ position: absolute;
1736
+ top: -6px;
1737
+ left: -6px;
1738
+ min-width: 18px;
1739
+ height: 18px;
1740
+ padding: 0 5px;
1741
+ box-sizing: border-box;
1742
+ border: 2px solid var(--dc-surface);
1743
+ border-radius: 9px;
1744
+ background: var(--dc-danger);
1745
+ color: #fff;
1746
+ font-size: 10.5px;
1747
+ font-weight: 800;
1748
+ line-height: 14px;
1749
+ text-align: center;
1750
+ pointer-events: none;
1751
+ animation: dc-pop .35s ease-out;
1752
+ }
1753
+
1754
+ #dc-custom-fab[data-dc-alarm] { opacity: 1; }
1755
+
1756
+ #dc-bubbles {
1757
+ position: fixed;
1758
+ right: 15px;
1759
+ bottom: calc(144px + env(safe-area-inset-bottom));
1760
+ z-index: 99998;
1761
+ display: flex;
1762
+ flex-direction: column;
1763
+ align-items: flex-end;
1764
+ gap: 8px;
1765
+ width: min(320px, calc(100vw - 24px));
1766
+ pointer-events: none;
1767
+ }
1768
+
1769
+ #dc-bubbles.dc-left { align-items: flex-start; }
1770
+
1771
+ #dc-bubbles .dc-bubble {
1772
+ position: relative;
1773
+ box-sizing: border-box;
1774
+ width: 100%;
1775
+ padding: 10px 12px 11px;
1776
+ border: 1px solid var(--dc-border);
1777
+ border-radius: 16px 16px 6px 16px;
1778
+ background: var(--dc-surface);
1779
+ color: var(--dc-text);
1780
+ box-shadow: var(--dc-shadow);
1781
+ font-size: 13px;
1782
+ line-height: 1.4;
1783
+ cursor: pointer;
1784
+ pointer-events: auto;
1785
+ touch-action: pan-y;
1786
+ user-select: none;
1787
+ -webkit-user-select: none;
1788
+ opacity: 0;
1789
+ transform: translateY(10px) scale(.96);
1790
+ transition: opacity .22s ease, transform .22s ease;
1791
+ }
1792
+
1793
+ #dc-bubbles.dc-left .dc-bubble { border-radius: 16px 16px 16px 6px; }
1794
+ #dc-bubbles .dc-bubble.dc-in { opacity: 1; transform: none; }
1795
+ #dc-bubbles .dc-bubble.dc-out { opacity: 0; transform: translateY(6px) scale(.96); pointer-events: none; }
1796
+
1797
+ #dc-bubbles .dc-bubble-top {
1798
+ display: flex;
1799
+ align-items: center;
1800
+ gap: 6px;
1801
+ margin-bottom: 3px;
1802
+ }
1803
+
1804
+ #dc-bubbles .dc-bubble-tag {
1805
+ flex: 0 0 auto;
1806
+ padding: 1px 7px;
1807
+ border-radius: 8px;
1808
+ background: var(--dc-accent-soft);
1809
+ color: var(--dc-accent-ink);
1810
+ font-size: 11px;
1811
+ font-weight: 800;
1812
+ }
1813
+
1814
+ #dc-bubbles .dc-bubble-time {
1815
+ flex: 1 1 auto;
1816
+ min-width: 0;
1817
+ color: var(--dc-text-dim);
1818
+ font-size: 11px;
1819
+ }
1820
+
1821
+ #dc-bubbles .dc-bubble-x {
1822
+ flex: 0 0 auto;
1823
+ width: 24px;
1824
+ height: 24px;
1825
+ margin: -4px -6px -4px 0;
1826
+ padding: 0;
1827
+ border: none;
1828
+ border-radius: 50%;
1829
+ background: transparent;
1830
+ color: var(--dc-text-dim);
1831
+ font-size: 17px;
1832
+ line-height: 24px;
1833
+ cursor: pointer;
1834
+ }
1835
+
1836
+ #dc-bubbles .dc-bubble-text {
1837
+ display: -webkit-box;
1838
+ overflow: hidden;
1839
+ -webkit-line-clamp: 2;
1840
+ -webkit-box-orient: vertical;
1841
+ word-break: break-all;
1842
+ font-weight: 600;
1843
+ }
1844
+
1845
+ #dc-bubbles .dc-bubble-more {
1846
+ margin-top: 4px;
1847
+ color: var(--dc-accent-ink);
1848
+ font-size: 11.5px;
1849
+ font-weight: 700;
1850
+ }
1851
+
1852
+ #dc-custom-modal .dc-row.dc-row-new .dc-row-title { font-weight: 800; }
1853
+
1854
+ .dc-key-cursor { outline: 2px solid var(--dc-accent-ink) !important; outline-offset: -2px; background: var(--dc-accent-soft) !important; }
1855
+
1681
1856
  #dc-custom-modal .dc-about {
1682
1857
  margin-top: 14px;
1683
1858
  padding: 12px 13px;
@@ -2799,6 +2974,11 @@
2799
2974
  root.classList.toggle('dc-home-media-off', settings.hideHomeMedia !== false);
2800
2975
  root.classList.toggle('dc-footer-info-off', settings.hideFooterInfo !== false);
2801
2976
  root.classList.toggle('dc-pc-fit', IS_PC_SITE && settings.pcFitWidth !== false);
2977
+ root.classList.toggle('dc-ad-slots-off', settings.hideAdSlots !== false);
2978
+ root.classList.toggle('dc-news-rows-off', settings.hideNewsRows !== false);
2979
+ root.classList.toggle('dc-comment-ads-off', settings.hideCommentAds !== false);
2980
+ root.classList.toggle('dc-silbe-alarm-off', settings.hideSilbeAlarm !== false);
2981
+ root.classList.toggle('dc-alarm-bubble', settings.alarmBubble !== false);
2802
2982
  root.classList.toggle('dc-hide-scrollbar', settings.autoHideScrollbar !== false && root.classList.contains('dc-classic-scrollbar'));
2803
2983
  };
2804
2984
 
@@ -2953,6 +3133,7 @@
2953
3133
  until: Number(stored.until) || 0,
2954
3134
  last: Number(stored.last) || 0,
2955
3135
  strikes: Number(stored.strikes) || 0,
3136
+ src: stored.src === 'page' ? 'page' : 'fetch',
2956
3137
  lastStrike: Number(stored.lastStrike) || 0,
2957
3138
  hits: Array.isArray(stored.hits) ? stored.hits.filter(t => Date.now() - t < NET_WINDOW) : [],
2958
3139
  autoHits: Array.isArray(stored.autoHits) ? stored.autoHits.filter(t => Date.now() - t < NET_WINDOW) : [],
@@ -2979,10 +3160,12 @@
2979
3160
 
2980
3161
  const netListeners = [];
2981
3162
 
2982
- const noteNetBlocked = () => {
3163
+ const noteNetBlocked = (source = 'fetch') => {
2983
3164
  const state = readNetState();
2984
3165
  const now = Date.now();
2985
3166
 
3167
+ state.src = source;
3168
+
2986
3169
  state.strikes = now - state.lastStrike < 60 * 60 * 1000 ? state.strikes + 1 : 1;
2987
3170
  state.lastStrike = now;
2988
3171
  state.until = now + Math.min(NET_COOLDOWN_MAX, NET_COOLDOWN * 2 ** (state.strikes - 1));
@@ -3024,10 +3207,21 @@
3024
3207
  }
3025
3208
  };
3026
3209
 
3210
+ const NET_PROBATION = 60 * 1000;
3211
+
3027
3212
  const noteSitePageView = () => {
3028
3213
  const state = readNetState();
3214
+ const now = Date.now();
3215
+
3216
+ state.recent.push(now);
3217
+
3218
+ if (state.until > now + NET_PROBATION && state.src !== 'page') {
3219
+ state.until = now + NET_PROBATION;
3220
+ writeNetState(state);
3221
+ scheduleNetResume();
3222
+ return;
3223
+ }
3029
3224
 
3030
- state.recent.push(Date.now());
3031
3225
  writeNetState(state);
3032
3226
  };
3033
3227
 
@@ -3041,6 +3235,8 @@
3041
3235
  let netHiddenAt = 0;
3042
3236
  let netResumedAt = 0;
3043
3237
 
3238
+ let netLeavingAt = 0;
3239
+
3044
3240
  const pageIsHidden = () => document.visibilityState === 'hidden';
3045
3241
 
3046
3242
  document.addEventListener('visibilitychange', () => {
@@ -3052,7 +3248,48 @@
3052
3248
  });
3053
3249
  window.addEventListener('pagehide', () => {
3054
3250
  netHiddenAt = Date.now();
3251
+ netLeavingAt = Date.now();
3252
+ });
3253
+
3254
+ const noteNetLeaving = () => {
3255
+ netLeavingAt = Date.now();
3256
+ };
3257
+
3258
+ window.addEventListener('beforeunload', noteNetLeaving);
3259
+
3260
+ window.addEventListener('click', event => {
3261
+ const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
3262
+
3263
+ if (!link || event.defaultPrevented || event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
3264
+ return;
3265
+ }
3266
+
3267
+ if (link.target && link.target !== '_self') {
3268
+ return;
3269
+ }
3270
+
3271
+ try {
3272
+ const to = new URL(link.href, location.href);
3273
+
3274
+ if (/^https?:$/.test(to.protocol) && (to.pathname !== location.pathname || to.search !== location.search)) {
3275
+ noteNetLeaving();
3276
+ }
3277
+ } catch {
3278
+ }
3055
3279
  });
3280
+
3281
+ try {
3282
+ if (window.navigation && typeof window.navigation.addEventListener === 'function') {
3283
+ window.navigation.addEventListener('navigate', event => {
3284
+ if (!event.hashChange && !(event.destination && event.destination.sameDocument)) {
3285
+ noteNetLeaving();
3286
+ }
3287
+ });
3288
+ }
3289
+ } catch {
3290
+ }
3291
+
3292
+ const netLeavingRecently = () => netLeavingAt && Date.now() - netLeavingAt < 6000;
3056
3293
  window.addEventListener('pageshow', event => {
3057
3294
  if (event.persisted) {
3058
3295
  netResumedAt = Date.now();
@@ -3066,7 +3303,18 @@
3066
3303
  });
3067
3304
 
3068
3305
  const unreliableWindow = startedAt =>
3069
- pageIsHidden() || netHiddenAt >= startedAt || Date.now() - netResumedAt < NET_RESUME_SETTLE + 3000;
3306
+ pageIsHidden() || netHiddenAt >= startedAt || netLeavingAt >= startedAt || netLeavingRecently() ||
3307
+ Date.now() - netResumedAt < NET_RESUME_SETTLE + 3000;
3308
+
3309
+ const truncatedResponse = (response, text) => {
3310
+ try {
3311
+ const length = Number(response.headers && response.headers.get && response.headers.get('content-length'));
3312
+
3313
+ return Number.isFinite(length) && length > 0 && !text.trim();
3314
+ } catch {
3315
+ return false;
3316
+ }
3317
+ };
3070
3318
 
3071
3319
  class NetTransientError extends Error {}
3072
3320
 
@@ -3084,7 +3332,7 @@
3084
3332
  throw new NetPausedError('paused');
3085
3333
  }
3086
3334
 
3087
- if (pageIsHidden()) {
3335
+ if (pageIsHidden() || netLeavingRecently()) {
3088
3336
  throw new NetTransientError('hidden');
3089
3337
  }
3090
3338
 
@@ -3158,7 +3406,7 @@
3158
3406
  let { response, text } = await fetchOnce('');
3159
3407
 
3160
3408
  if (looksBlockedResponse(response, text)) {
3161
- if (unreliableWindow(startedAt)) {
3409
+ if (unreliableWindow(startedAt) || truncatedResponse(response, text)) {
3162
3410
  throw new NetTransientError('unreliable');
3163
3411
  }
3164
3412
 
@@ -3170,11 +3418,17 @@
3170
3418
  ({ response, text } = await fetchOnce(options.cache === 'no-store' ? 'no-store' : 'reload'));
3171
3419
 
3172
3420
  if (looksBlockedResponse(response, text)) {
3421
+ if (unreliableWindow(startedAt) || truncatedResponse(response, text)) {
3422
+ throw new NetTransientError('unreliable');
3423
+ }
3424
+
3425
+ await sleepMs(1500 * NET_GAP_SCALE);
3426
+
3173
3427
  if (unreliableWindow(startedAt)) {
3174
3428
  throw new NetTransientError('unreliable');
3175
3429
  }
3176
3430
 
3177
- noteNetBlocked();
3431
+ noteNetBlocked('fetch');
3178
3432
  throw new NetPausedError('blocked');
3179
3433
  }
3180
3434
  }
@@ -5592,6 +5846,38 @@
5592
5846
  // =========================================================
5593
5847
 
5594
5848
  let toastTimer = null;
5849
+ const toastListeners = [];
5850
+
5851
+ const notifyToastListeners = () => toastListeners.forEach(fn => {
5852
+ try {
5853
+ fn();
5854
+ } catch {
5855
+ }
5856
+ });
5857
+
5858
+ const placeToast = toast => {
5859
+ toast.style.bottom = '';
5860
+
5861
+ const fab = document.getElementById('dc-custom-fab');
5862
+
5863
+ if (!fab || getComputedStyle(fab).display === 'none') {
5864
+ return;
5865
+ }
5866
+
5867
+ const f = fab.getBoundingClientRect();
5868
+ const t = toast.getBoundingClientRect();
5869
+
5870
+ if (!f.width || !t.width) {
5871
+ return;
5872
+ }
5873
+
5874
+ const overlapX = t.left < f.right + 6 && t.right > f.left - 6;
5875
+ const overlapY = t.top < f.bottom + 6 && t.bottom > f.top - 6;
5876
+
5877
+ if (overlapX && overlapY) {
5878
+ toast.style.bottom = `${Math.round(window.innerHeight - f.top + 10)}px`;
5879
+ }
5880
+ };
5595
5881
 
5596
5882
  const showToast = (text, { actionLabel = '', onAction = null, duration = 3500 } = {}) => {
5597
5883
  let toast = document.getElementById('dc-toast');
@@ -5620,8 +5906,13 @@
5620
5906
  };
5621
5907
 
5622
5908
  toast.classList.add('show');
5909
+ placeToast(toast);
5910
+ notifyToastListeners();
5623
5911
  clearTimeout(toastTimer);
5624
- toastTimer = setTimeout(() => toast.classList.remove('show'), duration);
5912
+ toastTimer = setTimeout(() => {
5913
+ toast.classList.remove('show');
5914
+ setTimeout(notifyToastListeners, 220);
5915
+ }, duration);
5625
5916
  };
5626
5917
 
5627
5918
  const offerUndo = (text, undo) => {
@@ -6814,6 +7105,8 @@
6814
7105
  document.querySelectorAll(COMMENT_ITEM_SELECTOR).forEach(li => {
6815
7106
  processUserNode(li, li.querySelector(COMMENT_NICK_SELECTOR), 'comment');
6816
7107
  li.classList.toggle('dc-comment-hidden', settings.hideDcconComments !== false && isDcconOnlyComment(li));
7108
+
7109
+ li.classList.toggle('dc-comment-boy', /^\s*댓글돌이\s*$/.test((li.querySelector('.nick') || {}).textContent || ''));
6817
7110
  });
6818
7111
 
6819
7112
  processMediaElements();
@@ -9700,6 +9993,7 @@
9700
9993
  const SETTING_GROUPS = [
9701
9994
  { key: 'block', title: '🚫 차단 방식', intro: '차단한 글·댓글을 화면에서 어떻게 처리할지 정합니다.' },
9702
9995
  { key: 'clean', title: '🧹 화면 정리', intro: '사이트가 기본으로 보여 주는 영역 중 필요 없는 것을 숨깁니다.' },
9996
+ { key: 'alarm', title: '🔔 알림', intro: '디시 알림(댓글·답글·힛갤 등)을 보기 쉽게 바꿉니다. 알림 내용은 사이트가 받은 것을 그대로 씁니다.' },
9703
9997
  { key: 'dccon', title: '😶 디시콘', intro: '디시 자체 이모티콘을 다룹니다.' },
9704
9998
  { key: 'list', title: '📜 목록 보기', intro: '게시판 목록을 넘겨 보는 방식을 정합니다.' },
9705
9999
  { key: 'media', title: '🖼 이미지·영상', intro: '본문 이미지 차단과 확대 보기를 다룹니다.' },
@@ -9823,22 +10117,71 @@
9823
10117
  off: '디시미디어·디시이슈 묶음을 보여 줍니다.',
9824
10118
  refresh: 'root'
9825
10119
  },
10120
+ {
10121
+ group: 'clean',
10122
+ key: 'hideNewsRows',
10123
+ name: '목록의 제휴 뉴스 숨기기',
10124
+ on: "말머리가 '뉴스'인 제휴 기사(광고성 글)를 목록에서 숨깁니다.",
10125
+ off: '제휴 뉴스를 목록에 그대로 보여 줍니다.',
10126
+ refresh: 'root'
10127
+ },
10128
+ {
10129
+ group: 'clean',
10130
+ key: 'hideCommentAds',
10131
+ name: '댓글돌이·파워링크 댓글 숨기기',
10132
+ on: '댓글 사이에 끼어드는 댓글돌이(기사·광고 넘겨 보기)와 파워링크 댓글을 숨깁니다.',
10133
+ off: '댓글돌이를 그대로 보여 줍니다.',
10134
+ refresh: 'root'
10135
+ },
10136
+ {
10137
+ group: 'clean',
10138
+ key: 'hideAdSlots',
10139
+ name: '광고 자리 숨기기',
10140
+ on: '배너·파워링크·구글 광고처럼 광고가 들어가는 칸을 숨겨 빈 공간 없이 보여 줍니다.',
10141
+ off: '광고 칸을 사이트 기본처럼 둡니다.',
10142
+ refresh: 'root'
10143
+ },
9826
10144
  {
9827
10145
  group: 'clean',
9828
10146
  key: 'pcFitWidth',
9829
10147
  name: 'PC 화면을 창 크기에 꽉 차게 (PC)',
9830
- on: '가운데 고정 폭(1160px) 대신 창 너비에 맞춰 목록·본문·머리말을 넓힙니다. 창이 좁으면 오른쪽 칸(로그인·실시간 베스트)을 숨겨 목록이 잘리지 않게 합니다.',
10148
+ on: '가운데 고정 폭 대신 창 너비에 맞춰 갤러리 목록·글·디시 홈을 넓힙니다. 창이 좁으면 오른쪽 칸(로그인·실시간 베스트)을 숨겨 목록이 잘리지 않게 합니다.',
9831
10149
  off: '사이트 기본처럼 가운데 고정 폭으로 보여 줍니다.',
9832
10150
  refresh: 'root'
9833
10151
  },
9834
10152
  {
9835
10153
  group: 'clean',
9836
10154
  key: 'hideFooterInfo',
9837
- name: '맨 아래 회사 정보 줄 숨기기 (모바일)',
9838
- on: '페이지 맨 아래의 앱 설치·로그인·회사 소개·약관·저작권 줄을 숨깁니다. 즐겨찾기·야간 모드·PC버전 줄은 남깁니다.',
10155
+ name: '맨 아래 정보 영역 숨기기',
10156
+ on: '모바일은 맨 아래 앱 설치·회사 소개·약관·저작권 줄을, PC는 검색창 아래의 갤러리 분류 목록·야간모드/맨위로·회사 정보·저작권을 숨깁니다. 창이 작아도 목록이 가려지지 않습니다.',
9839
10157
  off: '맨 아래 정보 줄을 모두 보여 줍니다.',
9840
10158
  refresh: 'root'
9841
10159
  },
10160
+ {
10161
+ group: 'clean',
10162
+ key: 'hideSilbeAlarm',
10163
+ name: '실베(실시간 베스트) 알림 팝업 숨기기',
10164
+ on: '내 알림과 상관없이 화면 구석에 뜨는 실시간 베스트 홍보 한 줄 알림을 숨깁니다.',
10165
+ off: '실시간 베스트 알림을 사이트 기본처럼 띄웁니다.',
10166
+ refresh: 'root'
10167
+ },
10168
+ {
10169
+ group: 'alarm',
10170
+ key: 'alarmBubble',
10171
+ name: '알림을 플로팅 말풍선으로',
10172
+ on: '사이트의 한 줄 알림 대신 차단 관리 버튼 곁에 말풍선으로 띄우고, 읽지 않은 알림 수를 버튼에 표시합니다. 누르면 이동, 좌우로 밀면 닫힙니다. 지난 알림은 [🕘 최근] 탭에 모입니다.',
10173
+ off: '사이트 기본 한 줄 알림을 그대로 씁니다.',
10174
+ refresh: 'root'
10175
+ },
10176
+ {
10177
+ group: 'alarm',
10178
+ key: 'alarmKeepPolling',
10179
+ parent: 'alarmBubble',
10180
+ name: '오래 보는 페이지에서도 알림 받기 (모바일)',
10181
+ on: '모바일 사이트는 한 페이지에서 알림을 세 번(약 4분 반)만 확인합니다. 그 뒤에도 화면을 보는 동안 3분마다 이어서 확인합니다.',
10182
+ off: '사이트 기본처럼 페이지를 옮길 때만 다시 확인합니다.',
10183
+ refresh: 'none'
10184
+ },
9842
10185
  {
9843
10186
  group: 'dccon',
9844
10187
  key: 'blockDccon',
@@ -9855,6 +10198,14 @@
9855
10198
  off: '모든 댓글을 보여 줍니다.',
9856
10199
  refresh: 'all'
9857
10200
  },
10201
+ {
10202
+ group: 'list',
10203
+ key: 'keyboardShortcuts',
10204
+ name: '키보드 단축키 (PC)',
10205
+ on: '목록에서 J/K 로 글을 고르고 Enter 로 엽니다. 글에서는 J/K 로 다음·이전 글, S 스크랩, / 검색창, B 차단 관리, ? 안내.',
10206
+ off: '단축키를 쓰지 않습니다.',
10207
+ refresh: 'none'
10208
+ },
9858
10209
  {
9859
10210
  group: 'list',
9860
10211
  key: 'autoFillList',
@@ -10048,6 +10399,10 @@
10048
10399
  repinReadColors();
10049
10400
  }
10050
10401
 
10402
+ if (item.key === 'alarmBubble') {
10403
+ paintAlarmBadge();
10404
+ }
10405
+
10051
10406
  break;
10052
10407
 
10053
10408
  case 'log':
@@ -11008,7 +11363,11 @@
11008
11363
  '<button id="dc-modal-close-btn" class="dc-head-btn" type="button">닫기</button>' +
11009
11364
  '</div>' +
11010
11365
  `<div class="dc-modal-tabs">${Object.keys(MODAL_VIEWS)
11011
- .map(key => `<button class="dc-modal-tab" type="button" data-dc-view="${key}">${MODAL_VIEWS[key].tab}</button>`)
11366
+ .map(key => {
11367
+ const [ico, ...rest] = MODAL_VIEWS[key].tab.split(' ');
11368
+
11369
+ return `<button class="dc-modal-tab" type="button" data-dc-view="${key}"><span class="dc-tab-ico" aria-hidden="true">${ico}</span><span class="dc-tab-txt">${rest.join(' ')}</span></button>`;
11370
+ })
11012
11371
  .join('')}</div>` +
11013
11372
  '</div>' +
11014
11373
  '<div id="dc-modal-content"></div>';
@@ -11080,6 +11439,9 @@
11080
11439
  const SYNC_MIN_GAP = 20000;
11081
11440
  const SYNC_POLL = 60000;
11082
11441
  const SYNC_LOCK_MS = 20000;
11442
+ const SYNC_RETRY = [3000, 10000, 30000];
11443
+ const SYNC_FAIL_SHOW = 3;
11444
+ const SYNC_STUCK_MS = 30000;
11083
11445
  const SYNC_DEVICE_REFRESH = 10 * 60 * 1000;
11084
11446
  const SYNC_DEVICE_STALE = 60 * 24 * 3600 * 1000;
11085
11447
  const SYNC_LOG_MAX = 20;
@@ -11094,6 +11456,8 @@
11094
11456
  { key: 'words', label: '단어', fields: ['keywords', 'titles'] },
11095
11457
  { key: 'heads', label: '말머리', fields: ['heads'] },
11096
11458
  { key: 'scraps', label: '스크랩', fields: ['scraps'] },
11459
+ { key: 'recent', label: '최근 갤러리', fields: ['recent'] },
11460
+ { key: 'search', label: '검색 기록', fields: ['search'] },
11097
11461
  { key: 'settings', label: '설정', fields: ['settings'] }
11098
11462
  ];
11099
11463
 
@@ -11260,7 +11624,9 @@
11260
11624
  titles: [...blockedTitles],
11261
11625
  heads: JSON.parse(JSON.stringify(hiddenHeadMap || {})),
11262
11626
  scraps: scraps.map(item => ({ ...item })),
11263
- settings: syncedSettings()
11627
+ settings: syncedSettings(),
11628
+ recent: collectRecentForSync(),
11629
+ search: collectSearchForSync()
11264
11630
  });
11265
11631
 
11266
11632
  const skippedFields = skip => SYNC_PARTS.filter(part => skip.includes(part.key)).flatMap(part => part.fields);
@@ -11366,7 +11732,9 @@
11366
11732
  titles,
11367
11733
  heads: mergeHeads(local.heads, base && base.heads, remote && remote.heads),
11368
11734
  scraps: mergeScraps(local.scraps, base && base.scraps, remote && remote.scraps),
11369
- settings: mergeSettings(local.settings, base && base.settings, remote && remote.settings)
11735
+ settings: mergeSettings(local.settings, base && base.settings, remote && remote.settings),
11736
+ recent: mergeRecent(local.recent, base && base.recent, remote && remote.recent),
11737
+ search: mergeSearchHistory(local.search, base && base.search, remote && remote.search)
11370
11738
  };
11371
11739
 
11372
11740
  skippedFields(skip).forEach(field => {
@@ -11416,9 +11784,20 @@
11416
11784
 
11417
11785
  let syncApplying = false;
11418
11786
 
11787
+ const blockSyncData = () => {
11788
+ const data = collectSyncData();
11789
+
11790
+ delete data.recent;
11791
+ delete data.search;
11792
+
11793
+ return JSON.stringify(data);
11794
+ };
11795
+
11419
11796
  const applySyncData = data => {
11797
+ const recentChanged = data && 'recent' in data ? applyRecentFromSync(data.recent) : false;
11798
+ const searchChanged = data && 'search' in data ? applySearchFromSync(data.search) : false;
11420
11799
  const clean = sanitizeBackup({ app: BACKUP_APP, ...data });
11421
- const before = JSON.stringify(collectSyncData());
11800
+ const before = blockSyncData();
11422
11801
 
11423
11802
  syncApplying = true;
11424
11803
 
@@ -11465,13 +11844,13 @@
11465
11844
  syncApplying = false;
11466
11845
  }
11467
11846
 
11468
- const changed = before !== JSON.stringify(collectSyncData());
11847
+ const changed = before !== blockSyncData();
11469
11848
 
11470
11849
  if (changed && initialized) {
11471
11850
  refreshAfterBlockChange();
11472
11851
  }
11473
11852
 
11474
- return changed;
11853
+ return changed || recentChanged || searchChanged;
11475
11854
  };
11476
11855
 
11477
11856
  const saveSyncUndo = (before, incoming, summary, from) => {
@@ -11501,6 +11880,20 @@
11501
11880
  return /^https:\/\/[^/\s]+/.test(base) ? `${base}${path}` : '';
11502
11881
  };
11503
11882
 
11883
+ const syncError = (message, { transient = false, quiet = false } = {}) => Object.assign(new Error(message), { transient, quiet });
11884
+ const statusError = status => syncError(status === 413 ? '내용이 너무 큽니다' : `서버 응답 ${status}`, { transient: status >= 500 || status === 429 || status === 408 });
11885
+
11886
+ const syncControllers = new Set();
11887
+
11888
+ const abortSyncRequests = () => {
11889
+ syncControllers.forEach(controller => {
11890
+ if (!controller.dcKeep) {
11891
+ controller.dcQuiet = true;
11892
+ controller.abort();
11893
+ }
11894
+ });
11895
+ };
11896
+
11504
11897
  const syncRequest = async (path, { method = 'GET', headers = {}, body, keepalive = false } = {}) => {
11505
11898
  const url = syncEndpoint(path);
11506
11899
 
@@ -11512,22 +11905,42 @@
11512
11905
  const timer = controller ? setTimeout(() => controller.abort(), 15000) : null;
11513
11906
  const payload = body === undefined ? undefined : JSON.stringify(body);
11514
11907
 
11908
+ if (controller) {
11909
+ controller.dcKeep = keepalive;
11910
+ syncControllers.add(controller);
11911
+ }
11912
+
11515
11913
  try {
11516
- const response = await fetch(url, {
11914
+ let response;
11915
+
11916
+ try {
11917
+ response = await fetch(url, {
11517
11918
  method,
11518
11919
  headers: { 'Content-Type': 'application/json', ...headers },
11519
11920
  body: payload,
11520
11921
  credentials: 'omit',
11521
11922
  cache: 'no-store',
11522
11923
  referrerPolicy: 'no-referrer',
11523
- keepalive: keepalive && (!payload || payload.length < 60000),
11524
- signal: controller ? controller.signal : undefined
11525
- });
11924
+ keepalive: keepalive && (!payload || payload.length < 60000),
11925
+ signal: controller ? controller.signal : undefined
11926
+ });
11927
+ } catch (error) {
11928
+ if (controller && controller.dcQuiet) {
11929
+ throw syncError('페이지를 떠나며 멈춤', { quiet: true });
11930
+ }
11931
+
11932
+ throw syncError(error && error.name === 'AbortError' ? '서버 응답이 없습니다' : '서버에 연결하지 못했습니다', { transient: true });
11933
+ }
11934
+
11526
11935
  const data = await response.json().catch(() => ({}));
11527
11936
 
11528
11937
  return { status: response.status, data };
11529
11938
  } finally {
11530
11939
  clearTimeout(timer);
11940
+
11941
+ if (controller) {
11942
+ syncControllers.delete(controller);
11943
+ }
11531
11944
  }
11532
11945
  };
11533
11946
 
@@ -11610,12 +12023,35 @@
11610
12023
  };
11611
12024
 
11612
12025
  let syncRunning = false;
12026
+ let syncRunId = 0;
12027
+ let syncRunSince = 0;
12028
+ let syncEpoch = 0;
12029
+ let pageLeaving = false;
11613
12030
  let syncAgain = false;
11614
12031
  let syncTimer = null;
11615
12032
  let syncPending = false;
11616
- let lastSyncAt = 0;
11617
12033
  let lastErrorMsg = '';
11618
12034
 
12035
+ const readSyncRt = () => readObject(STORAGE.syncRt);
12036
+ const writeSyncRt = patch => writeJson(STORAGE.syncRt, { ...readSyncRt(), ...patch });
12037
+ const lastSyncTry = () => Number(readSyncRt().lastTry) || 0;
12038
+
12039
+ const resetStuckSync = (force = false) => {
12040
+ if (syncRunning && (force || Date.now() - syncRunSince > SYNC_STUCK_MS)) {
12041
+ syncRunId++;
12042
+ syncRunning = false;
12043
+ releaseSyncLock();
12044
+ setSyncPhase('');
12045
+ }
12046
+ };
12047
+
12048
+ const syncDueOnReturn = () => {
12049
+ const rt = readSyncRt();
12050
+ const state = readObject(STORAGE.syncState);
12051
+
12052
+ return syncPending || rt.dirty === true || (state.at && state.ok === false) || Date.now() - lastSyncTry() > SYNC_MIN_GAP;
12053
+ };
12054
+
11619
12055
  const syncReady = () => {
11620
12056
  const conf = readSyncConf();
11621
12057
 
@@ -11649,6 +12085,8 @@
11649
12085
  return false;
11650
12086
  }
11651
12087
 
12088
+ resetStuckSync();
12089
+
11652
12090
  if (syncRunning) {
11653
12091
  syncAgain = true;
11654
12092
  return false;
@@ -11665,7 +12103,7 @@
11665
12103
  return false;
11666
12104
  }
11667
12105
 
11668
- if (!takeSyncLock(manual)) {
12106
+ if (!takeSyncLock(manual || leaving)) {
11669
12107
  scheduleSync(SYNC_LOCK_MS / 2);
11670
12108
  return false;
11671
12109
  }
@@ -11673,7 +12111,11 @@
11673
12111
  syncRunning = true;
11674
12112
  syncPending = false;
11675
12113
  clearTimeout(syncTimer);
11676
- lastSyncAt = Date.now();
12114
+ syncRunSince = Date.now();
12115
+ writeSyncRt({ lastTry: syncRunSince });
12116
+
12117
+ const runId = ++syncRunId;
12118
+ const epoch = syncEpoch;
11677
12119
 
11678
12120
  const key = readSyncKey();
11679
12121
  const conf = readSyncConf();
@@ -11696,7 +12138,7 @@
11696
12138
  }
11697
12139
 
11698
12140
  if (got.status !== 200) {
11699
- throw new Error(`서버 응답 ${got.status}`);
12141
+ throw statusError(got.status);
11700
12142
  }
11701
12143
 
11702
12144
  setSyncPhase('merge');
@@ -11736,7 +12178,7 @@
11736
12178
  }
11737
12179
 
11738
12180
  if (put.status !== 200) {
11739
- throw new Error(put.status === 413 ? '내용이 너무 큽니다' : `서버 응답 ${put.status}`);
12181
+ throw statusError(put.status);
11740
12182
  }
11741
12183
 
11742
12184
  newRev = Number(put.data.rev) || rev + 1;
@@ -11772,6 +12214,7 @@
11772
12214
  const msg = changed ? `${from}에서 받음 · ${summary}` : sentSummary ? `올림 · ${sentSummary}` : '최신 상태입니다';
11773
12215
 
11774
12216
  writeSyncState({ ok: true, msg });
12217
+ writeSyncRt({ fails: 0, dirty: syncPending });
11775
12218
  lastErrorMsg = '';
11776
12219
 
11777
12220
  if (changed) {
@@ -11796,31 +12239,60 @@
11796
12239
  return true;
11797
12240
  }
11798
12241
 
11799
- throw new Error('다른 기기와 동시에 바뀌어 잠시 뒤 다시 맞춥니다');
12242
+ throw syncError('다른 기기와 동시에 바뀌어 잠시 뒤 다시 맞춥니다', { transient: true });
11800
12243
  } catch (error) {
11801
- const msg = error && error.name === 'AbortError' ? '서버 응답이 없습니다' : String((error && error.message) || error);
11802
-
11803
- writeSyncState({ ok: false, msg });
12244
+ syncPending = true;
11804
12245
 
11805
- if (manual || msg !== lastErrorMsg) {
11806
- addSyncLog('error', msg);
12246
+ if (runId !== syncRunId || (error && error.quiet) || pageLeaving || leaving || epoch !== syncEpoch || document.visibilityState === 'hidden') {
12247
+ writeSyncRt({ dirty: true });
11807
12248
 
11808
- if (manual || lastErrorMsg === '') {
11809
- notifySync('error', `⚠️ 동기화 실패 · ${msg}`, { actionLabel: '다시 시도', onAction: () => syncNow({ manual: true }), duration: 5000, force: manual });
12249
+ if (runId === syncRunId && !pageLeaving && document.visibilityState !== 'hidden') {
12250
+ scheduleSync(SYNC_RETRY[0]);
11810
12251
  }
12252
+
12253
+ return false;
11811
12254
  }
11812
12255
 
11813
- lastErrorMsg = msg;
11814
- syncPending = true;
11815
- scheduleSync(SYNC_POLL);
12256
+ if (navigator.onLine === false) {
12257
+ writeSyncRt({ dirty: true });
12258
+ writeSyncState({ ok: false, offline: true, msg: '오프라인 · 연결되면 맞춥니다' });
12259
+ return false;
12260
+ }
11816
12261
 
11817
- return false;
12262
+ const transient = !!(error && (error.transient || error.name === 'TypeError'));
12263
+ const fails = (Number(readSyncRt().fails) || 0) + 1;
12264
+
12265
+ writeSyncRt({ fails, dirty: true });
12266
+
12267
+ if (transient && !manual && fails < SYNC_FAIL_SHOW) {
12268
+ scheduleSync(SYNC_RETRY[fails - 1]);
12269
+ return false;
12270
+ }
12271
+
12272
+ const msg = String((error && error.message) || error);
12273
+
12274
+ writeSyncState({ ok: false, msg });
12275
+
12276
+ if (manual || msg !== lastErrorMsg) {
12277
+ addSyncLog('error', msg);
12278
+
12279
+ if (manual || lastErrorMsg === '') {
12280
+ notifySync('error', `⚠️ 동기화 실패 · ${msg}`, { actionLabel: '다시 시도', onAction: () => syncNow({ manual: true }), duration: 5000, force: manual });
12281
+ }
12282
+ }
12283
+
12284
+ lastErrorMsg = msg;
12285
+ scheduleSync(transient ? SYNC_RETRY[Math.min(fails - 1, SYNC_RETRY.length - 1)] : SYNC_POLL);
12286
+
12287
+ return false;
11818
12288
  } finally {
11819
- syncRunning = false;
11820
- releaseSyncLock();
11821
- setSyncPhase('');
12289
+ if (runId === syncRunId) {
12290
+ syncRunning = false;
12291
+ releaseSyncLock();
12292
+ setSyncPhase('');
12293
+ }
11822
12294
 
11823
- if (syncAgain) {
12295
+ if (syncAgain && runId === syncRunId) {
11824
12296
  syncAgain = false;
11825
12297
  scheduleSync(SYNC_DEBOUNCE);
11826
12298
  }
@@ -11870,6 +12342,7 @@
11870
12342
  storageWriteListeners.push(key => {
11871
12343
  if (!syncApplying && SYNCED_STORAGE.includes(key) && syncReady()) {
11872
12344
  syncPending = true;
12345
+ writeSyncRt({ dirty: true });
11873
12346
  scheduleSync(SYNC_DEBOUNCE);
11874
12347
  }
11875
12348
  });
@@ -11904,7 +12377,7 @@
11904
12377
  return;
11905
12378
  }
11906
12379
 
11907
- if (document.visibilityState === 'visible' && Date.now() - lastSyncAt > SYNC_POLL - 1000) {
12380
+ if (document.visibilityState === 'visible' && Date.now() - lastSyncTry() > SYNC_POLL - 1000) {
11908
12381
  syncNow();
11909
12382
  }
11910
12383
  }, SYNC_POLL);
@@ -11961,29 +12434,48 @@
11961
12434
  syncStarted = true;
11962
12435
  noteScriptVersion();
11963
12436
 
11964
- document.addEventListener('visibilitychange', () => {
12437
+ const onReturn = restored => {
12438
+ pageLeaving = false;
12439
+
11965
12440
  if (!syncReady()) {
11966
12441
  return;
11967
12442
  }
11968
12443
 
11969
- if (document.visibilityState === 'visible') {
11970
- if (Date.now() - lastSyncAt > SYNC_MIN_GAP) {
11971
- scheduleSync(800);
11972
- }
11973
- } else if (syncPending) {
11974
- syncNow({ leaving: true });
12444
+ resetStuckSync(restored);
12445
+
12446
+ if (syncDueOnReturn() && !syncRunning) {
12447
+ scheduleSync(800);
12448
+ }
12449
+ };
12450
+
12451
+ const onLeave = unloading => {
12452
+ syncEpoch++;
12453
+ pageLeaving = unloading;
12454
+
12455
+ if (unloading) {
12456
+ abortSyncRequests();
11975
12457
  }
11976
- });
11977
12458
 
11978
- window.addEventListener('pagehide', () => {
12459
+ clearTimeout(syncTimer);
12460
+
11979
12461
  if (syncReady() && syncPending && !syncRunning) {
11980
12462
  syncNow({ leaving: true });
11981
12463
  }
12464
+ };
12465
+
12466
+ document.addEventListener('visibilitychange', () => {
12467
+ if (document.visibilityState === 'visible') {
12468
+ onReturn(false);
12469
+ } else {
12470
+ onLeave(false);
12471
+ }
11982
12472
  });
11983
12473
 
12474
+ window.addEventListener('pagehide', () => onLeave(true));
12475
+
11984
12476
  window.addEventListener('pageshow', event => {
11985
- if (event.persisted && syncReady()) {
11986
- scheduleSync(800);
12477
+ if (event.persisted) {
12478
+ onReturn(true);
11987
12479
  }
11988
12480
  });
11989
12481
 
@@ -12010,7 +12502,10 @@
12010
12502
 
12011
12503
  if (syncReady()) {
12012
12504
  startSyncPoll();
12013
- scheduleSync(1500);
12505
+
12506
+ if (syncDueOnReturn()) {
12507
+ scheduleSync(1500);
12508
+ }
12014
12509
  }
12015
12510
  };
12016
12511
 
@@ -12575,6 +13070,1095 @@
12575
13070
  return box;
12576
13071
  };
12577
13072
 
13073
+ // =========================================================
13074
+ // 13-3. 최근 방문 갤러리 · 사이트 알림
13075
+ // =========================================================
13076
+
13077
+ const RECENT_LIMIT = 50;
13078
+ const RECENT_NATIVE_CAP = IS_MOBILE_SITE ? 24 : 50;
13079
+ const RECENT_KINDS = ['G', 'M', 'N', 'P'];
13080
+ const RECENT_ID_RE = /^[A-Za-z0-9_.-]{1,64}$/;
13081
+
13082
+ const recentKeyOf = item => `${item.k}:${item.id}`;
13083
+
13084
+ const normalizeRecent = item => {
13085
+ if (!item || typeof item !== 'object' || typeof item.id !== 'string' || !RECENT_ID_RE.test(item.id)) {
13086
+ return null;
13087
+ }
13088
+
13089
+ return {
13090
+ k: RECENT_KINDS.includes(item.k) ? item.k : 'G',
13091
+ id: item.id,
13092
+ n: cleanText(item.n).slice(0, 40),
13093
+ at: Number(item.at) || 0
13094
+ };
13095
+ };
13096
+
13097
+ const tidyRecent = list => {
13098
+ const best = new Map();
13099
+
13100
+ list.map(normalizeRecent).filter(Boolean).forEach(item => {
13101
+ const key = recentKeyOf(item);
13102
+ const prev = best.get(key);
13103
+
13104
+ if (!prev || item.at > prev.at || (item.at === prev.at && !prev.n && item.n)) {
13105
+ best.set(key, prev && !item.n ? { ...item, n: prev.n } : item);
13106
+ }
13107
+ });
13108
+
13109
+ return [...best.values()].sort((a, b) => b.at - a.at).slice(0, RECENT_LIMIT);
13110
+ };
13111
+
13112
+ let recentGalls = tidyRecent(readArray(STORAGE.recent));
13113
+
13114
+ const saveRecent = () => {
13115
+ recentGalls = tidyRecent(recentGalls);
13116
+ writeJson(STORAGE.recent, recentGalls);
13117
+ };
13118
+
13119
+ const recentUrl = item => {
13120
+ if (IS_MOBILE_SITE) {
13121
+ const base = item.k === 'N' ? 'mini' : item.k === 'P' ? 'person' : 'board';
13122
+
13123
+ return `https://m.dcinside.com/${base}/${encodeURIComponent(item.id)}`;
13124
+ }
13125
+
13126
+ const prefix = { G: '', M: '/mgallery', N: '/mini', P: '/person' }[item.k] || '';
13127
+
13128
+ return `https://gall.dcinside.com${prefix}/board/lists/?id=${encodeURIComponent(item.id)}`;
13129
+ };
13130
+
13131
+ const RECENT_KIND_LABEL = { G: '', M: '마이너', N: '미니', P: '인물' };
13132
+
13133
+ const MOBILE_KIND_MARK = { M: '$minor$', N: '$mini$', P: '$person$' };
13134
+ const PC_KIND = { G: 'G', M: 'M', N: 'MI', P: 'PR' };
13135
+ const PC_KIND_BACK = { G: 'G', M: 'M', MI: 'N', PR: 'P' };
13136
+
13137
+ const readNativeRecent = () => {
13138
+ try {
13139
+ if (IS_MOBILE_SITE) {
13140
+ const raw = unescape(localStorage.getItem('m_dcinside_lately') || '');
13141
+
13142
+ return raw.split(',').map(part => {
13143
+ const cut = part.indexOf('|');
13144
+
13145
+ if (cut < 1) {
13146
+ return null;
13147
+ }
13148
+
13149
+ let name = part.slice(cut + 1);
13150
+ let k = 'G';
13151
+
13152
+ Object.entries(MOBILE_KIND_MARK).forEach(([kind, mark]) => {
13153
+ if (name.includes(mark)) {
13154
+ k = kind;
13155
+ name = name.replace(mark, '');
13156
+ }
13157
+ });
13158
+
13159
+ return normalizeRecent({ k, id: part.slice(0, cut).trim(), n: name, at: 0 });
13160
+ }).filter(Boolean);
13161
+ }
13162
+
13163
+ if (!/^gall\.dcinside\.com$/i.test(location.hostname)) {
13164
+ return null;
13165
+ }
13166
+
13167
+ const list = JSON.parse(localStorage.getItem('lately_gallery') || 'null');
13168
+
13169
+ return Array.isArray(list)
13170
+ ? list.map(item => item && normalizeRecent({ k: PC_KIND_BACK[item.type] || 'G', id: String(item.id || ''), n: item.name, at: 0 })).filter(Boolean)
13171
+ : [];
13172
+ } catch {
13173
+ return null;
13174
+ }
13175
+ };
13176
+
13177
+ const writeNativeRecent = list => {
13178
+ const top = list.slice(0, RECENT_NATIVE_CAP);
13179
+
13180
+ try {
13181
+ if (IS_MOBILE_SITE) {
13182
+ const text = top.map(item => `${item.id}|${(item.n || item.id).replace(/[,|]/g, ' ')}${MOBILE_KIND_MARK[item.k] ? ` ${MOBILE_KIND_MARK[item.k]}` : ''},`).join('');
13183
+
13184
+ localStorage.setItem('m_dcinside_lately', escape(text));
13185
+ } else if (/^gall\.dcinside\.com$/i.test(location.hostname)) {
13186
+ const dir = { G: '', M: 'mgallery/', N: 'mini/', P: 'person/' };
13187
+
13188
+ localStorage.setItem('lately_gallery', JSON.stringify(top.map(item => ({
13189
+ id: item.id,
13190
+ name: item.n || item.id,
13191
+ type: PC_KIND[item.k] || 'G',
13192
+ link: `gall.dcinside.com/${dir[item.k] || ''}board/lists?id=${item.id}`
13193
+ }))));
13194
+ } else {
13195
+ return;
13196
+ }
13197
+
13198
+ writeJson(STORAGE.recentNative, top.map(recentKeyOf));
13199
+ } catch {
13200
+ }
13201
+ };
13202
+
13203
+ const importNativeRecent = () => {
13204
+ const native = readNativeRecent();
13205
+
13206
+ if (!native) {
13207
+ return false;
13208
+ }
13209
+
13210
+ const before = JSON.stringify(recentGalls);
13211
+ const nativeKeys = new Set(native.map(recentKeyOf));
13212
+ const snapshot = readArray(STORAGE.recentNative).filter(key => typeof key === 'string');
13213
+
13214
+ if (snapshot.length) {
13215
+ const snapSet = new Set(snapshot);
13216
+ const added = native.filter(item => !snapSet.has(recentKeyOf(item))).length;
13217
+ const overflow = Math.max(0, snapshot.length + added - RECENT_NATIVE_CAP);
13218
+ const kept = snapshot.slice(0, snapshot.length - overflow);
13219
+ const removed = new Set(kept.filter(key => !nativeKeys.has(key)));
13220
+
13221
+ if (removed.size) {
13222
+ recentGalls = recentGalls.filter(item => !removed.has(recentKeyOf(item)));
13223
+ }
13224
+ }
13225
+
13226
+ const mine = new Map(recentGalls.map(item => [recentKeyOf(item), item]));
13227
+ let prevAt = Date.now();
13228
+
13229
+ native.forEach(item => {
13230
+ const known = mine.get(recentKeyOf(item));
13231
+
13232
+ if (known) {
13233
+ prevAt = Math.min(prevAt, known.at || prevAt);
13234
+
13235
+ if (!known.n && item.n) {
13236
+ known.n = item.n;
13237
+ }
13238
+ } else {
13239
+ prevAt -= 1000;
13240
+ recentGalls.push({ ...item, at: prevAt });
13241
+ }
13242
+ });
13243
+
13244
+ recentGalls = tidyRecent(recentGalls);
13245
+
13246
+ const changed = JSON.stringify(recentGalls) !== before;
13247
+
13248
+ if (changed) {
13249
+ writeJson(STORAGE.recent, recentGalls);
13250
+ }
13251
+
13252
+ return changed;
13253
+ };
13254
+
13255
+ const exportNativeRecent = () => {
13256
+ const native = readNativeRecent();
13257
+
13258
+ if (!native) {
13259
+ return;
13260
+ }
13261
+
13262
+ const want = recentGalls.slice(0, RECENT_NATIVE_CAP);
13263
+
13264
+ if (JSON.stringify(native.map(recentKeyOf)) !== JSON.stringify(want.map(recentKeyOf))) {
13265
+ writeNativeRecent(want);
13266
+ } else {
13267
+ writeJson(STORAGE.recentNative, want.map(recentKeyOf));
13268
+ }
13269
+ };
13270
+
13271
+ const currentGalleryVisit = () => {
13272
+ const id = getGalleryId();
13273
+
13274
+ if (!id || id === HOME_GALLERY_KEY || !RECENT_ID_RE.test(id)) {
13275
+ return null;
13276
+ }
13277
+
13278
+ let k = 'G';
13279
+ let name = '';
13280
+
13281
+ if (IS_MOBILE_SITE) {
13282
+ if (!/^\/(board|mini|person)\//.test(location.pathname)) {
13283
+ return null;
13284
+ }
13285
+
13286
+ k = /^\/mini\//.test(location.pathname)
13287
+ ? 'N'
13288
+ : /^\/person\//.test(location.pathname)
13289
+ ? 'P'
13290
+ : (document.getElementById('is_minor')?.value || '') !== '' ? 'M' : 'G';
13291
+ name = document.querySelector('.gall-tit-box .gall-tit')?.textContent || '';
13292
+ } else {
13293
+ if (!/^\/(mgallery\/|mini\/|person\/)?board\/(lists|view)/.test(location.pathname)) {
13294
+ return null;
13295
+ }
13296
+
13297
+ k = /^\/mgallery\//.test(location.pathname) ? 'M' : /^\/mini\//.test(location.pathname) ? 'N' : /^\/person\//.test(location.pathname) ? 'P' : 'G';
13298
+
13299
+ const head = document.querySelector('.page_head h2 a') || document.querySelector('.page_head h2');
13300
+
13301
+ name = head ? [...head.childNodes].filter(node => node.nodeType === 3).map(node => node.textContent).join(' ') : '';
13302
+
13303
+ if (!cleanText(name)) {
13304
+ const og = document.querySelector('meta[property="og:title"]')?.content || '';
13305
+
13306
+ name = og.split(' - ').slice(-2, -1)[0] || og.split(' - ')[0] || '';
13307
+ }
13308
+ }
13309
+
13310
+ name = cleanText(name).replace(/\s*(마이너|미니|인물)?\s*갤러리$/, '');
13311
+
13312
+ return normalizeRecent({ k, id, n: name || id, at: Date.now() });
13313
+ };
13314
+
13315
+ const recordGalleryVisit = () => {
13316
+ const visit = currentGalleryVisit();
13317
+
13318
+ if (!visit) {
13319
+ return;
13320
+ }
13321
+
13322
+ const prev = recentGalls.find(item => recentKeyOf(item) === recentKeyOf(visit));
13323
+
13324
+ if (prev && recentGalls[0] === prev && Date.now() - prev.at < 60000 && prev.n === visit.n) {
13325
+ return;
13326
+ }
13327
+
13328
+ recentGalls = [visit, ...recentGalls.filter(item => item !== prev)];
13329
+ saveRecent();
13330
+ };
13331
+
13332
+ const initRecentEarly = () => {
13333
+ importNativeRecent();
13334
+ exportNativeRecent();
13335
+ };
13336
+
13337
+ const initRecent = () => {
13338
+ importNativeRecent();
13339
+ recordGalleryVisit();
13340
+ exportNativeRecent();
13341
+
13342
+ const follow = () => {
13343
+ if (document.visibilityState === 'visible' && importNativeRecent()) {
13344
+ exportNativeRecent();
13345
+ }
13346
+ };
13347
+
13348
+ setTimeout(follow, 3000);
13349
+ document.addEventListener('visibilitychange', follow);
13350
+ };
13351
+
13352
+ const collectRecentForSync = () => recentGalls.map(item => ({ ...item }));
13353
+
13354
+ const mergeRecent = (local = [], base, remote) => {
13355
+ if (!Array.isArray(remote)) {
13356
+ return tidyRecent(local || []);
13357
+ }
13358
+
13359
+ const pick = new Map();
13360
+
13361
+ [...(remote || []), ...(local || [])].map(normalizeRecent).filter(Boolean).forEach(item => {
13362
+ const key = recentKeyOf(item);
13363
+ const prev = pick.get(key);
13364
+
13365
+ if (!prev || item.at > prev.at) {
13366
+ pick.set(key, prev && !item.n ? { ...item, n: prev.n } : item);
13367
+ }
13368
+ });
13369
+
13370
+ const keys = mergeList(
13371
+ (local || []).map(item => recentKeyOf(item)),
13372
+ Array.isArray(base) ? base.map(item => recentKeyOf(item)) : null,
13373
+ remote.map(item => recentKeyOf(item))
13374
+ );
13375
+
13376
+ return tidyRecent(keys.map(key => pick.get(key)).filter(Boolean));
13377
+ };
13378
+
13379
+ const applyRecentFromSync = list => {
13380
+ if (!Array.isArray(list)) {
13381
+ return false;
13382
+ }
13383
+
13384
+ const next = tidyRecent(list);
13385
+
13386
+ if (JSON.stringify(next) === JSON.stringify(recentGalls)) {
13387
+ return false;
13388
+ }
13389
+
13390
+ recentGalls = next;
13391
+ writeJson(STORAGE.recent, recentGalls);
13392
+ exportNativeRecent();
13393
+
13394
+ return true;
13395
+ };
13396
+
13397
+ // =========================================================
13398
+
13399
+ // =========================================================
13400
+
13401
+ const ALARM_TAGS = { R: '댓글', RR: '답글', P: '펌', hit: '힛갤', B: '실베', EC: '동영상', G: '방명록' };
13402
+ const ALARM_POLL_GAP = 3 * 60 * 1000;
13403
+ const ALARM_BUBBLE_MAX = 3;
13404
+ const ALARM_BUBBLE_FRESH = 10 * 60 * 1000;
13405
+
13406
+ const siteUserId = () => cleanText(document.getElementById('user_id')?.value || '');
13407
+
13408
+ const alarmStorageKeys = () => {
13409
+ try {
13410
+ if (IS_MOBILE_SITE) {
13411
+ const id = siteUserId();
13412
+
13413
+ return id ? [`alarm_notification_${id}`] : [];
13414
+ }
13415
+
13416
+ if (!/^gall\.dcinside\.com$/i.test(location.hostname)) {
13417
+ return [];
13418
+ }
13419
+
13420
+ const keys = [];
13421
+
13422
+ for (let i = 0; i < localStorage.length; i++) {
13423
+ const key = localStorage.key(i);
13424
+
13425
+ if (/^alarm_list/.test(key || '')) {
13426
+ keys.push(key);
13427
+ }
13428
+ }
13429
+
13430
+ return keys;
13431
+ } catch {
13432
+ return [];
13433
+ }
13434
+ };
13435
+
13436
+ const readAlarmRaw = () => alarmStorageKeys().map(key => {
13437
+ try {
13438
+ return localStorage.getItem(key) || '';
13439
+ } catch {
13440
+ return '';
13441
+ }
13442
+ }).join('\u0001');
13443
+
13444
+ const mobileAlarmHref = item => {
13445
+ const raw = String(item.id || '');
13446
+ const no = String(item.no || '').replace(/\D/g, '');
13447
+
13448
+ if (item.type === 'B' || raw === 'dcbest') {
13449
+ return no ? `https://m.dcinside.com/board/dcbest/${no}` : '';
13450
+ }
13451
+
13452
+ if (item.type === 'G') {
13453
+ const me = siteUserId();
13454
+
13455
+ return me ? `https://m.dcinside.com/gallog/${encodeURIComponent(me)}/guestbook` : '';
13456
+ }
13457
+
13458
+ const base = raw.startsWith('mi$') ? 'mini' : raw.startsWith('pr$') ? 'person' : 'board';
13459
+ const id = raw.replace(/^(mi|pr)\$/, '');
13460
+
13461
+ if (!id || !no || id === 'hit') {
13462
+ return id === 'hit' && no ? `https://m.dcinside.com/board/hit/${no}` : '';
13463
+ }
13464
+
13465
+ const comment = String(item.comment_no || '').replace(/\D/g, '');
13466
+
13467
+ return `https://m.dcinside.com/${base}/${encodeURIComponent(id)}/${no}${comment ? `?comment=${comment}` : ''}`;
13468
+ };
13469
+
13470
+ const readSiteAlarms = () => {
13471
+ const out = [];
13472
+
13473
+ alarmStorageKeys().forEach(key => {
13474
+ let list = [];
13475
+
13476
+ try {
13477
+ list = JSON.parse(localStorage.getItem(key) || '[]');
13478
+ } catch {
13479
+ list = [];
13480
+ }
13481
+
13482
+ (Array.isArray(list) ? list : []).forEach(item => {
13483
+ if (!item || typeof item !== 'object') {
13484
+ return;
13485
+ }
13486
+
13487
+ if (IS_MOBILE_SITE) {
13488
+ const at = Number(item.reg_date) || 0;
13489
+ const tag = item.id === 'hit' ? ALARM_TAGS.hit : ALARM_TAGS[item.type] || '알림';
13490
+
13491
+ out.push({
13492
+ key: `${item.type}:${item.id}:${item.no}:${item.comment_no || ''}:${at}`,
13493
+ tag,
13494
+ text: cleanText(item.subject).slice(0, 140) || '(내용 없음)',
13495
+ href: mobileAlarmHref(item),
13496
+ at
13497
+ });
13498
+ } else {
13499
+ const at = (Number(item.time) || 0) * 1000;
13500
+ const text = cleanText(`${item.prefix || ''} ${item.content || ''}`).replace(/<[^>]*>/g, '');
13501
+ const tagMatch = /^\[([^\]]{1,10})\]/.exec(cleanText(item.prefix || ''));
13502
+ let href = String(item.link || '');
13503
+
13504
+ if (href && !/^https?:\/\//.test(href)) {
13505
+ href = href.startsWith('/') ? `https://gall.dcinside.com${href}` : '';
13506
+ }
13507
+
13508
+ out.push({
13509
+ key: `${item.idx || ''}:${item.link || ''}:${at}`,
13510
+ tag: item.type === 'manager' ? '관리' : tagMatch ? tagMatch[1] : '알림',
13511
+ text: text.slice(0, 140) || '(내용 없음)',
13512
+ href: /^https:\/\/[a-z0-9.-]*dcinside\.com\//i.test(href) ? href : '',
13513
+ at,
13514
+ idx: item.idx
13515
+ });
13516
+ }
13517
+ });
13518
+ });
13519
+
13520
+ const seen = new Set();
13521
+
13522
+ return out
13523
+ .filter(item => !seen.has(item.key) && seen.add(item.key))
13524
+ .sort((a, b) => b.at - a.at)
13525
+ .slice(0, 30);
13526
+ };
13527
+
13528
+ const readAlarmMarks = () => {
13529
+ const marks = readObject(STORAGE.alarmSeen);
13530
+
13531
+ return { seen: Number(marks.seen) || 0, shown: Number(marks.shown) || 0 };
13532
+ };
13533
+
13534
+ const writeAlarmMarks = patch => writeJson(STORAGE.alarmSeen, { ...readAlarmMarks(), ...patch });
13535
+
13536
+ const unreadAlarmCount = () => {
13537
+ const { seen } = readAlarmMarks();
13538
+
13539
+ return readSiteAlarms().filter(item => item.at > seen).length;
13540
+ };
13541
+
13542
+ const paintAlarmBadge = () => {
13543
+ const fab = document.getElementById('dc-custom-fab');
13544
+
13545
+ if (!fab) {
13546
+ return;
13547
+ }
13548
+
13549
+ const count = settings.alarmBubble === false ? 0 : unreadAlarmCount();
13550
+
13551
+ if (count) {
13552
+ fab.dataset.dcAlarm = count > 9 ? '9+' : String(count);
13553
+ } else {
13554
+ delete fab.dataset.dcAlarm;
13555
+ }
13556
+ };
13557
+
13558
+ const markAlarmsSeen = () => {
13559
+ const latest = readSiteAlarms()[0];
13560
+
13561
+ writeAlarmMarks({ seen: Math.max(Date.now(), latest ? latest.at : 0) });
13562
+ paintAlarmBadge();
13563
+ };
13564
+
13565
+ let bubbleBox = null;
13566
+
13567
+ const placeBubbleBox = () => {
13568
+ if (!bubbleBox) {
13569
+ return;
13570
+ }
13571
+
13572
+ const fab = document.getElementById('dc-custom-fab');
13573
+ const rect = fab && getComputedStyle(fab).display !== 'none' ? fab.getBoundingClientRect() : null;
13574
+ const vw = window.innerWidth;
13575
+ const vh = window.innerHeight;
13576
+ let bottom = 144;
13577
+
13578
+ if (rect && rect.width) {
13579
+ const onRight = rect.left + rect.width / 2 > vw / 2;
13580
+
13581
+ bubbleBox.style.left = onRight ? '' : `${Math.max(8, rect.left)}px`;
13582
+ bubbleBox.style.right = onRight ? `${Math.max(8, vw - rect.right)}px` : '';
13583
+ bottom = Math.max(8, vh - rect.top + 10);
13584
+ bubbleBox.classList.toggle('dc-left', !onRight);
13585
+ } else {
13586
+ bubbleBox.style.left = '';
13587
+ bubbleBox.style.right = '15px';
13588
+ bubbleBox.classList.remove('dc-left');
13589
+ }
13590
+
13591
+ const toast = document.getElementById('dc-toast');
13592
+
13593
+ if (toast && toast.classList.contains('show')) {
13594
+ const t = toast.getBoundingClientRect();
13595
+ const box = bubbleBox.getBoundingClientRect();
13596
+
13597
+ if (t.width && t.left < box.right && t.right > box.left) {
13598
+ bottom = Math.max(bottom, vh - t.top + 8);
13599
+ }
13600
+ }
13601
+
13602
+ bubbleBox.style.bottom = `${Math.round(bottom)}px`;
13603
+ };
13604
+
13605
+ toastListeners.push(() => placeBubbleBox());
13606
+
13607
+ const ensureBubbleBox = () => {
13608
+ if (bubbleBox && bubbleBox.isConnected) {
13609
+ return bubbleBox;
13610
+ }
13611
+
13612
+ bubbleBox = h('div', 'dc-ui');
13613
+ bubbleBox.id = 'dc-bubbles';
13614
+ bubbleBox.setAttribute('role', 'log');
13615
+ bubbleBox.setAttribute('aria-live', 'polite');
13616
+ document.body.appendChild(bubbleBox);
13617
+
13618
+ return bubbleBox;
13619
+ };
13620
+
13621
+ const dismissBubble = (bubble, direction = 0) => {
13622
+ if (!bubble || bubble.classList.contains('dc-out')) {
13623
+ return;
13624
+ }
13625
+
13626
+ clearTimeout(bubble._dcTimer);
13627
+ bubble.classList.add('dc-out');
13628
+
13629
+ if (direction) {
13630
+ bubble.style.transform = `translateX(${direction * 120}%)`;
13631
+ }
13632
+
13633
+ setTimeout(() => bubble.remove(), 260);
13634
+ };
13635
+
13636
+ const openAlarm = item => {
13637
+ if (!IS_MOBILE_SITE && item.idx && typeof window.alarmReadOk === 'function') {
13638
+ try {
13639
+ window.alarmReadOk(item.idx);
13640
+ } catch {
13641
+ }
13642
+ }
13643
+
13644
+ markAlarmsSeen();
13645
+
13646
+ if (item.href) {
13647
+ location.href = item.href;
13648
+ }
13649
+ };
13650
+
13651
+ const bindBubbleSwipe = bubble => {
13652
+ let startX = 0;
13653
+ let dx = 0;
13654
+ let dragging = false;
13655
+
13656
+ bubble.addEventListener('pointerdown', event => {
13657
+ startX = event.clientX;
13658
+ dx = 0;
13659
+ dragging = true;
13660
+ clearTimeout(bubble._dcTimer);
13661
+ });
13662
+ bubble.addEventListener('pointermove', event => {
13663
+ if (!dragging) {
13664
+ return;
13665
+ }
13666
+
13667
+ dx = event.clientX - startX;
13668
+
13669
+ if (Math.abs(dx) > 6) {
13670
+ bubble.style.transition = 'none';
13671
+ bubble.style.transform = `translateX(${dx}px)`;
13672
+ bubble.style.opacity = String(Math.max(0.2, 1 - Math.abs(dx) / 220));
13673
+ }
13674
+ });
13675
+
13676
+ const end = () => {
13677
+ if (!dragging) {
13678
+ return;
13679
+ }
13680
+
13681
+ dragging = false;
13682
+ bubble.style.transition = '';
13683
+
13684
+ if (Math.abs(dx) > 70) {
13685
+ bubble._dcSwiped = true;
13686
+ dismissBubble(bubble, Math.sign(dx));
13687
+ } else {
13688
+ bubble.style.transform = '';
13689
+ bubble.style.opacity = '';
13690
+ bubble._dcTimer = setTimeout(() => dismissBubble(bubble), 6000);
13691
+ }
13692
+ };
13693
+
13694
+ bubble.addEventListener('pointerup', end);
13695
+ bubble.addEventListener('pointercancel', end);
13696
+ };
13697
+
13698
+ const showAlarmBubble = (item, extra = 0) => {
13699
+ const box = ensureBubbleBox();
13700
+
13701
+ placeBubbleBox();
13702
+
13703
+ while (box.children.length >= ALARM_BUBBLE_MAX) {
13704
+ box.firstElementChild.remove();
13705
+ }
13706
+
13707
+ const bubble = h('div', 'dc-bubble');
13708
+ const top = h('div', 'dc-bubble-top');
13709
+
13710
+ top.appendChild(h('span', 'dc-bubble-tag', item.tag));
13711
+ top.appendChild(h('span', 'dc-bubble-time', extra ? `새 알림 ${extra + 1}개` : timeAgo(item.at)));
13712
+
13713
+ const close = h('button', 'dc-bubble-x', '×');
13714
+
13715
+ close.type = 'button';
13716
+ close.setAttribute('aria-label', '알림 닫기');
13717
+ close.addEventListener('click', event => {
13718
+ event.stopPropagation();
13719
+ dismissBubble(bubble);
13720
+ });
13721
+ top.appendChild(close);
13722
+
13723
+ bubble.appendChild(top);
13724
+ bubble.appendChild(h('div', 'dc-bubble-text', item.text));
13725
+
13726
+ if (extra) {
13727
+ bubble.appendChild(h('div', 'dc-bubble-more', `외 ${extra}개 · 눌러서 모두 보기`));
13728
+ }
13729
+
13730
+ bubble.addEventListener('click', () => {
13731
+ if (bubble._dcSwiped) {
13732
+ return;
13733
+ }
13734
+
13735
+ dismissBubble(bubble);
13736
+
13737
+ if (extra) {
13738
+ openBlockModal('recent');
13739
+ } else {
13740
+ openAlarm(item);
13741
+ }
13742
+ });
13743
+
13744
+ bindBubbleSwipe(bubble);
13745
+ box.appendChild(bubble);
13746
+ requestAnimationFrame(() => bubble.classList.add('dc-in'));
13747
+ bubble._dcTimer = setTimeout(() => dismissBubble(bubble), 8000);
13748
+ };
13749
+
13750
+ let alarmRawMemo = null;
13751
+ let alarmWatchTimer = null;
13752
+
13753
+ const checkSiteAlarms = () => {
13754
+ if (settings.alarmBubble === false || document.visibilityState === 'hidden') {
13755
+ return;
13756
+ }
13757
+
13758
+ const raw = readAlarmRaw();
13759
+
13760
+ if (raw === alarmRawMemo) {
13761
+ return;
13762
+ }
13763
+
13764
+ alarmRawMemo = raw;
13765
+
13766
+ const list = readSiteAlarms();
13767
+ const marks = readAlarmMarks();
13768
+ const latest = list.length ? list[0].at : 0;
13769
+
13770
+ if (!marks.shown) {
13771
+ writeAlarmMarks({ shown: Math.max(latest, Date.now() - ALARM_BUBBLE_FRESH), seen: marks.seen || latest });
13772
+ paintAlarmBadge();
13773
+ return;
13774
+ }
13775
+
13776
+ const fresh = list.filter(item => item.at > marks.shown && Date.now() - item.at < ALARM_BUBBLE_FRESH);
13777
+
13778
+ if (latest > marks.shown) {
13779
+ writeAlarmMarks({ shown: latest });
13780
+ }
13781
+
13782
+ if (fresh.length) {
13783
+ showAlarmBubble(fresh[0], fresh.length > ALARM_BUBBLE_MAX ? fresh.length - 1 : 0);
13784
+
13785
+ if (fresh.length > 1 && fresh.length <= ALARM_BUBBLE_MAX) {
13786
+ fresh.slice(1).reverse().forEach(item => showAlarmBubble(item));
13787
+ }
13788
+ }
13789
+
13790
+ paintAlarmBadge();
13791
+ };
13792
+
13793
+ let alarmPollAt = Date.now();
13794
+
13795
+ const siteAlarmsOff = () => {
13796
+ try {
13797
+ const list = JSON.parse(localStorage.getItem('alarm_setting') || '[]');
13798
+
13799
+ return Array.isArray(list) && list.some(item => item && item.setting === 'onoff' && item.value !== 'on');
13800
+ } catch {
13801
+ return false;
13802
+ }
13803
+ };
13804
+
13805
+ const keepSiteAlarmPolling = () => {
13806
+ if (!IS_MOBILE_SITE || settings.alarmKeepPolling === false || settings.alarmBubble === false) {
13807
+ return;
13808
+ }
13809
+
13810
+ if (document.visibilityState === 'hidden' || !siteUserId() || siteAlarmsOff() || netPausedUntil()) {
13811
+ return;
13812
+ }
13813
+
13814
+ if (Date.now() - alarmPollAt < ALARM_POLL_GAP || typeof window.alarm_notification_pop !== 'function') {
13815
+ return;
13816
+ }
13817
+
13818
+ alarmPollAt = Date.now();
13819
+
13820
+ try {
13821
+ if (typeof window.set_interval_noti === 'number' && window.set_interval_noti >= 1) {
13822
+ window.set_interval_noti = 1;
13823
+ }
13824
+
13825
+ window.alarm_notification_pop();
13826
+ } catch {
13827
+ }
13828
+ };
13829
+
13830
+ function initSiteAlarms() {
13831
+ if (alarmWatchTimer || !(IS_MOBILE_SITE || /^gall\.dcinside\.com$/i.test(location.hostname))) {
13832
+ paintAlarmBadge();
13833
+ return;
13834
+ }
13835
+
13836
+ if (!siteUserId() && !alarmStorageKeys().length) {
13837
+ paintAlarmBadge();
13838
+
13839
+ if (!initSiteAlarms.retried) {
13840
+ initSiteAlarms.retried = true;
13841
+ [3000, 10000].forEach(delay => setTimeout(() => {
13842
+ if (!alarmWatchTimer && (siteUserId() || alarmStorageKeys().length)) {
13843
+ initSiteAlarms();
13844
+ }
13845
+ }, delay));
13846
+ }
13847
+
13848
+ return;
13849
+ }
13850
+
13851
+ checkSiteAlarms();
13852
+ alarmWatchTimer = setInterval(() => {
13853
+ checkSiteAlarms();
13854
+ keepSiteAlarmPolling();
13855
+ }, 2000);
13856
+
13857
+ document.addEventListener('visibilitychange', () => {
13858
+ if (document.visibilityState === 'visible') {
13859
+ checkSiteAlarms();
13860
+ paintAlarmBadge();
13861
+ }
13862
+ });
13863
+
13864
+ window.addEventListener('storage', event => {
13865
+ if (event.key === STORAGE.alarmSeen || /^alarm_(list|notification_)/.test(event.key || '')) {
13866
+ alarmRawMemo = null;
13867
+ checkSiteAlarms();
13868
+ paintAlarmBadge();
13869
+ }
13870
+ });
13871
+
13872
+ window.addEventListener('resize', placeBubbleBox, { passive: true });
13873
+ };
13874
+
13875
+ // =========================================================
13876
+
13877
+ // =========================================================
13878
+
13879
+ const renderRecentView = area => {
13880
+ const alarms = siteUserId() || alarmStorageKeys().length ? readSiteAlarms() : null;
13881
+
13882
+ if (alarms) {
13883
+ const { seen } = readAlarmMarks();
13884
+ const unread = alarms.filter(item => item.at > seen).length;
13885
+
13886
+ area.appendChild(makeSectionTitle(`🔔 알림${unread ? ` · 새 알림 ${unread}개` : ''}`));
13887
+
13888
+ if (!alarms.length) {
13889
+ area.appendChild(makeEmpty('받은 알림이 없습니다. 댓글·답글 등 디시 알림이 오면 여기와 플로팅 버튼 곁 말풍선으로 보입니다.'));
13890
+ } else {
13891
+ alarms.slice(0, 15).forEach(item => {
13892
+ const row = makeRow({
13893
+ title: item.text,
13894
+ chip: item.tag,
13895
+ chipClass: item.at > seen ? 'on' : '',
13896
+ meta: timeAgo(item.at),
13897
+ actions: item.href ? [{ label: '열기', href: item.href }] : []
13898
+ });
13899
+
13900
+ if (item.at > seen) {
13901
+ row.classList.add('dc-row-new');
13902
+ }
13903
+
13904
+ if (item.href) {
13905
+ row.querySelector('a.dc-btn')?.addEventListener('click', () => openAlarm({ ...item, href: '' }));
13906
+ }
13907
+
13908
+ area.appendChild(row);
13909
+ });
13910
+
13911
+ if (unread) {
13912
+ area.appendChild(makeButton('✓ 모두 읽음', 'wide', () => {
13913
+ markAlarmsSeen();
13914
+ rerenderModal();
13915
+ }));
13916
+ }
13917
+ }
13918
+ } else if (IS_MOBILE_SITE || /^gall\.dcinside\.com$/i.test(location.hostname)) {
13919
+ area.appendChild(makeSectionTitle('🔔 알림'));
13920
+ area.appendChild(makeNote('디시에 로그인하면 댓글·답글 알림을 여기와 플로팅 말풍선으로 볼 수 있습니다.'));
13921
+ }
13922
+
13923
+ area.appendChild(makeSectionTitle(`🕘 최근 방문 갤러리 · ${recentGalls.length}개`));
13924
+
13925
+ const syncOn = typeof syncReady === 'function' && syncReady() && !readSyncConf().skip.includes('recent');
13926
+
13927
+ area.appendChild(makeNote(syncOn
13928
+ ? '동기화로 모든 기기의 방문 기록이 합쳐집니다. 디시 화면의 최근 방문 목록에도 함께 보입니다.'
13929
+ : '디시의 최근 방문 목록과 같은 내용입니다. 동기화를 켜면 다른 기기의 방문도 합쳐집니다.'));
13930
+
13931
+ if (!recentGalls.length) {
13932
+ area.appendChild(makeEmpty('아직 방문한 갤러리가 없습니다.'));
13933
+ return;
13934
+ }
13935
+
13936
+ const filterRow = makeFilterRow(recentGalls.length);
13937
+
13938
+ if (filterRow) {
13939
+ area.appendChild(filterRow);
13940
+ }
13941
+
13942
+ recentGalls.forEach(item => {
13943
+ area.appendChild(makeRow({
13944
+ title: item.n || item.id,
13945
+ chip: RECENT_KIND_LABEL[item.k] || '',
13946
+ meta: [item.id, item.at ? timeAgo(item.at) : ''].filter(Boolean).join(' · '),
13947
+ actions: [
13948
+ { label: '열기', href: recentUrl(item) },
13949
+ {
13950
+ label: '지우기',
13951
+ onClick: () => {
13952
+ recentGalls = recentGalls.filter(other => other !== item);
13953
+ saveRecent();
13954
+ exportNativeRecent();
13955
+ rerenderModal();
13956
+ }
13957
+ }
13958
+ ]
13959
+ }));
13960
+ });
13961
+
13962
+ area.appendChild(makeConfirmButton('최근 방문 모두 지우기', '최근 방문 갤러리 목록을 모두 지울까요? (동기화 중이면 다른 기기에서도 지워집니다)', () => {
13963
+ recentGalls = [];
13964
+ saveRecent();
13965
+ exportNativeRecent();
13966
+ rerenderModal();
13967
+ }));
13968
+ };
13969
+
13970
+ (() => {
13971
+ const settingsView = MODAL_VIEWS.settings;
13972
+
13973
+ delete MODAL_VIEWS.settings;
13974
+ MODAL_VIEWS.recent = {
13975
+ title: '알림 · 최근 갤러리',
13976
+ tab: '🕘 최근',
13977
+ render: area => {
13978
+ document.querySelectorAll('#dc-bubbles .dc-bubble').forEach(bubble => dismissBubble(bubble));
13979
+ renderRecentView(area);
13980
+
13981
+ setTimeout(markAlarmsSeen, 600);
13982
+ }
13983
+ };
13984
+ MODAL_VIEWS.settings = settingsView;
13985
+ })();
13986
+
13987
+ initRecentEarly();
13988
+
13989
+ // =========================================================
13990
+
13991
+ // =========================================================
13992
+
13993
+ const KEY_HELP = 'J/K 다음·이전 글 · Enter 열기 · S 스크랩 · / 검색 · B 차단 관리';
13994
+
13995
+ const typingTarget = el =>
13996
+ !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || !!el.closest?.('[contenteditable="true"]'));
13997
+
13998
+ const visibleListRows = () => {
13999
+ const listEl = pickListElement(document);
14000
+
14001
+ if (!listEl) {
14002
+ return [];
14003
+ }
14004
+
14005
+ return realListItems(listEl).filter(row => row.getClientRects().length && listItemKey(row) && !row.closest('.dc-ui'));
14006
+ };
14007
+
14008
+ let keyCursor = null;
14009
+
14010
+ const moveKeyCursor = step => {
14011
+ const rows = visibleListRows();
14012
+
14013
+ if (!rows.length) {
14014
+ return false;
14015
+ }
14016
+
14017
+ const here = currentPostKey();
14018
+ let index = keyCursor ? rows.indexOf(keyCursor) : -1;
14019
+
14020
+ if (currentPostInfo()) {
14021
+ const at = rows.findIndex(row => listItemKey(row) === here);
14022
+ const target = at >= 0 ? rows[at + step] : step > 0 ? rows[0] : null;
14023
+ const link = target && target.querySelector(POST_LINK_SELECTOR);
14024
+
14025
+ if (link) {
14026
+ location.href = link.href;
14027
+ return true;
14028
+ }
14029
+
14030
+ showToast(step > 0 ? '아래에 더 볼 글이 없습니다' : '첫 글입니다');
14031
+ return true;
14032
+ }
14033
+
14034
+ if (index < 0) {
14035
+ index = rows.findIndex(row => row.getBoundingClientRect().bottom > 60);
14036
+ index = Math.max(0, step > 0 ? index : index);
14037
+ } else {
14038
+ index = Math.min(rows.length - 1, Math.max(0, index + step));
14039
+ }
14040
+
14041
+ keyCursor?.classList.remove('dc-key-cursor');
14042
+ keyCursor = rows[index];
14043
+ keyCursor.classList.add('dc-key-cursor');
14044
+ keyCursor.scrollIntoView({ block: 'nearest' });
14045
+
14046
+ return true;
14047
+ };
14048
+
14049
+ const initKeyboardShortcuts = () => {
14050
+ document.addEventListener('keydown', event => {
14051
+ if (settings.keyboardShortcuts === false || event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) {
14052
+ return;
14053
+ }
14054
+
14055
+ if (typingTarget(event.target) || typingTarget(document.activeElement)) {
14056
+ return;
14057
+ }
14058
+
14059
+ if (document.getElementById('dc-custom-modal')?.classList.contains('show')) {
14060
+ return;
14061
+ }
14062
+
14063
+ const key = event.key;
14064
+ let handled = false;
14065
+
14066
+ if (key === 'j' || key === 'J') {
14067
+ handled = moveKeyCursor(1);
14068
+ } else if (key === 'k' || key === 'K') {
14069
+ handled = moveKeyCursor(-1);
14070
+ } else if ((key === 'Enter' || key === 'o' || key === 'O') && keyCursor && keyCursor.isConnected) {
14071
+ const link = keyCursor.querySelector(POST_LINK_SELECTOR);
14072
+
14073
+ if (link) {
14074
+ location.href = link.href;
14075
+ handled = true;
14076
+ }
14077
+ } else if (key === 's' || key === 'S') {
14078
+ const info = currentPostInfo();
14079
+
14080
+ if (info) {
14081
+ toggleScrap(info);
14082
+ handled = true;
14083
+ }
14084
+ } else if (key === '/') {
14085
+ const box = document.querySelector('.in_keyword, #preSWord, input.js-keyword, input[name="search_keyword"], .top_search input[type="text"]');
14086
+
14087
+ if (box) {
14088
+ box.focus();
14089
+ handled = true;
14090
+ }
14091
+ } else if (key === 'b' || key === 'B') {
14092
+ openBlockModal(document.getElementById('dc-custom-fab')?.dataset.dcAlarm ? 'recent' : 'overview');
14093
+ handled = true;
14094
+ } else if (key === '?') {
14095
+ showToast(`⌨️ ${KEY_HELP}`, { duration: 6000 });
14096
+ handled = true;
14097
+ }
14098
+
14099
+ if (handled) {
14100
+ event.preventDefault();
14101
+ }
14102
+ });
14103
+ };
14104
+
14105
+ // =========================================================
14106
+
14107
+ // =========================================================
14108
+
14109
+ const SEARCH_HISTORY_MAX = 30;
14110
+
14111
+ const readSiteSearchHistory = () => {
14112
+ if (!IS_MOBILE_SITE) {
14113
+ return null;
14114
+ }
14115
+
14116
+ try {
14117
+ const list = JSON.parse(localStorage.getItem('searchHistory') || '[]');
14118
+
14119
+ return Array.isArray(list) ? list.filter(item => typeof item === 'string' && item.trim()).map(item => item.slice(0, 60)) : [];
14120
+ } catch {
14121
+ return [];
14122
+ }
14123
+ };
14124
+
14125
+ const collectSearchForSync = () => {
14126
+ const site = readSiteSearchHistory();
14127
+ const list = site || readArray(STORAGE.searchHistory).filter(item => typeof item === 'string');
14128
+
14129
+ return [...new Set(list)].slice(-SEARCH_HISTORY_MAX);
14130
+ };
14131
+
14132
+ const mergeSearchHistory = (local = [], base, remote) => {
14133
+ if (!Array.isArray(remote)) {
14134
+ return [...new Set(local || [])].slice(-SEARCH_HISTORY_MAX);
14135
+ }
14136
+
14137
+ return mergeList(local || [], Array.isArray(base) ? base : null, remote).slice(-SEARCH_HISTORY_MAX);
14138
+ };
14139
+
14140
+ const applySearchFromSync = list => {
14141
+ if (!Array.isArray(list)) {
14142
+ return false;
14143
+ }
14144
+
14145
+ const next = [...new Set(list.filter(item => typeof item === 'string'))].slice(-SEARCH_HISTORY_MAX);
14146
+
14147
+ if (JSON.stringify(next) === JSON.stringify(collectSearchForSync())) {
14148
+ return false;
14149
+ }
14150
+
14151
+ writeJson(STORAGE.searchHistory, next);
14152
+
14153
+ if (IS_MOBILE_SITE) {
14154
+ try {
14155
+ localStorage.setItem('searchHistory', JSON.stringify(next));
14156
+ } catch {
14157
+ }
14158
+ }
14159
+
14160
+ return true;
14161
+ };
12578
14162
  // =========================================================
12579
14163
  // 14. 플로팅 버튼
12580
14164
  // =========================================================
@@ -12892,7 +14476,7 @@
12892
14476
  return;
12893
14477
  }
12894
14478
 
12895
- openBlockModal();
14479
+ openBlockModal(fab.dataset.dcAlarm ? 'recent' : 'overview');
12896
14480
  });
12897
14481
 
12898
14482
  wake();
@@ -13533,6 +15117,7 @@
13533
15117
 
13534
15118
  state.strikes = Date.now() - state.lastStrike < 60 * 60 * 1000 ? state.strikes + 1 : 1;
13535
15119
  state.lastStrike = Date.now();
15120
+ state.src = 'page';
13536
15121
  state.until = Date.now() + Math.min(NET_COOLDOWN_MAX, NET_COOLDOWN * 2 ** (state.strikes - 1));
13537
15122
  writeNetState(state);
13538
15123
  }
@@ -13559,6 +15144,9 @@
13559
15144
  initScrap();
13560
15145
  initScrollIndicator();
13561
15146
  initSync();
15147
+ initRecent();
15148
+ initSiteAlarms();
15149
+ initKeyboardShortcuts();
13562
15150
  restoreListSnapshot();
13563
15151
  processElements();
13564
15152