@kangsicj/dcinsideenhancer 1.0.2 → 1.1.0

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.0
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.0';
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,10 @@
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
+ alarmSeen: 'dc_alarm_marks',
107
111
  seenVersion: 'dc_seen_version'
108
112
  };
109
113
 
@@ -212,6 +216,12 @@
212
216
  hideHomeNewGall: true,
213
217
  hideHomeMedia: true,
214
218
  hideFooterInfo: true,
219
+ hideAdSlots: true,
220
+ hideNewsRows: true,
221
+ hideCommentAds: true,
222
+ hideSilbeAlarm: true,
223
+ alarmBubble: true,
224
+ alarmKeepPolling: true,
215
225
  pcFitWidth: true,
216
226
  autoHideScrollbar: true,
217
227
  scrapIntercept: true,
@@ -395,57 +405,77 @@
395
405
 
396
406
  html.dc-pc-fit { --dc-fit-gap: clamp(12px, 2vw, 32px); }
397
407
  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 {
408
+ html.dc-pc-fit .dcwrap { min-width: 0 !important; }
409
+ html.dc-pc-fit .dcwrap .dchead,
410
+ html.dc-pc-fit .dcwrap .gnb,
411
+ html.dc-pc-fit .dcwrap .gnb_depth2_list,
412
+ html.dc-pc-fit .dcwrap .info_policy,
413
+ html.dc-pc-fit .dcwrap .copyright,
414
+ html.dc-pc-fit .dcwrap .visit_bookmark {
405
415
  width: auto !important;
406
416
  max-width: none !important;
407
417
  margin-left: var(--dc-fit-gap) !important;
408
418
  margin-right: var(--dc-fit-gap) !important;
409
419
  }
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; }
420
+
421
+ html.dc-pc-fit .dcwrap .gnb_bar { min-width: 0 !important; width: auto !important; margin: 0 !important; }
422
+ html.dc-pc-fit .dcwrap .gnb_list,
423
+ html.dc-pc-fit .dcwrap .dc_all { width: auto !important; }
413
424
  html.dc-pc-fit .dcwrap .wrap_inner { width: auto !important; }
414
- html.dc-pc-fit .dcwrap.width1160 #container {
425
+ html.dc-pc-fit .dcwrap #container {
426
+ width: auto !important;
427
+ margin-left: var(--dc-fit-gap) !important;
428
+ margin-right: var(--dc-fit-gap) !important;
429
+ }
430
+ html.dc-pc-fit .dcwrap #container:has(> .left_content),
431
+ html.dc-pc-fit .dcwrap #container > .main_content:has(> .left_content) {
415
432
  display: flex;
416
433
  flex-wrap: wrap;
417
434
  align-items: flex-start;
418
435
  gap: 20px;
419
436
  width: auto !important;
420
- margin: 20px var(--dc-fit-gap) 0 !important;
421
437
  }
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) {
438
+ html.dc-pc-fit .dcwrap #container:has(> .left_content)::before,
439
+ html.dc-pc-fit .dcwrap #container:has(> .left_content)::after,
440
+ html.dc-pc-fit .dcwrap .main_content:has(> .left_content)::before,
441
+ html.dc-pc-fit .dcwrap .main_content:has(> .left_content)::after { display: none !important; }
442
+ html.dc-pc-fit .dcwrap #container:has(> .left_content) > :not(.left_content):not(.right_content),
443
+ html.dc-pc-fit .dcwrap .main_content:has(> .left_content) > :not(.left_content):not(.right_content) {
425
444
  flex: 0 0 100%;
426
445
  float: none !important;
427
446
  }
428
- html.dc-pc-fit .dcwrap.width1160 #container > .left_content {
447
+ html.dc-pc-fit .dcwrap :is(#container, .main_content) > .left_content {
429
448
  float: none !important;
430
449
  flex: 1 1 0;
431
450
  width: auto !important;
432
451
  min-width: 0;
433
452
  }
434
- html.dc-pc-fit .dcwrap.width1160 #container > .right_content {
453
+ html.dc-pc-fit .dcwrap :is(#container, .main_content) > .right_content {
435
454
  float: none !important;
436
455
  flex: 0 0 300px;
456
+ margin-left: 0 !important;
437
457
  }
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; }
458
+ html.dc-pc-fit .dcwrap .issuebox,
459
+ html.dc-pc-fit .dcwrap .newvisit_history,
460
+ html.dc-pc-fit .dcwrap .trendalign { width: auto !important; }
461
+ html.dc-pc-fit .dcwrap .list_array_option { width: 100% !important; }
442
462
  html.dc-pc-fit .dcwrap .stickyunit { display: none !important; }
443
463
  html.dc-pc-fit .dcwrap .darkmodebox .pop_tipbox { left: auto !important; right: 0 !important; }
444
464
 
445
465
  @media (max-width: 1240px) {
446
- html.dc-pc-fit .dcwrap.width1160 #container > .right_content { display: none !important; }
466
+ html.dc-pc-fit .dcwrap :is(#container, .main_content) > .right_content { display: none !important; }
447
467
  }
448
468
 
469
+ html.dc-footer-info-off .dcwrap footer.dcfoot,
470
+ html.dc-footer-info-off .dcwrap #container > .ad_bottom_list { display: none !important; }
471
+
472
+ 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),
473
+ html.dc-ad-slots-off :is(.adv-group, li.adv-inner, section.grid.clkhigh, #home-ad, .kakao_ad_area) { display: none !important; }
474
+ 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; }
475
+ 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; }
476
+ html.dc-silbe-alarm-off :is(.alarm-popup.silbe-alarm, .rtbAlarmPopup) { display: none !important; }
477
+ html.dc-alarm-bubble :is(.alarm-popup.alarm-default, .alarmPopup) { display: none !important; }
478
+
449
479
  html.dc-top-concept-off:not(.dc-peek) .detail-top-concept,
450
480
  html.dc-top-concept-off:not(.dc-peek) .con-child-box#recom_box2,
451
481
  html:not(.dc-peek) .detail-top-lst.dc-top-empty {
@@ -939,7 +969,7 @@
939
969
 
940
970
  #dc-custom-modal .dc-modal-tabs {
941
971
  display: flex;
942
- gap: 4px;
972
+ gap: 2px;
943
973
  margin: 0;
944
974
  padding: 3px;
945
975
  border-radius: 12px;
@@ -954,8 +984,9 @@
954
984
  border-radius: 9px;
955
985
  background: transparent;
956
986
  color: var(--dc-text-dim);
957
- font-size: 12.5px;
987
+ font-size: 12px;
958
988
  font-weight: 700;
989
+ letter-spacing: -.3px;
959
990
  white-space: nowrap;
960
991
  cursor: pointer;
961
992
  }
@@ -1678,6 +1709,127 @@
1678
1709
 
1679
1710
  @keyframes dc-pop { from { transform: scale(0); } to { transform: scale(1); } }
1680
1711
 
1712
+ #dc-custom-fab[data-dc-alarm]::before {
1713
+ content: attr(data-dc-alarm);
1714
+ position: absolute;
1715
+ top: -6px;
1716
+ left: -6px;
1717
+ min-width: 18px;
1718
+ height: 18px;
1719
+ padding: 0 5px;
1720
+ box-sizing: border-box;
1721
+ border: 2px solid var(--dc-surface);
1722
+ border-radius: 9px;
1723
+ background: var(--dc-danger);
1724
+ color: #fff;
1725
+ font-size: 10.5px;
1726
+ font-weight: 800;
1727
+ line-height: 14px;
1728
+ text-align: center;
1729
+ pointer-events: none;
1730
+ animation: dc-pop .35s ease-out;
1731
+ }
1732
+
1733
+ #dc-custom-fab[data-dc-alarm] { opacity: 1; }
1734
+
1735
+ #dc-bubbles {
1736
+ position: fixed;
1737
+ right: 15px;
1738
+ bottom: calc(144px + env(safe-area-inset-bottom));
1739
+ z-index: 99998;
1740
+ display: flex;
1741
+ flex-direction: column;
1742
+ align-items: flex-end;
1743
+ gap: 8px;
1744
+ width: min(320px, calc(100vw - 24px));
1745
+ pointer-events: none;
1746
+ }
1747
+
1748
+ #dc-bubbles.dc-left { align-items: flex-start; }
1749
+
1750
+ #dc-bubbles .dc-bubble {
1751
+ position: relative;
1752
+ box-sizing: border-box;
1753
+ width: 100%;
1754
+ padding: 10px 12px 11px;
1755
+ border: 1px solid var(--dc-border);
1756
+ border-radius: 16px 16px 6px 16px;
1757
+ background: var(--dc-surface);
1758
+ color: var(--dc-text);
1759
+ box-shadow: var(--dc-shadow);
1760
+ font-size: 13px;
1761
+ line-height: 1.4;
1762
+ cursor: pointer;
1763
+ pointer-events: auto;
1764
+ touch-action: pan-y;
1765
+ user-select: none;
1766
+ -webkit-user-select: none;
1767
+ opacity: 0;
1768
+ transform: translateY(10px) scale(.96);
1769
+ transition: opacity .22s ease, transform .22s ease;
1770
+ }
1771
+
1772
+ #dc-bubbles.dc-left .dc-bubble { border-radius: 16px 16px 16px 6px; }
1773
+ #dc-bubbles .dc-bubble.dc-in { opacity: 1; transform: none; }
1774
+ #dc-bubbles .dc-bubble.dc-out { opacity: 0; transform: translateY(6px) scale(.96); pointer-events: none; }
1775
+
1776
+ #dc-bubbles .dc-bubble-top {
1777
+ display: flex;
1778
+ align-items: center;
1779
+ gap: 6px;
1780
+ margin-bottom: 3px;
1781
+ }
1782
+
1783
+ #dc-bubbles .dc-bubble-tag {
1784
+ flex: 0 0 auto;
1785
+ padding: 1px 7px;
1786
+ border-radius: 8px;
1787
+ background: var(--dc-accent-soft);
1788
+ color: var(--dc-accent-ink);
1789
+ font-size: 11px;
1790
+ font-weight: 800;
1791
+ }
1792
+
1793
+ #dc-bubbles .dc-bubble-time {
1794
+ flex: 1 1 auto;
1795
+ min-width: 0;
1796
+ color: var(--dc-text-dim);
1797
+ font-size: 11px;
1798
+ }
1799
+
1800
+ #dc-bubbles .dc-bubble-x {
1801
+ flex: 0 0 auto;
1802
+ width: 24px;
1803
+ height: 24px;
1804
+ margin: -4px -6px -4px 0;
1805
+ padding: 0;
1806
+ border: none;
1807
+ border-radius: 50%;
1808
+ background: transparent;
1809
+ color: var(--dc-text-dim);
1810
+ font-size: 17px;
1811
+ line-height: 24px;
1812
+ cursor: pointer;
1813
+ }
1814
+
1815
+ #dc-bubbles .dc-bubble-text {
1816
+ display: -webkit-box;
1817
+ overflow: hidden;
1818
+ -webkit-line-clamp: 2;
1819
+ -webkit-box-orient: vertical;
1820
+ word-break: break-all;
1821
+ font-weight: 600;
1822
+ }
1823
+
1824
+ #dc-bubbles .dc-bubble-more {
1825
+ margin-top: 4px;
1826
+ color: var(--dc-accent-ink);
1827
+ font-size: 11.5px;
1828
+ font-weight: 700;
1829
+ }
1830
+
1831
+ #dc-custom-modal .dc-row.dc-row-new .dc-row-title { font-weight: 800; }
1832
+
1681
1833
  #dc-custom-modal .dc-about {
1682
1834
  margin-top: 14px;
1683
1835
  padding: 12px 13px;
@@ -2799,6 +2951,11 @@
2799
2951
  root.classList.toggle('dc-home-media-off', settings.hideHomeMedia !== false);
2800
2952
  root.classList.toggle('dc-footer-info-off', settings.hideFooterInfo !== false);
2801
2953
  root.classList.toggle('dc-pc-fit', IS_PC_SITE && settings.pcFitWidth !== false);
2954
+ root.classList.toggle('dc-ad-slots-off', settings.hideAdSlots !== false);
2955
+ root.classList.toggle('dc-news-rows-off', settings.hideNewsRows !== false);
2956
+ root.classList.toggle('dc-comment-ads-off', settings.hideCommentAds !== false);
2957
+ root.classList.toggle('dc-silbe-alarm-off', settings.hideSilbeAlarm !== false);
2958
+ root.classList.toggle('dc-alarm-bubble', settings.alarmBubble !== false);
2802
2959
  root.classList.toggle('dc-hide-scrollbar', settings.autoHideScrollbar !== false && root.classList.contains('dc-classic-scrollbar'));
2803
2960
  };
2804
2961
 
@@ -2953,6 +3110,7 @@
2953
3110
  until: Number(stored.until) || 0,
2954
3111
  last: Number(stored.last) || 0,
2955
3112
  strikes: Number(stored.strikes) || 0,
3113
+ src: stored.src === 'page' ? 'page' : 'fetch',
2956
3114
  lastStrike: Number(stored.lastStrike) || 0,
2957
3115
  hits: Array.isArray(stored.hits) ? stored.hits.filter(t => Date.now() - t < NET_WINDOW) : [],
2958
3116
  autoHits: Array.isArray(stored.autoHits) ? stored.autoHits.filter(t => Date.now() - t < NET_WINDOW) : [],
@@ -2979,10 +3137,12 @@
2979
3137
 
2980
3138
  const netListeners = [];
2981
3139
 
2982
- const noteNetBlocked = () => {
3140
+ const noteNetBlocked = (source = 'fetch') => {
2983
3141
  const state = readNetState();
2984
3142
  const now = Date.now();
2985
3143
 
3144
+ state.src = source;
3145
+
2986
3146
  state.strikes = now - state.lastStrike < 60 * 60 * 1000 ? state.strikes + 1 : 1;
2987
3147
  state.lastStrike = now;
2988
3148
  state.until = now + Math.min(NET_COOLDOWN_MAX, NET_COOLDOWN * 2 ** (state.strikes - 1));
@@ -3024,10 +3184,21 @@
3024
3184
  }
3025
3185
  };
3026
3186
 
3187
+ const NET_PROBATION = 60 * 1000;
3188
+
3027
3189
  const noteSitePageView = () => {
3028
3190
  const state = readNetState();
3191
+ const now = Date.now();
3192
+
3193
+ state.recent.push(now);
3194
+
3195
+ if (state.until > now + NET_PROBATION && state.src !== 'page') {
3196
+ state.until = now + NET_PROBATION;
3197
+ writeNetState(state);
3198
+ scheduleNetResume();
3199
+ return;
3200
+ }
3029
3201
 
3030
- state.recent.push(Date.now());
3031
3202
  writeNetState(state);
3032
3203
  };
3033
3204
 
@@ -3041,6 +3212,8 @@
3041
3212
  let netHiddenAt = 0;
3042
3213
  let netResumedAt = 0;
3043
3214
 
3215
+ let netLeavingAt = 0;
3216
+
3044
3217
  const pageIsHidden = () => document.visibilityState === 'hidden';
3045
3218
 
3046
3219
  document.addEventListener('visibilitychange', () => {
@@ -3052,7 +3225,48 @@
3052
3225
  });
3053
3226
  window.addEventListener('pagehide', () => {
3054
3227
  netHiddenAt = Date.now();
3228
+ netLeavingAt = Date.now();
3055
3229
  });
3230
+
3231
+ const noteNetLeaving = () => {
3232
+ netLeavingAt = Date.now();
3233
+ };
3234
+
3235
+ window.addEventListener('beforeunload', noteNetLeaving);
3236
+
3237
+ document.addEventListener('click', event => {
3238
+ const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
3239
+
3240
+ if (!link || event.defaultPrevented || event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
3241
+ return;
3242
+ }
3243
+
3244
+ if (link.target && link.target !== '_self') {
3245
+ return;
3246
+ }
3247
+
3248
+ try {
3249
+ const to = new URL(link.href, location.href);
3250
+
3251
+ if (/^https?:$/.test(to.protocol) && (to.pathname !== location.pathname || to.search !== location.search)) {
3252
+ noteNetLeaving();
3253
+ }
3254
+ } catch {
3255
+ }
3256
+ }, true);
3257
+
3258
+ try {
3259
+ if (window.navigation && typeof window.navigation.addEventListener === 'function') {
3260
+ window.navigation.addEventListener('navigate', event => {
3261
+ if (!event.hashChange && !(event.destination && event.destination.sameDocument)) {
3262
+ noteNetLeaving();
3263
+ }
3264
+ });
3265
+ }
3266
+ } catch {
3267
+ }
3268
+
3269
+ const netLeavingRecently = () => netLeavingAt && Date.now() - netLeavingAt < 6000;
3056
3270
  window.addEventListener('pageshow', event => {
3057
3271
  if (event.persisted) {
3058
3272
  netResumedAt = Date.now();
@@ -3066,7 +3280,18 @@
3066
3280
  });
3067
3281
 
3068
3282
  const unreliableWindow = startedAt =>
3069
- pageIsHidden() || netHiddenAt >= startedAt || Date.now() - netResumedAt < NET_RESUME_SETTLE + 3000;
3283
+ pageIsHidden() || netHiddenAt >= startedAt || netLeavingAt >= startedAt || netLeavingRecently() ||
3284
+ Date.now() - netResumedAt < NET_RESUME_SETTLE + 3000;
3285
+
3286
+ const truncatedResponse = (response, text) => {
3287
+ try {
3288
+ const length = Number(response.headers && response.headers.get && response.headers.get('content-length'));
3289
+
3290
+ return Number.isFinite(length) && length > 0 && !text.trim();
3291
+ } catch {
3292
+ return false;
3293
+ }
3294
+ };
3070
3295
 
3071
3296
  class NetTransientError extends Error {}
3072
3297
 
@@ -3084,7 +3309,7 @@
3084
3309
  throw new NetPausedError('paused');
3085
3310
  }
3086
3311
 
3087
- if (pageIsHidden()) {
3312
+ if (pageIsHidden() || netLeavingRecently()) {
3088
3313
  throw new NetTransientError('hidden');
3089
3314
  }
3090
3315
 
@@ -3158,7 +3383,7 @@
3158
3383
  let { response, text } = await fetchOnce('');
3159
3384
 
3160
3385
  if (looksBlockedResponse(response, text)) {
3161
- if (unreliableWindow(startedAt)) {
3386
+ if (unreliableWindow(startedAt) || truncatedResponse(response, text)) {
3162
3387
  throw new NetTransientError('unreliable');
3163
3388
  }
3164
3389
 
@@ -3170,11 +3395,17 @@
3170
3395
  ({ response, text } = await fetchOnce(options.cache === 'no-store' ? 'no-store' : 'reload'));
3171
3396
 
3172
3397
  if (looksBlockedResponse(response, text)) {
3398
+ if (unreliableWindow(startedAt) || truncatedResponse(response, text)) {
3399
+ throw new NetTransientError('unreliable');
3400
+ }
3401
+
3402
+ await sleepMs(1500 * NET_GAP_SCALE);
3403
+
3173
3404
  if (unreliableWindow(startedAt)) {
3174
3405
  throw new NetTransientError('unreliable');
3175
3406
  }
3176
3407
 
3177
- noteNetBlocked();
3408
+ noteNetBlocked('fetch');
3178
3409
  throw new NetPausedError('blocked');
3179
3410
  }
3180
3411
  }
@@ -6814,6 +7045,8 @@
6814
7045
  document.querySelectorAll(COMMENT_ITEM_SELECTOR).forEach(li => {
6815
7046
  processUserNode(li, li.querySelector(COMMENT_NICK_SELECTOR), 'comment');
6816
7047
  li.classList.toggle('dc-comment-hidden', settings.hideDcconComments !== false && isDcconOnlyComment(li));
7048
+
7049
+ li.classList.toggle('dc-comment-boy', /^\s*댓글돌이\s*$/.test((li.querySelector('.nick') || {}).textContent || ''));
6817
7050
  });
6818
7051
 
6819
7052
  processMediaElements();
@@ -9700,6 +9933,7 @@
9700
9933
  const SETTING_GROUPS = [
9701
9934
  { key: 'block', title: '🚫 차단 방식', intro: '차단한 글·댓글을 화면에서 어떻게 처리할지 정합니다.' },
9702
9935
  { key: 'clean', title: '🧹 화면 정리', intro: '사이트가 기본으로 보여 주는 영역 중 필요 없는 것을 숨깁니다.' },
9936
+ { key: 'alarm', title: '🔔 알림', intro: '디시 알림(댓글·답글·힛갤 등)을 보기 쉽게 바꿉니다. 알림 내용은 사이트가 받은 것을 그대로 씁니다.' },
9703
9937
  { key: 'dccon', title: '😶 디시콘', intro: '디시 자체 이모티콘을 다룹니다.' },
9704
9938
  { key: 'list', title: '📜 목록 보기', intro: '게시판 목록을 넘겨 보는 방식을 정합니다.' },
9705
9939
  { key: 'media', title: '🖼 이미지·영상', intro: '본문 이미지 차단과 확대 보기를 다룹니다.' },
@@ -9823,22 +10057,71 @@
9823
10057
  off: '디시미디어·디시이슈 묶음을 보여 줍니다.',
9824
10058
  refresh: 'root'
9825
10059
  },
10060
+ {
10061
+ group: 'clean',
10062
+ key: 'hideNewsRows',
10063
+ name: '목록의 제휴 뉴스 숨기기',
10064
+ on: "말머리가 '뉴스'인 제휴 기사(광고성 글)를 목록에서 숨깁니다.",
10065
+ off: '제휴 뉴스를 목록에 그대로 보여 줍니다.',
10066
+ refresh: 'root'
10067
+ },
10068
+ {
10069
+ group: 'clean',
10070
+ key: 'hideCommentAds',
10071
+ name: '댓글돌이·파워링크 댓글 숨기기',
10072
+ on: '댓글 사이에 끼어드는 댓글돌이(기사·광고 넘겨 보기)와 파워링크 댓글을 숨깁니다.',
10073
+ off: '댓글돌이를 그대로 보여 줍니다.',
10074
+ refresh: 'root'
10075
+ },
10076
+ {
10077
+ group: 'clean',
10078
+ key: 'hideAdSlots',
10079
+ name: '광고 자리 숨기기',
10080
+ on: '배너·파워링크·구글 광고처럼 광고가 들어가는 칸을 숨겨 빈 공간 없이 보여 줍니다.',
10081
+ off: '광고 칸을 사이트 기본처럼 둡니다.',
10082
+ refresh: 'root'
10083
+ },
9826
10084
  {
9827
10085
  group: 'clean',
9828
10086
  key: 'pcFitWidth',
9829
10087
  name: 'PC 화면을 창 크기에 꽉 차게 (PC)',
9830
- on: '가운데 고정 폭(1160px) 대신 창 너비에 맞춰 목록·본문·머리말을 넓힙니다. 창이 좁으면 오른쪽 칸(로그인·실시간 베스트)을 숨겨 목록이 잘리지 않게 합니다.',
10088
+ on: '가운데 고정 폭 대신 창 너비에 맞춰 갤러리 목록·글·디시 홈을 넓힙니다. 창이 좁으면 오른쪽 칸(로그인·실시간 베스트)을 숨겨 목록이 잘리지 않게 합니다.',
9831
10089
  off: '사이트 기본처럼 가운데 고정 폭으로 보여 줍니다.',
9832
10090
  refresh: 'root'
9833
10091
  },
9834
10092
  {
9835
10093
  group: 'clean',
9836
10094
  key: 'hideFooterInfo',
9837
- name: '맨 아래 회사 정보 줄 숨기기 (모바일)',
9838
- on: '페이지 맨 아래의 앱 설치·로그인·회사 소개·약관·저작권 줄을 숨깁니다. 즐겨찾기·야간 모드·PC버전 줄은 남깁니다.',
10095
+ name: '맨 아래 정보 영역 숨기기',
10096
+ on: '모바일은 맨 아래 앱 설치·회사 소개·약관·저작권 줄을, PC는 검색창 아래의 갤러리 분류 목록·야간모드/맨위로·회사 정보·저작권을 숨깁니다. 창이 작아도 목록이 가려지지 않습니다.',
9839
10097
  off: '맨 아래 정보 줄을 모두 보여 줍니다.',
9840
10098
  refresh: 'root'
9841
10099
  },
10100
+ {
10101
+ group: 'clean',
10102
+ key: 'hideSilbeAlarm',
10103
+ name: '실베(실시간 베스트) 알림 팝업 숨기기',
10104
+ on: '내 알림과 상관없이 화면 구석에 뜨는 실시간 베스트 홍보 한 줄 알림을 숨깁니다.',
10105
+ off: '실시간 베스트 알림을 사이트 기본처럼 띄웁니다.',
10106
+ refresh: 'root'
10107
+ },
10108
+ {
10109
+ group: 'alarm',
10110
+ key: 'alarmBubble',
10111
+ name: '알림을 플로팅 말풍선으로',
10112
+ on: '사이트의 한 줄 알림 대신 차단 관리 버튼 곁에 말풍선으로 띄우고, 읽지 않은 알림 수를 버튼에 표시합니다. 누르면 이동, 좌우로 밀면 닫힙니다. 지난 알림은 [🕘 최근] 탭에 모입니다.',
10113
+ off: '사이트 기본 한 줄 알림을 그대로 씁니다.',
10114
+ refresh: 'root'
10115
+ },
10116
+ {
10117
+ group: 'alarm',
10118
+ key: 'alarmKeepPolling',
10119
+ parent: 'alarmBubble',
10120
+ name: '오래 보는 페이지에서도 알림 받기 (모바일)',
10121
+ on: '모바일 사이트는 한 페이지에서 알림을 세 번(약 4분 반)만 확인합니다. 그 뒤에도 화면을 보는 동안 3분마다 이어서 확인합니다.',
10122
+ off: '사이트 기본처럼 페이지를 옮길 때만 다시 확인합니다.',
10123
+ refresh: 'none'
10124
+ },
9842
10125
  {
9843
10126
  group: 'dccon',
9844
10127
  key: 'blockDccon',
@@ -10048,6 +10331,10 @@
10048
10331
  repinReadColors();
10049
10332
  }
10050
10333
 
10334
+ if (item.key === 'alarmBubble') {
10335
+ paintAlarmBadge();
10336
+ }
10337
+
10051
10338
  break;
10052
10339
 
10053
10340
  case 'log':
@@ -11080,6 +11367,9 @@
11080
11367
  const SYNC_MIN_GAP = 20000;
11081
11368
  const SYNC_POLL = 60000;
11082
11369
  const SYNC_LOCK_MS = 20000;
11370
+ const SYNC_RETRY = [3000, 10000, 30000];
11371
+ const SYNC_FAIL_SHOW = 3;
11372
+ const SYNC_STUCK_MS = 30000;
11083
11373
  const SYNC_DEVICE_REFRESH = 10 * 60 * 1000;
11084
11374
  const SYNC_DEVICE_STALE = 60 * 24 * 3600 * 1000;
11085
11375
  const SYNC_LOG_MAX = 20;
@@ -11094,6 +11384,7 @@
11094
11384
  { key: 'words', label: '단어', fields: ['keywords', 'titles'] },
11095
11385
  { key: 'heads', label: '말머리', fields: ['heads'] },
11096
11386
  { key: 'scraps', label: '스크랩', fields: ['scraps'] },
11387
+ { key: 'recent', label: '최근 갤러리', fields: ['recent'] },
11097
11388
  { key: 'settings', label: '설정', fields: ['settings'] }
11098
11389
  ];
11099
11390
 
@@ -11260,7 +11551,8 @@
11260
11551
  titles: [...blockedTitles],
11261
11552
  heads: JSON.parse(JSON.stringify(hiddenHeadMap || {})),
11262
11553
  scraps: scraps.map(item => ({ ...item })),
11263
- settings: syncedSettings()
11554
+ settings: syncedSettings(),
11555
+ recent: collectRecentForSync()
11264
11556
  });
11265
11557
 
11266
11558
  const skippedFields = skip => SYNC_PARTS.filter(part => skip.includes(part.key)).flatMap(part => part.fields);
@@ -11366,7 +11658,8 @@
11366
11658
  titles,
11367
11659
  heads: mergeHeads(local.heads, base && base.heads, remote && remote.heads),
11368
11660
  scraps: mergeScraps(local.scraps, base && base.scraps, remote && remote.scraps),
11369
- settings: mergeSettings(local.settings, base && base.settings, remote && remote.settings)
11661
+ settings: mergeSettings(local.settings, base && base.settings, remote && remote.settings),
11662
+ recent: mergeRecent(local.recent, base && base.recent, remote && remote.recent)
11370
11663
  };
11371
11664
 
11372
11665
  skippedFields(skip).forEach(field => {
@@ -11416,9 +11709,18 @@
11416
11709
 
11417
11710
  let syncApplying = false;
11418
11711
 
11712
+ const blockSyncData = () => {
11713
+ const data = collectSyncData();
11714
+
11715
+ delete data.recent;
11716
+
11717
+ return JSON.stringify(data);
11718
+ };
11719
+
11419
11720
  const applySyncData = data => {
11721
+ const recentChanged = data && 'recent' in data ? applyRecentFromSync(data.recent) : false;
11420
11722
  const clean = sanitizeBackup({ app: BACKUP_APP, ...data });
11421
- const before = JSON.stringify(collectSyncData());
11723
+ const before = blockSyncData();
11422
11724
 
11423
11725
  syncApplying = true;
11424
11726
 
@@ -11465,13 +11767,13 @@
11465
11767
  syncApplying = false;
11466
11768
  }
11467
11769
 
11468
- const changed = before !== JSON.stringify(collectSyncData());
11770
+ const changed = before !== blockSyncData();
11469
11771
 
11470
11772
  if (changed && initialized) {
11471
11773
  refreshAfterBlockChange();
11472
11774
  }
11473
11775
 
11474
- return changed;
11776
+ return changed || recentChanged;
11475
11777
  };
11476
11778
 
11477
11779
  const saveSyncUndo = (before, incoming, summary, from) => {
@@ -11501,6 +11803,20 @@
11501
11803
  return /^https:\/\/[^/\s]+/.test(base) ? `${base}${path}` : '';
11502
11804
  };
11503
11805
 
11806
+ const syncError = (message, { transient = false, quiet = false } = {}) => Object.assign(new Error(message), { transient, quiet });
11807
+ const statusError = status => syncError(status === 413 ? '내용이 너무 큽니다' : `서버 응답 ${status}`, { transient: status >= 500 || status === 429 || status === 408 });
11808
+
11809
+ const syncControllers = new Set();
11810
+
11811
+ const abortSyncRequests = () => {
11812
+ syncControllers.forEach(controller => {
11813
+ if (!controller.dcKeep) {
11814
+ controller.dcQuiet = true;
11815
+ controller.abort();
11816
+ }
11817
+ });
11818
+ };
11819
+
11504
11820
  const syncRequest = async (path, { method = 'GET', headers = {}, body, keepalive = false } = {}) => {
11505
11821
  const url = syncEndpoint(path);
11506
11822
 
@@ -11512,22 +11828,42 @@
11512
11828
  const timer = controller ? setTimeout(() => controller.abort(), 15000) : null;
11513
11829
  const payload = body === undefined ? undefined : JSON.stringify(body);
11514
11830
 
11831
+ if (controller) {
11832
+ controller.dcKeep = keepalive;
11833
+ syncControllers.add(controller);
11834
+ }
11835
+
11515
11836
  try {
11516
- const response = await fetch(url, {
11837
+ let response;
11838
+
11839
+ try {
11840
+ response = await fetch(url, {
11517
11841
  method,
11518
11842
  headers: { 'Content-Type': 'application/json', ...headers },
11519
11843
  body: payload,
11520
11844
  credentials: 'omit',
11521
11845
  cache: 'no-store',
11522
11846
  referrerPolicy: 'no-referrer',
11523
- keepalive: keepalive && (!payload || payload.length < 60000),
11524
- signal: controller ? controller.signal : undefined
11525
- });
11847
+ keepalive: keepalive && (!payload || payload.length < 60000),
11848
+ signal: controller ? controller.signal : undefined
11849
+ });
11850
+ } catch (error) {
11851
+ if (controller && controller.dcQuiet) {
11852
+ throw syncError('페이지를 떠나며 멈춤', { quiet: true });
11853
+ }
11854
+
11855
+ throw syncError(error && error.name === 'AbortError' ? '서버 응답이 없습니다' : '서버에 연결하지 못했습니다', { transient: true });
11856
+ }
11857
+
11526
11858
  const data = await response.json().catch(() => ({}));
11527
11859
 
11528
11860
  return { status: response.status, data };
11529
11861
  } finally {
11530
11862
  clearTimeout(timer);
11863
+
11864
+ if (controller) {
11865
+ syncControllers.delete(controller);
11866
+ }
11531
11867
  }
11532
11868
  };
11533
11869
 
@@ -11610,12 +11946,35 @@
11610
11946
  };
11611
11947
 
11612
11948
  let syncRunning = false;
11949
+ let syncRunId = 0;
11950
+ let syncRunSince = 0;
11951
+ let syncEpoch = 0;
11952
+ let pageLeaving = false;
11613
11953
  let syncAgain = false;
11614
11954
  let syncTimer = null;
11615
11955
  let syncPending = false;
11616
- let lastSyncAt = 0;
11617
11956
  let lastErrorMsg = '';
11618
11957
 
11958
+ const readSyncRt = () => readObject(STORAGE.syncRt);
11959
+ const writeSyncRt = patch => writeJson(STORAGE.syncRt, { ...readSyncRt(), ...patch });
11960
+ const lastSyncTry = () => Number(readSyncRt().lastTry) || 0;
11961
+
11962
+ const resetStuckSync = (force = false) => {
11963
+ if (syncRunning && (force || Date.now() - syncRunSince > SYNC_STUCK_MS)) {
11964
+ syncRunId++;
11965
+ syncRunning = false;
11966
+ releaseSyncLock();
11967
+ setSyncPhase('');
11968
+ }
11969
+ };
11970
+
11971
+ const syncDueOnReturn = () => {
11972
+ const rt = readSyncRt();
11973
+ const state = readObject(STORAGE.syncState);
11974
+
11975
+ return syncPending || rt.dirty === true || (state.at && state.ok === false) || Date.now() - lastSyncTry() > SYNC_MIN_GAP;
11976
+ };
11977
+
11619
11978
  const syncReady = () => {
11620
11979
  const conf = readSyncConf();
11621
11980
 
@@ -11649,6 +12008,8 @@
11649
12008
  return false;
11650
12009
  }
11651
12010
 
12011
+ resetStuckSync();
12012
+
11652
12013
  if (syncRunning) {
11653
12014
  syncAgain = true;
11654
12015
  return false;
@@ -11665,7 +12026,7 @@
11665
12026
  return false;
11666
12027
  }
11667
12028
 
11668
- if (!takeSyncLock(manual)) {
12029
+ if (!takeSyncLock(manual || leaving)) {
11669
12030
  scheduleSync(SYNC_LOCK_MS / 2);
11670
12031
  return false;
11671
12032
  }
@@ -11673,7 +12034,11 @@
11673
12034
  syncRunning = true;
11674
12035
  syncPending = false;
11675
12036
  clearTimeout(syncTimer);
11676
- lastSyncAt = Date.now();
12037
+ syncRunSince = Date.now();
12038
+ writeSyncRt({ lastTry: syncRunSince });
12039
+
12040
+ const runId = ++syncRunId;
12041
+ const epoch = syncEpoch;
11677
12042
 
11678
12043
  const key = readSyncKey();
11679
12044
  const conf = readSyncConf();
@@ -11696,7 +12061,7 @@
11696
12061
  }
11697
12062
 
11698
12063
  if (got.status !== 200) {
11699
- throw new Error(`서버 응답 ${got.status}`);
12064
+ throw statusError(got.status);
11700
12065
  }
11701
12066
 
11702
12067
  setSyncPhase('merge');
@@ -11736,7 +12101,7 @@
11736
12101
  }
11737
12102
 
11738
12103
  if (put.status !== 200) {
11739
- throw new Error(put.status === 413 ? '내용이 너무 큽니다' : `서버 응답 ${put.status}`);
12104
+ throw statusError(put.status);
11740
12105
  }
11741
12106
 
11742
12107
  newRev = Number(put.data.rev) || rev + 1;
@@ -11772,6 +12137,7 @@
11772
12137
  const msg = changed ? `${from}에서 받음 · ${summary}` : sentSummary ? `올림 · ${sentSummary}` : '최신 상태입니다';
11773
12138
 
11774
12139
  writeSyncState({ ok: true, msg });
12140
+ writeSyncRt({ fails: 0, dirty: syncPending });
11775
12141
  lastErrorMsg = '';
11776
12142
 
11777
12143
  if (changed) {
@@ -11796,9 +12162,37 @@
11796
12162
  return true;
11797
12163
  }
11798
12164
 
11799
- throw new Error('다른 기기와 동시에 바뀌어 잠시 뒤 다시 맞춥니다');
12165
+ throw syncError('다른 기기와 동시에 바뀌어 잠시 뒤 다시 맞춥니다', { transient: true });
11800
12166
  } catch (error) {
11801
- const msg = error && error.name === 'AbortError' ? '서버 응답이 없습니다' : String((error && error.message) || error);
12167
+ syncPending = true;
12168
+
12169
+ if (runId !== syncRunId || (error && error.quiet) || pageLeaving || leaving || epoch !== syncEpoch || document.visibilityState === 'hidden') {
12170
+ writeSyncRt({ dirty: true });
12171
+
12172
+ if (runId === syncRunId && !pageLeaving && document.visibilityState !== 'hidden') {
12173
+ scheduleSync(SYNC_RETRY[0]);
12174
+ }
12175
+
12176
+ return false;
12177
+ }
12178
+
12179
+ if (navigator.onLine === false) {
12180
+ writeSyncRt({ dirty: true });
12181
+ writeSyncState({ ok: false, offline: true, msg: '오프라인 · 연결되면 맞춥니다' });
12182
+ return false;
12183
+ }
12184
+
12185
+ const transient = !!(error && (error.transient || error.name === 'TypeError'));
12186
+ const fails = (Number(readSyncRt().fails) || 0) + 1;
12187
+
12188
+ writeSyncRt({ fails, dirty: true });
12189
+
12190
+ if (transient && !manual && fails < SYNC_FAIL_SHOW) {
12191
+ scheduleSync(SYNC_RETRY[fails - 1]);
12192
+ return false;
12193
+ }
12194
+
12195
+ const msg = String((error && error.message) || error);
11802
12196
 
11803
12197
  writeSyncState({ ok: false, msg });
11804
12198
 
@@ -11811,16 +12205,17 @@
11811
12205
  }
11812
12206
 
11813
12207
  lastErrorMsg = msg;
11814
- syncPending = true;
11815
- scheduleSync(SYNC_POLL);
12208
+ scheduleSync(transient ? SYNC_RETRY[Math.min(fails - 1, SYNC_RETRY.length - 1)] : SYNC_POLL);
11816
12209
 
11817
12210
  return false;
11818
12211
  } finally {
11819
- syncRunning = false;
11820
- releaseSyncLock();
11821
- setSyncPhase('');
12212
+ if (runId === syncRunId) {
12213
+ syncRunning = false;
12214
+ releaseSyncLock();
12215
+ setSyncPhase('');
12216
+ }
11822
12217
 
11823
- if (syncAgain) {
12218
+ if (syncAgain && runId === syncRunId) {
11824
12219
  syncAgain = false;
11825
12220
  scheduleSync(SYNC_DEBOUNCE);
11826
12221
  }
@@ -11870,6 +12265,7 @@
11870
12265
  storageWriteListeners.push(key => {
11871
12266
  if (!syncApplying && SYNCED_STORAGE.includes(key) && syncReady()) {
11872
12267
  syncPending = true;
12268
+ writeSyncRt({ dirty: true });
11873
12269
  scheduleSync(SYNC_DEBOUNCE);
11874
12270
  }
11875
12271
  });
@@ -11904,7 +12300,7 @@
11904
12300
  return;
11905
12301
  }
11906
12302
 
11907
- if (document.visibilityState === 'visible' && Date.now() - lastSyncAt > SYNC_POLL - 1000) {
12303
+ if (document.visibilityState === 'visible' && Date.now() - lastSyncTry() > SYNC_POLL - 1000) {
11908
12304
  syncNow();
11909
12305
  }
11910
12306
  }, SYNC_POLL);
@@ -11961,29 +12357,48 @@
11961
12357
  syncStarted = true;
11962
12358
  noteScriptVersion();
11963
12359
 
11964
- document.addEventListener('visibilitychange', () => {
12360
+ const onReturn = restored => {
12361
+ pageLeaving = false;
12362
+
11965
12363
  if (!syncReady()) {
11966
12364
  return;
11967
12365
  }
11968
12366
 
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 });
12367
+ resetStuckSync(restored);
12368
+
12369
+ if (syncDueOnReturn() && !syncRunning) {
12370
+ scheduleSync(800);
11975
12371
  }
11976
- });
12372
+ };
12373
+
12374
+ const onLeave = unloading => {
12375
+ syncEpoch++;
12376
+ pageLeaving = unloading;
12377
+
12378
+ if (unloading) {
12379
+ abortSyncRequests();
12380
+ }
12381
+
12382
+ clearTimeout(syncTimer);
11977
12383
 
11978
- window.addEventListener('pagehide', () => {
11979
12384
  if (syncReady() && syncPending && !syncRunning) {
11980
12385
  syncNow({ leaving: true });
11981
12386
  }
12387
+ };
12388
+
12389
+ document.addEventListener('visibilitychange', () => {
12390
+ if (document.visibilityState === 'visible') {
12391
+ onReturn(false);
12392
+ } else {
12393
+ onLeave(false);
12394
+ }
11982
12395
  });
11983
12396
 
12397
+ window.addEventListener('pagehide', () => onLeave(true));
12398
+
11984
12399
  window.addEventListener('pageshow', event => {
11985
- if (event.persisted && syncReady()) {
11986
- scheduleSync(800);
12400
+ if (event.persisted) {
12401
+ onReturn(true);
11987
12402
  }
11988
12403
  });
11989
12404
 
@@ -12010,7 +12425,10 @@
12010
12425
 
12011
12426
  if (syncReady()) {
12012
12427
  startSyncPoll();
12013
- scheduleSync(1500);
12428
+
12429
+ if (syncDueOnReturn()) {
12430
+ scheduleSync(1500);
12431
+ }
12014
12432
  }
12015
12433
  };
12016
12434
 
@@ -12575,6 +12993,886 @@
12575
12993
  return box;
12576
12994
  };
12577
12995
 
12996
+ // =========================================================
12997
+ // 13-3. 최근 방문 갤러리 · 사이트 알림
12998
+ // =========================================================
12999
+
13000
+ const RECENT_LIMIT = 50;
13001
+ const RECENT_NATIVE_CAP = IS_MOBILE_SITE ? 24 : 50;
13002
+ const RECENT_KINDS = ['G', 'M', 'N', 'P'];
13003
+ const RECENT_ID_RE = /^[A-Za-z0-9_.-]{1,64}$/;
13004
+
13005
+ const recentKeyOf = item => `${item.k}:${item.id}`;
13006
+
13007
+ const normalizeRecent = item => {
13008
+ if (!item || typeof item !== 'object' || typeof item.id !== 'string' || !RECENT_ID_RE.test(item.id)) {
13009
+ return null;
13010
+ }
13011
+
13012
+ return {
13013
+ k: RECENT_KINDS.includes(item.k) ? item.k : 'G',
13014
+ id: item.id,
13015
+ n: cleanText(item.n).slice(0, 40),
13016
+ at: Number(item.at) || 0
13017
+ };
13018
+ };
13019
+
13020
+ const tidyRecent = list => {
13021
+ const best = new Map();
13022
+
13023
+ list.map(normalizeRecent).filter(Boolean).forEach(item => {
13024
+ const key = recentKeyOf(item);
13025
+ const prev = best.get(key);
13026
+
13027
+ if (!prev || item.at > prev.at || (item.at === prev.at && !prev.n && item.n)) {
13028
+ best.set(key, prev && !item.n ? { ...item, n: prev.n } : item);
13029
+ }
13030
+ });
13031
+
13032
+ return [...best.values()].sort((a, b) => b.at - a.at).slice(0, RECENT_LIMIT);
13033
+ };
13034
+
13035
+ let recentGalls = tidyRecent(readArray(STORAGE.recent));
13036
+
13037
+ const saveRecent = () => {
13038
+ recentGalls = tidyRecent(recentGalls);
13039
+ writeJson(STORAGE.recent, recentGalls);
13040
+ };
13041
+
13042
+ const recentUrl = item => {
13043
+ if (IS_MOBILE_SITE) {
13044
+ const base = item.k === 'N' ? 'mini' : item.k === 'P' ? 'person' : 'board';
13045
+
13046
+ return `https://m.dcinside.com/${base}/${encodeURIComponent(item.id)}`;
13047
+ }
13048
+
13049
+ const prefix = { G: '', M: '/mgallery', N: '/mini', P: '/person' }[item.k] || '';
13050
+
13051
+ return `https://gall.dcinside.com${prefix}/board/lists/?id=${encodeURIComponent(item.id)}`;
13052
+ };
13053
+
13054
+ const RECENT_KIND_LABEL = { G: '', M: '마이너', N: '미니', P: '인물' };
13055
+
13056
+ const MOBILE_KIND_MARK = { M: '$minor$', N: '$mini$', P: '$person$' };
13057
+ const PC_KIND = { G: 'G', M: 'M', N: 'MI', P: 'PR' };
13058
+ const PC_KIND_BACK = { G: 'G', M: 'M', MI: 'N', PR: 'P' };
13059
+
13060
+ const readNativeRecent = () => {
13061
+ try {
13062
+ if (IS_MOBILE_SITE) {
13063
+ const raw = unescape(localStorage.getItem('m_dcinside_lately') || '');
13064
+
13065
+ return raw.split(',').map(part => {
13066
+ const cut = part.indexOf('|');
13067
+
13068
+ if (cut < 1) {
13069
+ return null;
13070
+ }
13071
+
13072
+ let name = part.slice(cut + 1);
13073
+ let k = 'G';
13074
+
13075
+ Object.entries(MOBILE_KIND_MARK).forEach(([kind, mark]) => {
13076
+ if (name.includes(mark)) {
13077
+ k = kind;
13078
+ name = name.replace(mark, '');
13079
+ }
13080
+ });
13081
+
13082
+ return normalizeRecent({ k, id: part.slice(0, cut).trim(), n: name, at: 0 });
13083
+ }).filter(Boolean);
13084
+ }
13085
+
13086
+ if (!/^gall\.dcinside\.com$/i.test(location.hostname)) {
13087
+ return null;
13088
+ }
13089
+
13090
+ const list = JSON.parse(localStorage.getItem('lately_gallery') || 'null');
13091
+
13092
+ return Array.isArray(list)
13093
+ ? list.map(item => item && normalizeRecent({ k: PC_KIND_BACK[item.type] || 'G', id: String(item.id || ''), n: item.name, at: 0 })).filter(Boolean)
13094
+ : [];
13095
+ } catch {
13096
+ return null;
13097
+ }
13098
+ };
13099
+
13100
+ const writeNativeRecent = list => {
13101
+ const top = list.slice(0, RECENT_NATIVE_CAP);
13102
+
13103
+ try {
13104
+ if (IS_MOBILE_SITE) {
13105
+ const text = top.map(item => `${item.id}|${(item.n || item.id).replace(/[,|]/g, ' ')}${MOBILE_KIND_MARK[item.k] ? ` ${MOBILE_KIND_MARK[item.k]}` : ''},`).join('');
13106
+
13107
+ localStorage.setItem('m_dcinside_lately', escape(text));
13108
+ } else if (/^gall\.dcinside\.com$/i.test(location.hostname)) {
13109
+ const dir = { G: '', M: 'mgallery/', N: 'mini/', P: 'person/' };
13110
+
13111
+ localStorage.setItem('lately_gallery', JSON.stringify(top.map(item => ({
13112
+ id: item.id,
13113
+ name: item.n || item.id,
13114
+ type: PC_KIND[item.k] || 'G',
13115
+ link: `gall.dcinside.com/${dir[item.k] || ''}board/lists?id=${item.id}`
13116
+ }))));
13117
+ } else {
13118
+ return;
13119
+ }
13120
+
13121
+ writeJson(STORAGE.recentNative, top.map(recentKeyOf));
13122
+ } catch {
13123
+ }
13124
+ };
13125
+
13126
+ const importNativeRecent = () => {
13127
+ const native = readNativeRecent();
13128
+
13129
+ if (!native) {
13130
+ return false;
13131
+ }
13132
+
13133
+ const before = JSON.stringify(recentGalls);
13134
+ const nativeKeys = new Set(native.map(recentKeyOf));
13135
+ const snapshot = readArray(STORAGE.recentNative).filter(key => typeof key === 'string');
13136
+
13137
+ if (snapshot.length && native.length < RECENT_NATIVE_CAP - 1) {
13138
+ const removed = new Set(snapshot.filter(key => !nativeKeys.has(key)));
13139
+
13140
+ if (removed.size) {
13141
+ recentGalls = recentGalls.filter(item => !removed.has(recentKeyOf(item)));
13142
+ }
13143
+ }
13144
+
13145
+ const mine = new Map(recentGalls.map(item => [recentKeyOf(item), item]));
13146
+ let prevAt = Date.now();
13147
+
13148
+ native.forEach(item => {
13149
+ const known = mine.get(recentKeyOf(item));
13150
+
13151
+ if (known) {
13152
+ prevAt = Math.min(prevAt, known.at || prevAt);
13153
+
13154
+ if (!known.n && item.n) {
13155
+ known.n = item.n;
13156
+ }
13157
+ } else {
13158
+ prevAt -= 1000;
13159
+ recentGalls.push({ ...item, at: prevAt });
13160
+ }
13161
+ });
13162
+
13163
+ recentGalls = tidyRecent(recentGalls);
13164
+
13165
+ const changed = JSON.stringify(recentGalls) !== before;
13166
+
13167
+ if (changed) {
13168
+ writeJson(STORAGE.recent, recentGalls);
13169
+ }
13170
+
13171
+ return changed;
13172
+ };
13173
+
13174
+ const exportNativeRecent = () => {
13175
+ const native = readNativeRecent();
13176
+
13177
+ if (!native) {
13178
+ return;
13179
+ }
13180
+
13181
+ const want = recentGalls.slice(0, RECENT_NATIVE_CAP);
13182
+
13183
+ if (JSON.stringify(native.map(recentKeyOf)) !== JSON.stringify(want.map(recentKeyOf))) {
13184
+ writeNativeRecent(want);
13185
+ } else {
13186
+ writeJson(STORAGE.recentNative, want.map(recentKeyOf));
13187
+ }
13188
+ };
13189
+
13190
+ const currentGalleryVisit = () => {
13191
+ const id = getGalleryId();
13192
+
13193
+ if (!id || id === HOME_GALLERY_KEY || !RECENT_ID_RE.test(id)) {
13194
+ return null;
13195
+ }
13196
+
13197
+ let k = 'G';
13198
+ let name = '';
13199
+
13200
+ if (IS_MOBILE_SITE) {
13201
+ if (!/^\/(board|mini|person)\//.test(location.pathname)) {
13202
+ return null;
13203
+ }
13204
+
13205
+ k = /^\/mini\//.test(location.pathname)
13206
+ ? 'N'
13207
+ : /^\/person\//.test(location.pathname)
13208
+ ? 'P'
13209
+ : (document.getElementById('is_minor')?.value || '') !== '' ? 'M' : 'G';
13210
+ name = document.querySelector('.gall-tit-box .gall-tit')?.textContent || '';
13211
+ } else {
13212
+ if (!/^\/(mgallery\/|mini\/|person\/)?board\/(lists|view)/.test(location.pathname)) {
13213
+ return null;
13214
+ }
13215
+
13216
+ k = /^\/mgallery\//.test(location.pathname) ? 'M' : /^\/mini\//.test(location.pathname) ? 'N' : /^\/person\//.test(location.pathname) ? 'P' : 'G';
13217
+
13218
+ const head = document.querySelector('.page_head h2 a') || document.querySelector('.page_head h2');
13219
+
13220
+ name = head ? [...head.childNodes].filter(node => node.nodeType === 3).map(node => node.textContent).join(' ') : '';
13221
+
13222
+ if (!cleanText(name)) {
13223
+ const og = document.querySelector('meta[property="og:title"]')?.content || '';
13224
+
13225
+ name = og.split(' - ').slice(-2, -1)[0] || og.split(' - ')[0] || '';
13226
+ }
13227
+ }
13228
+
13229
+ name = cleanText(name).replace(/\s*(마이너|미니|인물)?\s*갤러리$/, '');
13230
+
13231
+ return normalizeRecent({ k, id, n: name || id, at: Date.now() });
13232
+ };
13233
+
13234
+ const recordGalleryVisit = () => {
13235
+ const visit = currentGalleryVisit();
13236
+
13237
+ if (!visit) {
13238
+ return;
13239
+ }
13240
+
13241
+ const prev = recentGalls.find(item => recentKeyOf(item) === recentKeyOf(visit));
13242
+
13243
+ if (prev && recentGalls[0] === prev && Date.now() - prev.at < 60000 && prev.n === visit.n) {
13244
+ return;
13245
+ }
13246
+
13247
+ recentGalls = [visit, ...recentGalls.filter(item => item !== prev)];
13248
+ saveRecent();
13249
+ };
13250
+
13251
+ const initRecentEarly = () => {
13252
+ importNativeRecent();
13253
+ exportNativeRecent();
13254
+ };
13255
+
13256
+ const initRecent = () => {
13257
+ importNativeRecent();
13258
+ recordGalleryVisit();
13259
+ exportNativeRecent();
13260
+
13261
+ const follow = () => {
13262
+ if (document.visibilityState === 'visible' && importNativeRecent()) {
13263
+ exportNativeRecent();
13264
+ }
13265
+ };
13266
+
13267
+ setTimeout(follow, 3000);
13268
+ document.addEventListener('visibilitychange', follow);
13269
+ };
13270
+
13271
+ const collectRecentForSync = () => recentGalls.map(item => ({ ...item }));
13272
+
13273
+ const mergeRecent = (local = [], base, remote) => {
13274
+ if (!Array.isArray(remote)) {
13275
+ return tidyRecent(local || []);
13276
+ }
13277
+
13278
+ const pick = new Map();
13279
+
13280
+ [...(remote || []), ...(local || [])].map(normalizeRecent).filter(Boolean).forEach(item => {
13281
+ const key = recentKeyOf(item);
13282
+ const prev = pick.get(key);
13283
+
13284
+ if (!prev || item.at > prev.at) {
13285
+ pick.set(key, prev && !item.n ? { ...item, n: prev.n } : item);
13286
+ }
13287
+ });
13288
+
13289
+ const keys = mergeList(
13290
+ (local || []).map(item => recentKeyOf(item)),
13291
+ Array.isArray(base) ? base.map(item => recentKeyOf(item)) : null,
13292
+ remote.map(item => recentKeyOf(item))
13293
+ );
13294
+
13295
+ return tidyRecent(keys.map(key => pick.get(key)).filter(Boolean));
13296
+ };
13297
+
13298
+ const applyRecentFromSync = list => {
13299
+ if (!Array.isArray(list)) {
13300
+ return false;
13301
+ }
13302
+
13303
+ const next = tidyRecent(list);
13304
+
13305
+ if (JSON.stringify(next) === JSON.stringify(recentGalls)) {
13306
+ return false;
13307
+ }
13308
+
13309
+ recentGalls = next;
13310
+ writeJson(STORAGE.recent, recentGalls);
13311
+ exportNativeRecent();
13312
+
13313
+ return true;
13314
+ };
13315
+
13316
+ // =========================================================
13317
+
13318
+ // =========================================================
13319
+
13320
+ const ALARM_TAGS = { R: '댓글', RR: '답글', P: '펌', hit: '힛갤', B: '실베', EC: '동영상', G: '방명록' };
13321
+ const ALARM_POLL_GAP = 3 * 60 * 1000;
13322
+ const ALARM_BUBBLE_MAX = 3;
13323
+ const ALARM_BUBBLE_FRESH = 10 * 60 * 1000;
13324
+
13325
+ const siteUserId = () => cleanText(document.getElementById('user_id')?.value || '');
13326
+
13327
+ const alarmStorageKeys = () => {
13328
+ try {
13329
+ if (IS_MOBILE_SITE) {
13330
+ const id = siteUserId();
13331
+
13332
+ return id ? [`alarm_notification_${id}`] : [];
13333
+ }
13334
+
13335
+ if (!/^gall\.dcinside\.com$/i.test(location.hostname)) {
13336
+ return [];
13337
+ }
13338
+
13339
+ const keys = [];
13340
+
13341
+ for (let i = 0; i < localStorage.length; i++) {
13342
+ const key = localStorage.key(i);
13343
+
13344
+ if (/^alarm_list/.test(key || '')) {
13345
+ keys.push(key);
13346
+ }
13347
+ }
13348
+
13349
+ return keys;
13350
+ } catch {
13351
+ return [];
13352
+ }
13353
+ };
13354
+
13355
+ const readAlarmRaw = () => alarmStorageKeys().map(key => {
13356
+ try {
13357
+ return localStorage.getItem(key) || '';
13358
+ } catch {
13359
+ return '';
13360
+ }
13361
+ }).join('\u0001');
13362
+
13363
+ const mobileAlarmHref = item => {
13364
+ const raw = String(item.id || '');
13365
+ const no = String(item.no || '').replace(/\D/g, '');
13366
+
13367
+ if (item.type === 'B' || raw === 'dcbest') {
13368
+ return no ? `https://m.dcinside.com/board/dcbest/${no}` : '';
13369
+ }
13370
+
13371
+ if (item.type === 'G') {
13372
+ const me = siteUserId();
13373
+
13374
+ return me ? `https://m.dcinside.com/gallog/${encodeURIComponent(me)}/guestbook` : '';
13375
+ }
13376
+
13377
+ const base = raw.startsWith('mi$') ? 'mini' : raw.startsWith('pr$') ? 'person' : 'board';
13378
+ const id = raw.replace(/^(mi|pr)\$/, '');
13379
+
13380
+ if (!id || !no || id === 'hit') {
13381
+ return id === 'hit' && no ? `https://m.dcinside.com/board/hit/${no}` : '';
13382
+ }
13383
+
13384
+ const comment = String(item.comment_no || '').replace(/\D/g, '');
13385
+
13386
+ return `https://m.dcinside.com/${base}/${encodeURIComponent(id)}/${no}${comment ? `?comment=${comment}` : ''}`;
13387
+ };
13388
+
13389
+ const readSiteAlarms = () => {
13390
+ const out = [];
13391
+
13392
+ alarmStorageKeys().forEach(key => {
13393
+ let list = [];
13394
+
13395
+ try {
13396
+ list = JSON.parse(localStorage.getItem(key) || '[]');
13397
+ } catch {
13398
+ list = [];
13399
+ }
13400
+
13401
+ (Array.isArray(list) ? list : []).forEach(item => {
13402
+ if (!item || typeof item !== 'object') {
13403
+ return;
13404
+ }
13405
+
13406
+ if (IS_MOBILE_SITE) {
13407
+ const at = Number(item.reg_date) || 0;
13408
+ const tag = item.id === 'hit' ? ALARM_TAGS.hit : ALARM_TAGS[item.type] || '알림';
13409
+
13410
+ out.push({
13411
+ key: `${item.type}:${item.id}:${item.no}:${item.comment_no || ''}:${at}`,
13412
+ tag,
13413
+ text: cleanText(item.subject).slice(0, 140) || '(내용 없음)',
13414
+ href: mobileAlarmHref(item),
13415
+ at
13416
+ });
13417
+ } else {
13418
+ const at = (Number(item.time) || 0) * 1000;
13419
+ const text = cleanText(`${item.prefix || ''} ${item.content || ''}`).replace(/<[^>]*>/g, '');
13420
+ const tagMatch = /^\[([^\]]{1,10})\]/.exec(cleanText(item.prefix || ''));
13421
+ let href = String(item.link || '');
13422
+
13423
+ if (href && !/^https?:\/\//.test(href)) {
13424
+ href = href.startsWith('/') ? `https://gall.dcinside.com${href}` : '';
13425
+ }
13426
+
13427
+ out.push({
13428
+ key: `${item.idx || ''}:${item.link || ''}:${at}`,
13429
+ tag: item.type === 'manager' ? '관리' : tagMatch ? tagMatch[1] : '알림',
13430
+ text: text.slice(0, 140) || '(내용 없음)',
13431
+ href: /^https:\/\/[a-z0-9.-]*dcinside\.com\//i.test(href) ? href : '',
13432
+ at,
13433
+ idx: item.idx
13434
+ });
13435
+ }
13436
+ });
13437
+ });
13438
+
13439
+ const seen = new Set();
13440
+
13441
+ return out
13442
+ .filter(item => !seen.has(item.key) && seen.add(item.key))
13443
+ .sort((a, b) => b.at - a.at)
13444
+ .slice(0, 30);
13445
+ };
13446
+
13447
+ const readAlarmMarks = () => {
13448
+ const marks = readObject(STORAGE.alarmSeen);
13449
+
13450
+ return { seen: Number(marks.seen) || 0, shown: Number(marks.shown) || 0 };
13451
+ };
13452
+
13453
+ const writeAlarmMarks = patch => writeJson(STORAGE.alarmSeen, { ...readAlarmMarks(), ...patch });
13454
+
13455
+ const unreadAlarmCount = () => {
13456
+ const { seen } = readAlarmMarks();
13457
+
13458
+ return readSiteAlarms().filter(item => item.at > seen).length;
13459
+ };
13460
+
13461
+ const paintAlarmBadge = () => {
13462
+ const fab = document.getElementById('dc-custom-fab');
13463
+
13464
+ if (!fab) {
13465
+ return;
13466
+ }
13467
+
13468
+ const count = settings.alarmBubble === false ? 0 : unreadAlarmCount();
13469
+
13470
+ if (count) {
13471
+ fab.dataset.dcAlarm = count > 9 ? '9+' : String(count);
13472
+ } else {
13473
+ delete fab.dataset.dcAlarm;
13474
+ }
13475
+ };
13476
+
13477
+ const markAlarmsSeen = () => {
13478
+ const latest = readSiteAlarms()[0];
13479
+
13480
+ writeAlarmMarks({ seen: Math.max(Date.now(), latest ? latest.at : 0) });
13481
+ paintAlarmBadge();
13482
+ };
13483
+
13484
+ let bubbleBox = null;
13485
+
13486
+ const placeBubbleBox = () => {
13487
+ if (!bubbleBox) {
13488
+ return;
13489
+ }
13490
+
13491
+ const fab = document.getElementById('dc-custom-fab');
13492
+ const rect = fab && fab.offsetParent !== null ? fab.getBoundingClientRect() : null;
13493
+ const vw = window.innerWidth;
13494
+ const vh = window.innerHeight;
13495
+
13496
+ if (rect && rect.width) {
13497
+ const onRight = rect.left + rect.width / 2 > vw / 2;
13498
+
13499
+ bubbleBox.style.left = onRight ? '' : `${Math.max(8, rect.left)}px`;
13500
+ bubbleBox.style.right = onRight ? `${Math.max(8, vw - rect.right)}px` : '';
13501
+ bubbleBox.style.bottom = `${Math.max(8, vh - rect.top + 10)}px`;
13502
+ bubbleBox.classList.toggle('dc-left', !onRight);
13503
+ } else {
13504
+ bubbleBox.style.left = '';
13505
+ bubbleBox.style.right = '15px';
13506
+ bubbleBox.style.bottom = 'calc(144px + env(safe-area-inset-bottom))';
13507
+ }
13508
+ };
13509
+
13510
+ const ensureBubbleBox = () => {
13511
+ if (bubbleBox && bubbleBox.isConnected) {
13512
+ return bubbleBox;
13513
+ }
13514
+
13515
+ bubbleBox = h('div', 'dc-ui');
13516
+ bubbleBox.id = 'dc-bubbles';
13517
+ bubbleBox.setAttribute('role', 'log');
13518
+ bubbleBox.setAttribute('aria-live', 'polite');
13519
+ document.body.appendChild(bubbleBox);
13520
+
13521
+ return bubbleBox;
13522
+ };
13523
+
13524
+ const dismissBubble = (bubble, direction = 0) => {
13525
+ if (!bubble || bubble.classList.contains('dc-out')) {
13526
+ return;
13527
+ }
13528
+
13529
+ clearTimeout(bubble._dcTimer);
13530
+ bubble.classList.add('dc-out');
13531
+
13532
+ if (direction) {
13533
+ bubble.style.transform = `translateX(${direction * 120}%)`;
13534
+ }
13535
+
13536
+ setTimeout(() => bubble.remove(), 260);
13537
+ };
13538
+
13539
+ const openAlarm = item => {
13540
+ if (!IS_MOBILE_SITE && item.idx && typeof window.alarmReadOk === 'function') {
13541
+ try {
13542
+ window.alarmReadOk(item.idx);
13543
+ } catch {
13544
+ }
13545
+ }
13546
+
13547
+ markAlarmsSeen();
13548
+
13549
+ if (item.href) {
13550
+ location.href = item.href;
13551
+ }
13552
+ };
13553
+
13554
+ const bindBubbleSwipe = bubble => {
13555
+ let startX = 0;
13556
+ let dx = 0;
13557
+ let dragging = false;
13558
+
13559
+ bubble.addEventListener('pointerdown', event => {
13560
+ startX = event.clientX;
13561
+ dx = 0;
13562
+ dragging = true;
13563
+ clearTimeout(bubble._dcTimer);
13564
+ });
13565
+ bubble.addEventListener('pointermove', event => {
13566
+ if (!dragging) {
13567
+ return;
13568
+ }
13569
+
13570
+ dx = event.clientX - startX;
13571
+
13572
+ if (Math.abs(dx) > 6) {
13573
+ bubble.style.transition = 'none';
13574
+ bubble.style.transform = `translateX(${dx}px)`;
13575
+ bubble.style.opacity = String(Math.max(0.2, 1 - Math.abs(dx) / 220));
13576
+ }
13577
+ });
13578
+
13579
+ const end = () => {
13580
+ if (!dragging) {
13581
+ return;
13582
+ }
13583
+
13584
+ dragging = false;
13585
+ bubble.style.transition = '';
13586
+
13587
+ if (Math.abs(dx) > 70) {
13588
+ bubble._dcSwiped = true;
13589
+ dismissBubble(bubble, Math.sign(dx));
13590
+ } else {
13591
+ bubble.style.transform = '';
13592
+ bubble.style.opacity = '';
13593
+ bubble._dcTimer = setTimeout(() => dismissBubble(bubble), 6000);
13594
+ }
13595
+ };
13596
+
13597
+ bubble.addEventListener('pointerup', end);
13598
+ bubble.addEventListener('pointercancel', end);
13599
+ };
13600
+
13601
+ const showAlarmBubble = (item, extra = 0) => {
13602
+ const box = ensureBubbleBox();
13603
+
13604
+ placeBubbleBox();
13605
+
13606
+ while (box.children.length >= ALARM_BUBBLE_MAX) {
13607
+ box.firstElementChild.remove();
13608
+ }
13609
+
13610
+ const bubble = h('div', 'dc-bubble');
13611
+ const top = h('div', 'dc-bubble-top');
13612
+
13613
+ top.appendChild(h('span', 'dc-bubble-tag', item.tag));
13614
+ top.appendChild(h('span', 'dc-bubble-time', extra ? `새 알림 ${extra + 1}개` : timeAgo(item.at)));
13615
+
13616
+ const close = h('button', 'dc-bubble-x', '×');
13617
+
13618
+ close.type = 'button';
13619
+ close.setAttribute('aria-label', '알림 닫기');
13620
+ close.addEventListener('click', event => {
13621
+ event.stopPropagation();
13622
+ dismissBubble(bubble);
13623
+ });
13624
+ top.appendChild(close);
13625
+
13626
+ bubble.appendChild(top);
13627
+ bubble.appendChild(h('div', 'dc-bubble-text', item.text));
13628
+
13629
+ if (extra) {
13630
+ bubble.appendChild(h('div', 'dc-bubble-more', `외 ${extra}개 · 눌러서 모두 보기`));
13631
+ }
13632
+
13633
+ bubble.addEventListener('click', () => {
13634
+ if (bubble._dcSwiped) {
13635
+ return;
13636
+ }
13637
+
13638
+ dismissBubble(bubble);
13639
+
13640
+ if (extra) {
13641
+ openBlockModal('recent');
13642
+ } else {
13643
+ openAlarm(item);
13644
+ }
13645
+ });
13646
+
13647
+ bindBubbleSwipe(bubble);
13648
+ box.appendChild(bubble);
13649
+ requestAnimationFrame(() => bubble.classList.add('dc-in'));
13650
+ bubble._dcTimer = setTimeout(() => dismissBubble(bubble), 8000);
13651
+ };
13652
+
13653
+ let alarmRawMemo = null;
13654
+ let alarmWatchTimer = null;
13655
+
13656
+ const checkSiteAlarms = () => {
13657
+ if (settings.alarmBubble === false || document.visibilityState === 'hidden') {
13658
+ return;
13659
+ }
13660
+
13661
+ const raw = readAlarmRaw();
13662
+
13663
+ if (raw === alarmRawMemo) {
13664
+ return;
13665
+ }
13666
+
13667
+ alarmRawMemo = raw;
13668
+
13669
+ const list = readSiteAlarms();
13670
+ const marks = readAlarmMarks();
13671
+ const latest = list.length ? list[0].at : 0;
13672
+
13673
+ if (!marks.shown) {
13674
+ writeAlarmMarks({ shown: Math.max(latest, Date.now() - ALARM_BUBBLE_FRESH), seen: marks.seen || latest });
13675
+ paintAlarmBadge();
13676
+ return;
13677
+ }
13678
+
13679
+ const fresh = list.filter(item => item.at > marks.shown && Date.now() - item.at < ALARM_BUBBLE_FRESH);
13680
+
13681
+ if (latest > marks.shown) {
13682
+ writeAlarmMarks({ shown: latest });
13683
+ }
13684
+
13685
+ if (fresh.length) {
13686
+ showAlarmBubble(fresh[0], fresh.length > ALARM_BUBBLE_MAX ? fresh.length - 1 : 0);
13687
+
13688
+ if (fresh.length > 1 && fresh.length <= ALARM_BUBBLE_MAX) {
13689
+ fresh.slice(1).reverse().forEach(item => showAlarmBubble(item));
13690
+ }
13691
+ }
13692
+
13693
+ paintAlarmBadge();
13694
+ };
13695
+
13696
+ let alarmPollAt = Date.now();
13697
+
13698
+ const siteAlarmsOff = () => {
13699
+ try {
13700
+ const list = JSON.parse(localStorage.getItem('alarm_setting') || '[]');
13701
+
13702
+ return Array.isArray(list) && list.some(item => item && item.setting === 'onoff' && item.value !== 'on');
13703
+ } catch {
13704
+ return false;
13705
+ }
13706
+ };
13707
+
13708
+ const keepSiteAlarmPolling = () => {
13709
+ if (!IS_MOBILE_SITE || settings.alarmKeepPolling === false || settings.alarmBubble === false) {
13710
+ return;
13711
+ }
13712
+
13713
+ if (document.visibilityState === 'hidden' || !siteUserId() || siteAlarmsOff() || netPausedUntil()) {
13714
+ return;
13715
+ }
13716
+
13717
+ if (Date.now() - alarmPollAt < ALARM_POLL_GAP || typeof window.alarm_notification_pop !== 'function') {
13718
+ return;
13719
+ }
13720
+
13721
+ alarmPollAt = Date.now();
13722
+
13723
+ try {
13724
+ if (typeof window.set_interval_noti === 'number' && window.set_interval_noti >= 1) {
13725
+ window.set_interval_noti = 1;
13726
+ }
13727
+
13728
+ window.alarm_notification_pop();
13729
+ } catch {
13730
+ }
13731
+ };
13732
+
13733
+ const initSiteAlarms = () => {
13734
+ if (alarmWatchTimer || !alarmStorageKeys().length && !siteUserId()) {
13735
+ paintAlarmBadge();
13736
+ return;
13737
+ }
13738
+
13739
+ checkSiteAlarms();
13740
+ alarmWatchTimer = setInterval(() => {
13741
+ checkSiteAlarms();
13742
+ keepSiteAlarmPolling();
13743
+ }, 2000);
13744
+
13745
+ document.addEventListener('visibilitychange', () => {
13746
+ if (document.visibilityState === 'visible') {
13747
+ checkSiteAlarms();
13748
+ paintAlarmBadge();
13749
+ }
13750
+ });
13751
+
13752
+ window.addEventListener('storage', event => {
13753
+ if (event.key === STORAGE.alarmSeen || /^alarm_(list|notification_)/.test(event.key || '')) {
13754
+ alarmRawMemo = null;
13755
+ checkSiteAlarms();
13756
+ paintAlarmBadge();
13757
+ }
13758
+ });
13759
+
13760
+ window.addEventListener('resize', placeBubbleBox, { passive: true });
13761
+ };
13762
+
13763
+ // =========================================================
13764
+
13765
+ // =========================================================
13766
+
13767
+ const renderRecentView = area => {
13768
+ const alarms = siteUserId() || alarmStorageKeys().length ? readSiteAlarms() : null;
13769
+
13770
+ if (alarms) {
13771
+ const { seen } = readAlarmMarks();
13772
+ const unread = alarms.filter(item => item.at > seen).length;
13773
+
13774
+ area.appendChild(makeSectionTitle(`🔔 알림${unread ? ` · 새 알림 ${unread}개` : ''}`));
13775
+
13776
+ if (!alarms.length) {
13777
+ area.appendChild(makeEmpty('받은 알림이 없습니다. 댓글·답글 등 디시 알림이 오면 여기와 플로팅 버튼 곁 말풍선으로 보입니다.'));
13778
+ } else {
13779
+ alarms.slice(0, 15).forEach(item => {
13780
+ const row = makeRow({
13781
+ title: item.text,
13782
+ chip: item.tag,
13783
+ chipClass: item.at > seen ? 'on' : '',
13784
+ meta: timeAgo(item.at),
13785
+ actions: item.href ? [{ label: '열기', href: item.href }] : []
13786
+ });
13787
+
13788
+ if (item.at > seen) {
13789
+ row.classList.add('dc-row-new');
13790
+ }
13791
+
13792
+ if (item.href) {
13793
+ row.querySelector('a.dc-btn')?.addEventListener('click', () => openAlarm({ ...item, href: '' }));
13794
+ }
13795
+
13796
+ area.appendChild(row);
13797
+ });
13798
+
13799
+ if (unread) {
13800
+ area.appendChild(makeButton('✓ 모두 읽음', 'wide', () => {
13801
+ markAlarmsSeen();
13802
+ rerenderModal();
13803
+ }));
13804
+ }
13805
+ }
13806
+ } else if (IS_MOBILE_SITE || /^gall\.dcinside\.com$/i.test(location.hostname)) {
13807
+ area.appendChild(makeSectionTitle('🔔 알림'));
13808
+ area.appendChild(makeNote('디시에 로그인하면 댓글·답글 알림을 여기와 플로팅 말풍선으로 볼 수 있습니다.'));
13809
+ }
13810
+
13811
+ area.appendChild(makeSectionTitle(`🕘 최근 방문 갤러리 · ${recentGalls.length}개`));
13812
+
13813
+ const syncOn = typeof syncReady === 'function' && syncReady() && !readSyncConf().skip.includes('recent');
13814
+
13815
+ area.appendChild(makeNote(syncOn
13816
+ ? '동기화로 모든 기기의 방문 기록이 합쳐집니다. 디시 화면의 최근 방문 목록에도 함께 보입니다.'
13817
+ : '디시의 최근 방문 목록과 같은 내용입니다. 동기화를 켜면 다른 기기의 방문도 합쳐집니다.'));
13818
+
13819
+ if (!recentGalls.length) {
13820
+ area.appendChild(makeEmpty('아직 방문한 갤러리가 없습니다.'));
13821
+ return;
13822
+ }
13823
+
13824
+ const filterRow = makeFilterRow(recentGalls.length);
13825
+
13826
+ if (filterRow) {
13827
+ area.appendChild(filterRow);
13828
+ }
13829
+
13830
+ recentGalls.forEach(item => {
13831
+ area.appendChild(makeRow({
13832
+ title: item.n || item.id,
13833
+ chip: RECENT_KIND_LABEL[item.k] || '',
13834
+ meta: [item.id, item.at ? timeAgo(item.at) : ''].filter(Boolean).join(' · '),
13835
+ actions: [
13836
+ { label: '열기', href: recentUrl(item) },
13837
+ {
13838
+ label: '지우기',
13839
+ onClick: () => {
13840
+ recentGalls = recentGalls.filter(other => other !== item);
13841
+ saveRecent();
13842
+ exportNativeRecent();
13843
+ rerenderModal();
13844
+ }
13845
+ }
13846
+ ]
13847
+ }));
13848
+ });
13849
+
13850
+ area.appendChild(makeConfirmButton('최근 방문 모두 지우기', '최근 방문 갤러리 목록을 모두 지울까요? (동기화 중이면 다른 기기에서도 지워집니다)', () => {
13851
+ recentGalls = [];
13852
+ saveRecent();
13853
+ exportNativeRecent();
13854
+ rerenderModal();
13855
+ }));
13856
+ };
13857
+
13858
+ (() => {
13859
+ const settingsView = MODAL_VIEWS.settings;
13860
+
13861
+ delete MODAL_VIEWS.settings;
13862
+ MODAL_VIEWS.recent = {
13863
+ title: '알림 · 최근 갤러리',
13864
+ tab: '🕘 최근',
13865
+ render: area => {
13866
+ document.querySelectorAll('#dc-bubbles .dc-bubble').forEach(bubble => dismissBubble(bubble));
13867
+ renderRecentView(area);
13868
+
13869
+ setTimeout(markAlarmsSeen, 600);
13870
+ }
13871
+ };
13872
+ MODAL_VIEWS.settings = settingsView;
13873
+ })();
13874
+
13875
+ initRecentEarly();
12578
13876
  // =========================================================
12579
13877
  // 14. 플로팅 버튼
12580
13878
  // =========================================================
@@ -12892,7 +14190,7 @@
12892
14190
  return;
12893
14191
  }
12894
14192
 
12895
- openBlockModal();
14193
+ openBlockModal(fab.dataset.dcAlarm ? 'recent' : 'overview');
12896
14194
  });
12897
14195
 
12898
14196
  wake();
@@ -13533,6 +14831,7 @@
13533
14831
 
13534
14832
  state.strikes = Date.now() - state.lastStrike < 60 * 60 * 1000 ? state.strikes + 1 : 1;
13535
14833
  state.lastStrike = Date.now();
14834
+ state.src = 'page';
13536
14835
  state.until = Date.now() + Math.min(NET_COOLDOWN_MAX, NET_COOLDOWN * 2 ** (state.strikes - 1));
13537
14836
  writeNetState(state);
13538
14837
  }
@@ -13559,6 +14858,8 @@
13559
14858
  initScrap();
13560
14859
  initScrollIndicator();
13561
14860
  initSync();
14861
+ initRecent();
14862
+ initSiteAlarms();
13562
14863
  restoreListSnapshot();
13563
14864
  processElements();
13564
14865