@kangsicj/dcinsideenhancer 1.3.7 → 1.3.8

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.3.7
5
+ // @version 1.3.8
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 (no account, no tracking).
7
7
  // @description:ko 디시인사이드 차단·편의 기능 (모바일·PC) - 유저·단어(제목/본문)·말머리·공지 차단, 같은/비슷한 이미지·움짤 모든 글 차단, 디시콘 제거, 차단 미디어 데이터 절약, 목록 자동 보충·무한 스크롤·뒤로 가기 위치 복원, 스크랩, 이미지 확대뷰 개선, 야간모드 자동 연동, 암호화 기기 간 동기화 (계정 없음·추적 없음)
8
8
  // @author kangsicj
@@ -37,7 +37,7 @@
37
37
  window.__dcInsideEnhancer = true;
38
38
  window.__dcRefinery = true;
39
39
 
40
- const SCRIPT_VERSION = '1.3.7';
40
+ const SCRIPT_VERSION = '1.3.8';
41
41
  const PROJECT_URL = 'https://github.com/kangsicj/DCInsideEnhancer';
42
42
  const DONATE_URL = 'https://fairy.hada.io/@kangsicj';
43
43
  const DONATE_URL_BMC = 'https://buymeacoffee.com/kangsicj';
@@ -74,7 +74,10 @@
74
74
  searchHistory: 'dc_search_history',
75
75
  blockHits: 'dc_block_hits',
76
76
  alarmSeen: 'dc_alarm_marks',
77
- seenVersion: 'dc_seen_version'
77
+ seenVersion: 'dc_seen_version',
78
+ memos: 'dc_user_memos',
79
+ watch: 'dc_watch',
80
+ liveSeen: 'dc_live_seen'
78
81
  };
79
82
 
80
83
  const OBSOLETE_STORAGE = ['dc_media_hash_cache', 'dc_media_host_caps'];
@@ -194,6 +197,7 @@
194
197
  hoverPreview: true,
195
198
  previewHover: false,
196
199
  alarmSystemNotify: false,
200
+ watchAlerts: true,
197
201
  pcFitWidth: true,
198
202
  autoHideScrollbar: true,
199
203
  scrapIntercept: true,
@@ -203,6 +207,8 @@
203
207
  autoFillList: true,
204
208
  infiniteScroll: true,
205
209
  streamOnPost: true,
210
+ liveRefresh: true,
211
+ liveRefreshSec: 60,
206
212
  showScrollTop: true,
207
213
  keepBlockLog: true,
208
214
  logRetentionDays: 30,
@@ -786,7 +792,9 @@
786
792
  align-items: center;
787
793
  justify-content: flex-end;
788
794
  gap: 8px;
795
+ box-sizing: border-box;
789
796
  width: 100%;
797
+ max-width: 100%;
790
798
  margin: 3px 0 10px;
791
799
  font-size: 11px;
792
800
  line-height: 1.4;
@@ -802,6 +810,12 @@
802
810
  background: var(--dc-ghost-bg);
803
811
  }
804
812
 
813
+ .dc-media-tools.dc-media-tools-blocked:not([style*="width"]),
814
+ .dc-media-tools.dc-media-tools-deferred:not([style*="width"]) {
815
+ width: fit-content;
816
+ min-width: 240px;
817
+ }
818
+
805
819
  .dc-media-tools-label {
806
820
  color: var(--dc-text);
807
821
  opacity: .7;
@@ -1021,6 +1035,11 @@
1021
1035
  #dc-custom-modal .dc-modal-tab .dc-tab-ico { flex: 0 0 auto; font-size: 13px; line-height: 1; }
1022
1036
  #dc-custom-modal .dc-modal-tab .dc-tab-txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; line-height: 1.2; }
1023
1037
 
1038
+ @media (max-width: 520px) {
1039
+ #dc-custom-modal .dc-modal-tab { flex-direction: column; gap: 2px; height: 44px; font-size: 11px; letter-spacing: -.5px; }
1040
+ #dc-custom-modal .dc-modal-tab .dc-tab-ico { font-size: 15px; }
1041
+ }
1042
+
1024
1043
  #dc-custom-modal .dc-section-title {
1025
1044
  display: flex;
1026
1045
  align-items: center;
@@ -2937,6 +2956,160 @@
2937
2956
  animation: none !important;
2938
2957
  }
2939
2958
  }
2959
+
2960
+ table.gall_list .gall_writer .addbox { max-width: none !important; flex-wrap: wrap; }
2961
+ table.gall_list .gall_writer .nickname,
2962
+ table.gall_list .gall_writer .addbox .nickname {
2963
+ max-width: 100% !important;
2964
+ white-space: normal !important;
2965
+ overflow: visible !important;
2966
+ text-overflow: clip !important;
2967
+ word-break: break-all;
2968
+ flex-shrink: 1 !important;
2969
+ }
2970
+ table.gall_list .gall_writer .nickname em { line-height: 15px !important; }
2971
+
2972
+ .dc-memo-chip {
2973
+ display: inline-block;
2974
+ max-width: 12em;
2975
+ margin-left: 4px;
2976
+ padding: 0 5px;
2977
+ border-radius: 6px;
2978
+ background: var(--dc-accent-soft);
2979
+ color: var(--dc-accent-ink);
2980
+ font-size: 11px;
2981
+ line-height: 16px;
2982
+ vertical-align: middle;
2983
+ white-space: nowrap;
2984
+ overflow: hidden;
2985
+ text-overflow: ellipsis;
2986
+ font-weight: 500;
2987
+ }
2988
+ .ginfo li .dc-memo-chip { float: none; }
2989
+ .dc-watch-user::before {
2990
+ content: "⭐";
2991
+ margin-right: 2px;
2992
+ font-size: .85em;
2993
+ }
2994
+ .dc-memo-dialog {
2995
+ position: fixed;
2996
+ inset: 0;
2997
+ z-index: 2147483200;
2998
+ display: flex;
2999
+ align-items: center;
3000
+ justify-content: center;
3001
+ padding: 16px;
3002
+ background: var(--dc-overlay);
3003
+ }
3004
+ .dc-memo-box {
3005
+ width: min(420px, 100%);
3006
+ padding: 16px;
3007
+ border-radius: 14px;
3008
+ background: var(--dc-surface);
3009
+ color: var(--dc-text);
3010
+ box-shadow: var(--dc-shadow);
3011
+ font-size: 14px;
3012
+ }
3013
+ .dc-memo-head { display: flex; gap: 8px; align-items: baseline; margin-bottom: 10px; min-width: 0; }
3014
+ .dc-memo-head span { color: var(--dc-text-dim); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3015
+ .dc-memo-box textarea {
3016
+ box-sizing: border-box;
3017
+ width: 100%;
3018
+ min-height: 96px;
3019
+ padding: 10px;
3020
+ border: 1px solid var(--dc-border);
3021
+ border-radius: 10px;
3022
+ background: var(--dc-surface-2);
3023
+ color: var(--dc-text);
3024
+ font: inherit;
3025
+ font-size: 16px;
3026
+ resize: vertical;
3027
+ }
3028
+ .dc-memo-sub { margin-top: 6px; color: var(--dc-text-dim); font-size: 12px; }
3029
+ .dc-memo-actions { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
3030
+ .dc-memo-actions span { flex: 1; }
3031
+ .dc-memo-actions button {
3032
+ padding: 8px 14px;
3033
+ border: 1px solid var(--dc-border);
3034
+ border-radius: 10px;
3035
+ background: var(--dc-surface);
3036
+ color: var(--dc-text);
3037
+ font-size: 14px;
3038
+ cursor: pointer;
3039
+ }
3040
+ .dc-memo-actions button.primary { border-color: var(--dc-accent); background: var(--dc-accent); color: var(--dc-on-accent); }
3041
+ .dc-memo-actions button.danger { border-color: transparent; background: var(--dc-danger-soft); color: var(--dc-danger-ink); }
3042
+ .dc-block-popover .dc-pop-extra { display: flex; gap: 4px; margin-top: 6px; }
3043
+ .dc-block-popover .dc-pop-mini {
3044
+ flex: 1;
3045
+ padding: 6px 4px;
3046
+ border: 1px solid var(--dc-border);
3047
+ border-radius: 8px;
3048
+ background: transparent;
3049
+ color: var(--dc-text);
3050
+ font-size: 12px;
3051
+ white-space: nowrap;
3052
+ cursor: pointer;
3053
+ }
3054
+ .dc-block-popover .dc-pop-mini:hover { background: var(--dc-accent-soft); }
3055
+ .dc-block-popover .dc-pop-mini.on { border-color: var(--dc-accent); color: var(--dc-accent-ink); }
3056
+ .dc-author-bar {
3057
+ display: flex;
3058
+ gap: 8px;
3059
+ align-items: center;
3060
+ justify-content: space-between;
3061
+ margin: 6px 0;
3062
+ padding: 8px 12px;
3063
+ border-radius: 10px;
3064
+ background: var(--dc-accent-soft);
3065
+ color: var(--dc-text);
3066
+ font-size: 13px;
3067
+ line-height: 1.4;
3068
+ }
3069
+ .dc-author-bar button {
3070
+ flex: none;
3071
+ padding: 4px 10px;
3072
+ border: 1px solid var(--dc-border);
3073
+ border-radius: 8px;
3074
+ background: var(--dc-surface);
3075
+ color: var(--dc-accent-ink);
3076
+ font-size: 12px;
3077
+ cursor: pointer;
3078
+ }
3079
+ .dc-author-other { display: none !important; }
3080
+ html.dc-peek .dc-author-other { display: revert !important; opacity: .45; }
3081
+ .dc-live-pill {
3082
+ position: fixed;
3083
+ top: calc(env(safe-area-inset-top, 0px) + 64px);
3084
+ left: 50%;
3085
+ z-index: 2147483000;
3086
+ padding: 8px 16px;
3087
+ border: none;
3088
+ border-radius: 999px;
3089
+ background: var(--dc-accent);
3090
+ color: var(--dc-on-accent);
3091
+ box-shadow: var(--dc-shadow-sm);
3092
+ font-size: 13px;
3093
+ font-weight: 600;
3094
+ opacity: 0;
3095
+ transform: translate(-50%, -12px);
3096
+ pointer-events: none;
3097
+ transition: opacity .2s ease, transform .2s ease;
3098
+ cursor: pointer;
3099
+ }
3100
+ .dc-live-pill.show { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
3101
+ .dc-live-new { animation: dc-live-flash 3.5s ease-out; }
3102
+ .dc-live-new > td { animation: dc-live-flash 3.5s ease-out; }
3103
+ @keyframes dc-live-flash {
3104
+ 0%, 40% { background-color: rgba(91,110,214,.16); }
3105
+ 100% { background-color: transparent; }
3106
+ }
3107
+ .dc-live-watch .subjectin,
3108
+ .dc-live-watch .gall_tit a:first-child { font-weight: 700; }
3109
+ @media (prefers-reduced-motion: reduce) {
3110
+ .dc-live-pill { transition: none; }
3111
+ .dc-live-new, .dc-live-new > td { animation: none; }
3112
+ }
2940
3113
  `;
2941
3114
 
2942
3115
  let styleEl = null;
@@ -3542,7 +3715,7 @@
3542
3715
 
3543
3716
  let netQueue = Promise.resolve();
3544
3717
 
3545
- const siteFetchText = (url, options = {}, { background = false, auto = false } = {}) => {
3718
+ const siteFetchText = (url, options = {}, { background = false, auto = false, probe = false } = {}) => {
3546
3719
  const run = async () => {
3547
3720
  let state = readNetState();
3548
3721
 
@@ -3623,6 +3796,14 @@
3623
3796
 
3624
3797
  let { response, text } = await fetchOnce('');
3625
3798
 
3799
+ if (probe) {
3800
+ if (!response.ok) {
3801
+ throw new Error(`HTTP ${response.status}`);
3802
+ }
3803
+
3804
+ return text;
3805
+ }
3806
+
3626
3807
  if (looksBlockedResponse(response, text)) {
3627
3808
  if (unreliableWindow(startedAt) || truncatedResponse(response, text)) {
3628
3809
  throw new NetTransientError('unreliable');
@@ -5697,6 +5878,76 @@
5697
5878
  const isDeferActive = () =>
5698
5879
  settings.deferHeavyMedia === true || (settings.autoSaveData !== false && connectionSaveData());
5699
5880
 
5881
+ const mediaBoxes = new WeakMap();
5882
+
5883
+ const fitToolsToMedia = (mediaEl, tools) => {
5884
+ const parent = tools.parentElement;
5885
+
5886
+ let box = mediaBoxes.get(mediaEl) || tools._dcBox;
5887
+
5888
+ if (mediaBoxes.has(mediaEl)) {
5889
+ tools._dcBox = box;
5890
+ }
5891
+
5892
+ if (!box && mediaEl.tagName === 'IMG') {
5893
+ const width = Number(mediaEl.getAttribute('width')) || mediaEl.naturalWidth || 0;
5894
+
5895
+ box = width > 40 ? { width, left: null } : null;
5896
+ }
5897
+
5898
+ if (!box || !parent) {
5899
+ tools.style.removeProperty('width');
5900
+ tools.style.removeProperty('margin-left');
5901
+ return;
5902
+ }
5903
+
5904
+ const parentRect = parent.getBoundingClientRect();
5905
+ const room = parent.clientWidth || parentRect.width;
5906
+ const width = Math.min(box.width, room || box.width);
5907
+ let left = box.left === null ? 0 : box.left - parentRect.left - (parseFloat(getComputedStyle(parent).paddingLeft) || 0);
5908
+
5909
+ if (!(left > 0) || left + width > room + 1) {
5910
+ left = 0;
5911
+ }
5912
+
5913
+ tools.style.setProperty('width', `${Math.round(width)}px`);
5914
+ tools.style.setProperty('margin-left', `${Math.round(left)}px`);
5915
+ };
5916
+
5917
+ const refitMediaTools = mediaEl => {
5918
+ const tools = mediaTools.get(mediaEl);
5919
+ const host = mediaHosts.get(mediaEl) || mediaEl;
5920
+
5921
+ if (!tools || !tools.isConnected) {
5922
+ return;
5923
+ }
5924
+
5925
+ if (!host.classList.contains('dc-media-hidden')) {
5926
+ const shown = mediaEl.getBoundingClientRect();
5927
+
5928
+ if (shown.width > 40) {
5929
+ mediaBoxes.set(mediaEl, { width: shown.width, left: shown.left });
5930
+ }
5931
+ }
5932
+
5933
+ fitToolsToMedia(mediaEl, tools);
5934
+ };
5935
+
5936
+ let refitTimer = null;
5937
+
5938
+ window.addEventListener('resize', () => {
5939
+ clearTimeout(refitTimer);
5940
+ refitTimer = setTimeout(() => {
5941
+ document.querySelectorAll('.dc-media-tools').forEach(tools => {
5942
+ const mediaEl = tools.previousElementSibling && tools.previousElementSibling.matches('img, video') ? tools.previousElementSibling : null;
5943
+
5944
+ if (mediaEl) {
5945
+ refitMediaTools(mediaEl);
5946
+ }
5947
+ });
5948
+ }, 200);
5949
+ }, { passive: true });
5950
+
5700
5951
  const readToolIds = tools => (tools.dataset.dcMediaIds || '').split(',').filter(Boolean);
5701
5952
 
5702
5953
  const createTools = () => {
@@ -5796,6 +6047,12 @@
5796
6047
  mediaEl.classList.remove('dc-media-hidden');
5797
6048
  }
5798
6049
 
6050
+ const shown = mediaEl.getBoundingClientRect();
6051
+
6052
+ if (shown.width > 40 && !blocked && !deferred && !host.classList.contains('dc-media-hidden')) {
6053
+ mediaBoxes.set(mediaEl, { width: shown.width, left: shown.left });
6054
+ }
6055
+
5799
6056
  host.classList.toggle('dc-media-hidden', blocked || deferred);
5800
6057
 
5801
6058
  const tools = existing && existing.isConnected ? existing : createTools();
@@ -5824,6 +6081,12 @@
5824
6081
  tools.querySelector('.dc-media-btn-load').hidden = !deferred;
5825
6082
 
5826
6083
  mediaSignature.set(mediaEl, signature);
6084
+ fitToolsToMedia(mediaEl, tools);
6085
+
6086
+ if (!mediaBoxes.has(mediaEl) && !blocked && !deferred && !mediaEl.dataset.dcFitWatch) {
6087
+ mediaEl.dataset.dcFitWatch = '1';
6088
+ mediaEl.addEventListener(mediaEl.tagName === 'VIDEO' ? 'loadedmetadata' : 'load', () => refitMediaTools(mediaEl), { once: true });
6089
+ }
5827
6090
 
5828
6091
  return tools;
5829
6092
  };
@@ -6161,19 +6424,29 @@
6161
6424
  }
6162
6425
 
6163
6426
  let counts = null;
6427
+ let answered = false;
6164
6428
 
6165
6429
  const $ = window.jQuery;
6166
6430
 
6167
6431
  if ($ && $.ajax) {
6168
6432
  counts = await new Promise(resolve => {
6169
6433
  $.ajax({ url: '/ajax/userCnt', type: 'post', data: { user: code }, timeout: 6000 })
6170
- .done(res => resolve(res && res.board ? { post: String(res.board), reply: String(res.comment || '0') } : null))
6434
+ .done(res => {
6435
+ answered = true;
6436
+ resolve(res && res.board ? { post: String(res.board), reply: String(res.comment || '0') } : null);
6437
+ })
6171
6438
  .fail(() => resolve(null));
6172
6439
  });
6173
6440
  }
6174
6441
 
6442
+ if (answered) {
6443
+ gallogCountCache.set(code, counts);
6444
+
6445
+ return counts;
6446
+ }
6447
+
6175
6448
  if (!counts) {
6176
- const html = await siteFetchText(`https://m.dcinside.com/gallog/${encodeURIComponent(code)}`, { credentials: 'same-origin' });
6449
+ const html = await siteFetchText(`https://m.dcinside.com/gallog/${encodeURIComponent(code)}`, { credentials: 'same-origin' }, { probe: true });
6177
6450
  const text = html.replace(/<[^>]+>/g, ' ');
6178
6451
  const post = (text.match(/게시물\s*\(\s*([\d,]+)\s*\)/) || [])[1] || '';
6179
6452
  const reply = (text.match(/댓글\s*\(\s*([\d,]+)\s*\)/) || [])[1] || '';
@@ -6207,10 +6480,11 @@
6207
6480
  const ip = (keys.find(k => k.type === 'ip') || {}).value || '';
6208
6481
  const target = code || ip;
6209
6482
  const gallog = code ? `/gallog/${encodeURIComponent(code)}` : '';
6210
- const search = mobileSearchUrl(nick);
6483
+ const search = authorSearchUrl(keys) || mobileSearchUrl(nick);
6211
6484
  const me = (document.getElementById('user_id') || {}).value || '';
6212
6485
  const isMe = !!code && code === me;
6213
- const canMemo = !!target && typeof window.openMemoLayerView === 'function';
6486
+ const memo = memoForKeys(keys);
6487
+ const watched = isWatchedKeys(keys);
6214
6488
  const blockLabel = (type, value, label) => `${blockedUsers.includes(`${type}::${value}`) ? '차단 해제' : '차단'}(${label})`;
6215
6489
  const row = (attrs, label) => `<li><a ${attrs}>${escapeHtml(label)}</a></li>`;
6216
6490
  const menu = document.createElement('div');
@@ -6233,7 +6507,8 @@
6233
6507
  '<ul class="lst">' +
6234
6508
  (code ? row(`href="${escapeHtml(gallog)}"`, '갤로그') : '') +
6235
6509
  (search ? row(`href="${escapeHtml(search)}"`, '작성글 검색') : '') +
6236
- (!isMe && canMemo ? row('href="#" data-act="memo"', '이용자 메모') : '') +
6510
+ (!isMe ? row('href="#" data-act="memo"', memo ? `📝 메모 · ${memo.text.length > 14 ? `${memo.text.slice(0, 14)}…` : memo.text}` : '📝 메모 남기기') : '') +
6511
+ (!isMe ? row('href="#" data-act="watch"', watched ? '⭐ 관심 사용자 해제' : '☆ 관심 사용자 (새 글 알림)') : '') +
6237
6512
  (!isMe && nick ? row(`href="#" data-key="${escapeHtml(`nick::${nick}`)}"`, blockLabel('nick', nick, '닉네임')) : '') +
6238
6513
  (!isMe && code ? row(`href="#" data-key="${escapeHtml(`id::${code}`)}"`, blockLabel('id', code, '식별 코드')) : '') +
6239
6514
  (!isMe && !code && ip ? row(`href="#" data-key="${escapeHtml(`ip::${ip}`)}"`, blockLabel('ip', ip, 'IP')) : '') +
@@ -6251,7 +6526,14 @@
6251
6526
  if (link.dataset.act === 'memo') {
6252
6527
  event.preventDefault();
6253
6528
  closePopover();
6254
- window.openMemoLayerView(target);
6529
+ openMemoEditor(keys, nick || target);
6530
+ return;
6531
+ }
6532
+
6533
+ if (link.dataset.act === 'watch') {
6534
+ event.preventDefault();
6535
+ closePopover();
6536
+ toggleWatchUser(keys);
6255
6537
  return;
6256
6538
  }
6257
6539
 
@@ -6328,9 +6610,36 @@
6328
6610
  );
6329
6611
  })
6330
6612
  .join('') +
6613
+ '<div class="dc-pop-extra">' +
6614
+ `<button type="button" class="dc-pop-mini" data-act="memo" title="${memoForKeys(keys) ? '메모 고치기' : '메모 남기기'}">📝 메모</button>` +
6615
+ `<button type="button" class="dc-pop-mini${isWatchedKeys(keys) ? ' on' : ''}" data-act="watch" title="${isWatchedKeys(keys) ? '관심 사용자 해제' : '관심 사용자 등록 (새 글 알림)'}">${isWatchedKeys(keys) ? '⭐ 관심' : '☆ 관심'}</button>` +
6616
+ (authorSearchUrl(keys) ? '<button type="button" class="dc-pop-mini" data-act="search">🔍 작성글</button>' : '') +
6617
+ '</div>' +
6331
6618
 
6332
6619
  '<button type="button" class="dc-pop-site">⋯ 사이트 메뉴 (갤로그·작성글)</button>';
6333
6620
 
6621
+ popover.querySelector('.dc-pop-extra').addEventListener('click', event => {
6622
+ const act = event.target instanceof Element ? event.target.closest('[data-act]')?.dataset.act : '';
6623
+
6624
+ if (!act) {
6625
+ return;
6626
+ }
6627
+
6628
+ event.preventDefault();
6629
+ event.stopPropagation();
6630
+ closePopover();
6631
+
6632
+ const nickKey = keys.find(k => k.type === 'nick');
6633
+
6634
+ if (act === 'memo') {
6635
+ openMemoEditor(keys, nickKey ? nickKey.value : '');
6636
+ } else if (act === 'watch') {
6637
+ toggleWatchUser(keys);
6638
+ } else if (act === 'search') {
6639
+ location.href = authorSearchUrl(keys);
6640
+ }
6641
+ });
6642
+
6334
6643
  popover.querySelector('.dc-pop-site').addEventListener('click', event => {
6335
6644
  event.preventDefault();
6336
6645
  event.stopPropagation();
@@ -7136,6 +7445,25 @@
7136
7445
  hideEmptyReplyGroups(collapse);
7137
7446
  });
7138
7447
 
7448
+ const restoreFullNick = (nickElement, fullNick) => {
7449
+ if (!fullNick || !nickElement || nickElement.dataset.dcFullNick) {
7450
+ return;
7451
+ }
7452
+
7453
+ const walker = document.createTreeWalker(nickElement, NodeFilter.SHOW_TEXT);
7454
+
7455
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
7456
+ const match = node.nodeValue.match(/^(\s*)(.+?)(\.\.|…)(\s*(?:\([^)]*\))?\s*)$/);
7457
+
7458
+ if (match && match[2].length < fullNick.length && fullNick.startsWith(match[2].trim())) {
7459
+ node.nodeValue = `${match[1]}${fullNick}${match[4]}`;
7460
+ nickElement.dataset.dcFullNick = 'true';
7461
+ nickElement.title = fullNick;
7462
+ break;
7463
+ }
7464
+ }
7465
+ };
7466
+
7139
7467
  const processUserNode = (container, nickElement, source) => {
7140
7468
  if (!container || !nickElement || container.dataset.dcProcessed) {
7141
7469
  return;
@@ -7157,7 +7485,9 @@
7157
7485
 
7158
7486
  const keys = buildBlockKeys(info);
7159
7487
 
7488
+ restoreFullNick(nickElement, info.nickname);
7160
7489
  attachBlockTrigger(nickElement, keys);
7490
+ decorateUserNode(nickElement, keys);
7161
7491
 
7162
7492
  const area = IS_MOBILE_SITE ? nickElement.closest('.ginfo-area') : null;
7163
7493
 
@@ -7414,6 +7744,7 @@
7414
7744
  processMediaElements();
7415
7745
  pinReadColors();
7416
7746
  applyUserBlockState();
7747
+ applyAuthorFilter();
7417
7748
  applyHeadTabVisibility();
7418
7749
  bindHeadTabLongPress();
7419
7750
  maintainListStream();
@@ -9866,6 +10197,7 @@
9866
10197
  ];
9867
10198
 
9868
10199
  let blockSection = 'keywords';
10200
+ let userBlockType = 'nick';
9869
10201
  let blockFilter = '';
9870
10202
 
9871
10203
  const makeInputRow = (placeholder, buttonLabel, onSubmit, { maxLength = WORD_MAX_LENGTH } = {}) => {
@@ -10182,39 +10514,107 @@
10182
10514
  },
10183
10515
 
10184
10516
  users: area => {
10185
- area.appendChild(makeSectionTitle(`👤 차단한 유저 (${blockedUsers.length})`));
10517
+ const entries = blockedUsers.map(parseBlockKey);
10518
+ const count = type => entries.filter(entry => entry.type === type).length;
10519
+
10520
+ area.append(
10521
+ makeSectionTitle(`👤 유저 차단 (${blockedUsers.length})`),
10522
+ makeNote('기준마다 따로 모아 봅니다. 닉네임 = 같은 닉네임 모두(유동 포함), 식별코드 = 그 고닉 한 사람, IP = 그 유동 IP 대역. 닉네임을 눌러 차단하면 이곳에 쌓입니다.')
10523
+ );
10524
+
10525
+ const seg = h('div', 'dc-seg dc-word-scope');
10526
+
10527
+ Object.keys(BLOCK_TYPE_LABELS).forEach(type => {
10528
+ const btn = h('button', `dc-seg-btn${userBlockType === type ? ' active' : ''}`);
10186
10529
 
10187
- const filterRow = makeFilterRow(blockedUsers.length);
10530
+ btn.type = 'button';
10531
+ btn.setAttribute('aria-pressed', String(userBlockType === type));
10532
+ btn.append(h('span', '', BLOCK_TYPE_LABELS[type]), h('span', 'dc-seg-count', String(count(type))));
10533
+ btn.addEventListener('click', () => {
10534
+ userBlockType = type;
10535
+ blockFilter = '';
10536
+ rerenderModal();
10537
+ });
10538
+ seg.appendChild(btn);
10539
+ });
10540
+
10541
+ area.appendChild(seg);
10542
+ area.appendChild(
10543
+ makeInputRow(
10544
+ userBlockType === 'ip' ? 'IP 앞자리 (예: 118.235)' : userBlockType === 'id' ? '식별코드 (갤로그 주소의 아이디)' : '닉네임',
10545
+ '차단',
10546
+ value => {
10547
+ const values = String(value)
10548
+ .split(/[,\n]/)
10549
+ .map(item => item.trim())
10550
+ .filter(Boolean)
10551
+ .filter(item => userBlockType !== 'ip' || /^\d{1,3}\.\d{1,3}$/.test(item));
10552
+ let added = 0;
10553
+
10554
+ values.forEach(item => {
10555
+ const key = `${userBlockType}::${item}`;
10556
+
10557
+ if (!blockedUsers.includes(key)) {
10558
+ blockedUsers = [...blockedUsers, key];
10559
+ added += 1;
10560
+ }
10561
+ });
10562
+
10563
+ if (added) {
10564
+ saveUsers();
10565
+ refreshAfterBlockChange();
10566
+ } else if (userBlockType === 'ip' && values.length === 0) {
10567
+ showToast('IP 는 앞 두 자리(예: 118.235) 형식으로 넣어 주세요');
10568
+ }
10569
+
10570
+ rerenderModal();
10571
+ },
10572
+ { maxLength: 400 }
10573
+ )
10574
+ );
10575
+
10576
+ const list = entries.filter(entry => entry.type === userBlockType);
10577
+ const filterRow = makeFilterRow(list.length);
10188
10578
 
10189
10579
  if (filterRow) {
10190
10580
  area.appendChild(filterRow);
10191
10581
  }
10192
10582
 
10193
- if (!blockedUsers.length) {
10194
- area.appendChild(makeEmpty('차단한 유저가 없습니다.'));
10195
- return;
10583
+ if (!list.length) {
10584
+ area.appendChild(makeEmpty(`${BLOCK_TYPE_LABELS[userBlockType]}(으)로 차단한 유저가 없습니다.`));
10196
10585
  }
10197
10586
 
10198
- blockedUsers
10199
- .map(parseBlockKey)
10200
- .forEach(entry => {
10201
- area.appendChild(
10202
- makeRow({
10203
- title: entry.value,
10204
- meta: `${BLOCK_TYPE_LABELS[entry.type]} 기준`,
10205
- actions: [{ label: '해제', className: 'on', onClick: () => { toggleUserBlockKey(entry.key); rerenderModal(); } }]
10206
- })
10207
- );
10208
- });
10587
+ const memos = readMemos();
10209
10588
 
10210
- area.appendChild(
10211
- makeConfirmButton('유저 차단 전체 해제', '차단한 유저를 모두 해제할까요?', () => {
10212
- blockedUsers = [];
10213
- saveUsers();
10214
- refreshAfterBlockChange();
10215
- rerenderModal();
10216
- })
10217
- );
10589
+ list.forEach(entry => {
10590
+ const memo = (memos[entry.key] || {}).t;
10591
+
10592
+ area.appendChild(
10593
+ makeRow({
10594
+ title: entry.value,
10595
+ chip: BLOCK_TYPE_LABELS[entry.type],
10596
+ chipClass: BLOCK_TYPE_CLASS[entry.type],
10597
+ meta: memo ? `📝 ${memo}` : '',
10598
+ actions: [
10599
+ { label: '메모', onClick: () => openMemoEditor([entry], entry.value) },
10600
+ { label: '해제', className: 'on', onClick: () => { toggleUserBlockKey(entry.key); rerenderModal(); } }
10601
+ ]
10602
+ })
10603
+ );
10604
+ });
10605
+
10606
+ applyListFilter(area);
10607
+
10608
+ if (list.length) {
10609
+ area.appendChild(
10610
+ makeConfirmButton(`${BLOCK_TYPE_LABELS[userBlockType]} 차단 전체 해제`, `${BLOCK_TYPE_LABELS[userBlockType]}(으)로 차단한 유저 ${list.length}명을 모두 해제할까요?`, () => {
10611
+ blockedUsers = blockedUsers.filter(key => parseBlockKey(key).type !== userBlockType);
10612
+ saveUsers();
10613
+ refreshAfterBlockChange();
10614
+ rerenderModal();
10615
+ })
10616
+ );
10617
+ }
10218
10618
  },
10219
10619
 
10220
10620
  media: area => {
@@ -10501,6 +10901,19 @@
10501
10901
  off: '이 탭을 볼 때 말풍선으로만 알립니다.',
10502
10902
  refresh: 'root'
10503
10903
  },
10904
+ {
10905
+ group: 'alarm',
10906
+ key: 'watchAlerts',
10907
+ name: '관심 키워드·관심 사용자 새 글 알림',
10908
+ on: '새 글 자동 이어 붙이기로 들어온 글 중 관심 키워드가 제목에 있거나 관심 사용자가 쓴 글이면 알림을 띄웁니다. 관심 목록은 🔔 관심 탭에서 관리합니다.',
10909
+ off: '관심 글도 따로 알리지 않습니다 (목록에는 그대로 붙습니다).',
10910
+ status: on => {
10911
+ const watch = readWatch();
10912
+
10913
+ return on ? `관심 키워드 ${watch.words.length}개 · 관심 사용자 ${watch.users.length}명${settings.liveRefresh === false ? ' · 새 글 자동 이어 붙이기가 꺼져 있어 쉬는 중' : ''}` : null;
10914
+ },
10915
+ refresh: 'none'
10916
+ },
10504
10917
  {
10505
10918
  group: 'alarm',
10506
10919
  key: 'alarmKeepPolling',
@@ -10579,6 +10992,29 @@
10579
10992
  : null,
10580
10993
  refresh: 'stream'
10581
10994
  },
10995
+ {
10996
+ group: 'list',
10997
+ key: 'liveRefresh',
10998
+ name: '새 글 자동으로 이어 붙이기',
10999
+ on: '목록 1페이지를 보는 동안 새 글을 확인해 새로고침 없이 맨 위에 붙입니다. 내려서 읽는 중이면 읽던 자리를 그대로 두고 [↑ 새 글 N개] 만 띄웁니다. 화면을 보고 있을 때만, 10분 넘게 손대지 않으면 쉽니다.',
11000
+ off: '새 글을 보려면 직접 새로고침합니다.',
11001
+ status: on => (on ? liveStatusText() : null),
11002
+ refresh: 'live'
11003
+ },
11004
+ {
11005
+ group: 'list',
11006
+ key: 'liveRefreshSec',
11007
+ parent: 'liveRefresh',
11008
+ name: '새 글 확인 간격',
11009
+ on: '짧을수록 빨리 보이지만 요청이 늘어납니다. 디시 서버에 부담이 되지 않게 30초보다 짧게는 확인하지 않습니다.',
11010
+ choices: [
11011
+ { value: 30, label: '30초' },
11012
+ { value: 60, label: '1분' },
11013
+ { value: 120, label: '2분' },
11014
+ { value: 300, label: '5분' }
11015
+ ],
11016
+ refresh: 'live'
11017
+ },
10582
11018
  {
10583
11019
  group: 'list',
10584
11020
  key: 'hideReadMark',
@@ -10781,6 +11217,10 @@
10781
11217
  processElements();
10782
11218
  break;
10783
11219
 
11220
+ case 'live':
11221
+ scheduleLive();
11222
+ break;
11223
+
10784
11224
  case 'theme':
10785
11225
  coordinateOtherDarkScripts();
10786
11226
 
@@ -10809,7 +11249,7 @@
10809
11249
 
10810
11250
  const BACKUP_APP = 'dcinside-enhancer';
10811
11251
  const BACKUP_APP_OLD = ['dc-refinery', 'dc-optimizer'];
10812
- const BACKUP_KEYS = ['users', 'images', 'keywords', 'titles', 'heads', 'settings', 'scraps'];
11252
+ const BACKUP_KEYS = ['users', 'images', 'keywords', 'titles', 'heads', 'settings', 'scraps', 'memos', 'watch'];
10813
11253
  const USER_KEY_RE = /^(?:nick|ip|id)::.+/;
10814
11254
 
10815
11255
  const exportData = () => ({
@@ -10823,6 +11263,8 @@
10823
11263
  keywords: blockedKeywords,
10824
11264
  titles: blockedTitles,
10825
11265
  heads: hiddenHeadMap,
11266
+ memos: Object.fromEntries(Object.entries(readMemos()).filter(([, item]) => item.t)),
11267
+ watch: readWatch(),
10826
11268
  settings
10827
11269
  });
10828
11270
 
@@ -10880,12 +11322,36 @@
10880
11322
 
10881
11323
  const importedScraps = Array.isArray(parsed.scraps) ? uniqueScraps(parsed.scraps.filter(isValidScrap).map(normalizeScrap)) : null;
10882
11324
 
11325
+ let memos = null;
11326
+
11327
+ if (parsed.memos && typeof parsed.memos === 'object' && !Array.isArray(parsed.memos)) {
11328
+ memos = {};
11329
+ Object.entries(parsed.memos).forEach(([key, item]) => {
11330
+ const text = item && typeof item === 'object' ? String(item.t || '').trim().slice(0, 300) : '';
11331
+
11332
+ if (USER_KEY_RE.test(key) && text) {
11333
+ memos[key] = { t: text, at: Number(item.at) || Date.now() };
11334
+ }
11335
+ });
11336
+ }
11337
+
11338
+ let watch = null;
11339
+
11340
+ if (parsed.watch && typeof parsed.watch === 'object' && !Array.isArray(parsed.watch)) {
11341
+ watch = {
11342
+ users: Array.isArray(parsed.watch.users) ? [...new Set(parsed.watch.users.filter(key => typeof key === 'string' && USER_KEY_RE.test(key)))] : [],
11343
+ words: Array.isArray(parsed.watch.words) ? [...new Set(parsed.watch.words.map(word => normalizeWord(String(word || ''))).filter(word => word && word.length <= 60))] : []
11344
+ };
11345
+ }
11346
+
10883
11347
  return {
10884
11348
  users,
10885
11349
  images,
10886
11350
  keywords,
10887
11351
  titles,
10888
11352
  heads,
11353
+ memos,
11354
+ watch,
10889
11355
  scraps: importedScraps,
10890
11356
  settings: importedSettings,
10891
11357
  meta: {
@@ -10906,6 +11372,8 @@
10906
11372
  if (data.heads) parts.push(`말머리 ${headCount}${headGalleries ? `(갤러리 ${headGalleries})` : ''}`);
10907
11373
  if (data.images) parts.push(`이미지 ${data.images.length}`);
10908
11374
  if (data.scraps) parts.push(`스크랩 ${data.scraps.length}`);
11375
+ if (data.memos && Object.keys(data.memos).length) parts.push(`메모 ${Object.keys(data.memos).length}`);
11376
+ if (data.watch && data.watch.users.length + data.watch.words.length) parts.push(`관심 ${data.watch.users.length + data.watch.words.length}`);
10909
11377
  if (data.settings) parts.push('설정 포함');
10910
11378
 
10911
11379
  return parts.join(' · ') || '비어 있음';
@@ -10968,6 +11436,29 @@
10968
11436
  saveHiddenHeads();
10969
11437
  }
10970
11438
 
11439
+ if (data.memos) {
11440
+ const now = Date.now();
11441
+ const current = readMemos();
11442
+ const next = merge ? { ...current } : {};
11443
+
11444
+ if (!merge) {
11445
+ Object.keys(current).forEach(key => {
11446
+ next[key] = { t: '', at: now };
11447
+ });
11448
+ }
11449
+
11450
+ Object.entries(data.memos).forEach(([key, item]) => {
11451
+ next[key] = { t: item.t, at: Math.max(now, item.at) };
11452
+ });
11453
+ writeMemos(next);
11454
+ }
11455
+
11456
+ if (data.watch) {
11457
+ const current = readWatch();
11458
+
11459
+ writeWatch(merge ? { users: [...current.users, ...data.watch.users], words: [...current.words, ...data.watch.words] } : data.watch);
11460
+ }
11461
+
10971
11462
  if (data.settings && !merge) {
10972
11463
  settings = { ...settings, ...data.settings };
10973
11464
  saveSettings();
@@ -11845,6 +12336,8 @@
11845
12336
  { key: 'scraps', label: '스크랩', fields: ['scraps'] },
11846
12337
  { key: 'recent', label: '최근 갤러리', fields: ['recent', 'recentOrder'] },
11847
12338
  { key: 'search', label: '검색 기록', fields: ['search'] },
12339
+ { key: 'memos', label: '메모', fields: ['memos'] },
12340
+ { key: 'watch', label: '관심 알림', fields: ['watch'] },
11848
12341
  { key: 'settings', label: '설정', fields: ['settings'] }
11849
12342
  ];
11850
12343
 
@@ -12027,7 +12520,9 @@
12027
12520
  recent: collectRecentForSync(),
12028
12521
  recentOrder: collectRecentOrderForSync(),
12029
12522
  search: collectSearchForSync(),
12030
- hits: collectHitsForSync()
12523
+ hits: collectHitsForSync(),
12524
+ memos: collectMemosForSync(),
12525
+ watch: readWatch()
12031
12526
  });
12032
12527
 
12033
12528
  const skippedFields = skip => SYNC_PARTS.filter(part => skip.includes(part.key)).flatMap(part => part.fields);
@@ -12137,7 +12632,9 @@
12137
12632
  recent: mergeRecent(local.recent, base && base.recent, remote && remote.recent),
12138
12633
  recentOrder: mergeRecentOrder(local.recentOrder, remote && remote.recentOrder),
12139
12634
  search: mergeSearchHistory(local.search, base && base.search, remote && remote.search),
12140
- hits: mergeBlockHits(local.hits, remote && remote.hits)
12635
+ hits: mergeBlockHits(local.hits, remote && remote.hits),
12636
+ memos: mergeMemos(local.memos, remote && remote.memos),
12637
+ watch: mergeWatch(local.watch, base && base.watch, remote && remote.watch)
12141
12638
  };
12142
12639
 
12143
12640
  skippedFields(skip).forEach(field => {
@@ -12173,6 +12670,12 @@
12173
12670
  }
12174
12671
  if (after.heads) diff('말머리', flatHeads(before.heads), flatHeads(after.heads));
12175
12672
  if (after.scraps) diff('스크랩', (before.scraps || []).map(scrapKeyOf), after.scraps.map(scrapKeyOf));
12673
+ if (after.memos) {
12674
+ const memoText = memos => Object.entries(memos || {}).filter(([, item]) => item && item.t).map(([key, item]) => `${key}\u0000${item.t}`);
12675
+
12676
+ diff('메모', memoText(before.memos), memoText(after.memos));
12677
+ }
12678
+ if (after.watch) diff('관심', [...((before.watch || {}).users || []), ...((before.watch || {}).words || []).map(w => `w:${w}`)], [...(after.watch.users || []), ...(after.watch.words || []).map(w => `w:${w}`)]);
12176
12679
 
12177
12680
  if (after.settings) {
12178
12681
  const changed = Object.keys(pickKnownSettings(after.settings)).filter(key => JSON.stringify(after.settings[key]) !== JSON.stringify((before.settings || {})[key])).length;
@@ -12194,6 +12697,8 @@
12194
12697
  delete data.recentOrder;
12195
12698
  delete data.search;
12196
12699
  delete data.hits;
12700
+ delete data.memos;
12701
+ delete data.watch;
12197
12702
 
12198
12703
  return JSON.stringify(data);
12199
12704
  };
@@ -12203,6 +12708,8 @@
12203
12708
  const recentChanged = data && 'recent' in data ? applyRecentFromSync(data.recent) : false;
12204
12709
  const searchChanged = data && 'search' in data ? applySearchFromSync(data.search) : false;
12205
12710
  const hitsChanged = data && 'hits' in data ? applyHitsFromSync(data.hits) : false;
12711
+ const memosChanged = data && 'memos' in data ? applyMemosFromSync(data.memos) : false;
12712
+ const watchChanged = data && 'watch' in data ? applyWatchFromSync(data.watch) : false;
12206
12713
  const clean = sanitizeBackup({ app: BACKUP_APP, ...data });
12207
12714
  const before = blockSyncData();
12208
12715
 
@@ -12257,7 +12764,7 @@
12257
12764
  refreshAfterBlockChange();
12258
12765
  }
12259
12766
 
12260
- return changed || recentChanged || orderChanged || searchChanged || hitsChanged;
12767
+ return changed || recentChanged || orderChanged || searchChanged || hitsChanged || memosChanged || watchChanged;
12261
12768
  };
12262
12769
 
12263
12770
  const saveSyncUndo = (before, incoming, summary, from) => {
@@ -12523,6 +13030,7 @@
12523
13030
 
12524
13031
  const runId = ++syncRunId;
12525
13032
  const epoch = syncEpoch;
13033
+ const startedAt = syncRunSince;
12526
13034
 
12527
13035
  const key = readSyncKey();
12528
13036
  const conf = readSyncConf();
@@ -12654,7 +13162,9 @@
12654
13162
  } catch (error) {
12655
13163
  syncPending = true;
12656
13164
 
12657
- if (runId !== syncRunId || (error && error.quiet) || pageLeaving || leaving || epoch !== syncEpoch || document.visibilityState === 'hidden') {
13165
+ const interrupted = netLeavingRecently() || (netResumedAt > 0 && netResumedAt >= startedAt) || (netHiddenAt > 0 && netHiddenAt >= startedAt);
13166
+
13167
+ if (runId !== syncRunId || (error && error.quiet) || interrupted || pageLeaving || leaving || epoch !== syncEpoch || document.visibilityState === 'hidden') {
12658
13168
  writeSyncRt({ dirty: true });
12659
13169
 
12660
13170
  if (runId === syncRunId && !pageLeaving && document.visibilityState !== 'hidden') {
@@ -12748,7 +13258,7 @@
12748
13258
  rerenderModal();
12749
13259
  };
12750
13260
 
12751
- const SYNCED_STORAGE = [STORAGE.users, STORAGE.media, STORAGE.keywords, STORAGE.titles, STORAGE.heads, STORAGE.scraps, STORAGE.settings];
13261
+ const SYNCED_STORAGE = [STORAGE.users, STORAGE.media, STORAGE.keywords, STORAGE.titles, STORAGE.heads, STORAGE.scraps, STORAGE.settings, STORAGE.memos, STORAGE.watch];
12752
13262
 
12753
13263
  storageWriteListeners.push(key => {
12754
13264
  if (!syncApplying && SYNCED_STORAGE.includes(key) && syncReady()) {
@@ -15832,6 +16342,869 @@
15832
16342
  return true;
15833
16343
  };
15834
16344
 
16345
+ const MEMO_MAX = 300;
16346
+ const MEMO_TOMBSTONE_KEEP = 90 * 24 * 60 * 60 * 1000;
16347
+
16348
+ let memoCache = null;
16349
+ let watchCache = null;
16350
+
16351
+ window.addEventListener('storage', event => {
16352
+ if (!event.key || event.key === STORAGE.memos || event.key === STORAGE.watch) {
16353
+ memoCache = null;
16354
+ watchCache = null;
16355
+ refreshUserDecorations();
16356
+ }
16357
+ });
16358
+
16359
+ const rawStored = key => {
16360
+ try {
16361
+ return localStorage.getItem(key) || '';
16362
+ } catch {
16363
+ return '';
16364
+ }
16365
+ };
16366
+
16367
+ const readMemos = () => {
16368
+ const rawText = rawStored(STORAGE.memos);
16369
+
16370
+ if (memoCache && memoCache.raw === rawText) {
16371
+ return { ...memoCache.data };
16372
+ }
16373
+
16374
+ const raw = readObject(STORAGE.memos);
16375
+ const out = {};
16376
+
16377
+ Object.entries(raw || {}).forEach(([key, item]) => {
16378
+ if (/^(?:nick|ip|id)::.+/.test(key) && item && typeof item === 'object') {
16379
+ out[key] = { t: String(item.t || '').slice(0, MEMO_MAX), at: Number(item.at) || 0 };
16380
+ }
16381
+ });
16382
+
16383
+ memoCache = { raw: rawText, data: out };
16384
+
16385
+ return { ...out };
16386
+ };
16387
+
16388
+ const writeMemos = memos => {
16389
+ const now = Date.now();
16390
+ const out = {};
16391
+
16392
+ Object.entries(memos).forEach(([key, item]) => {
16393
+ if (item.t || now - item.at < MEMO_TOMBSTONE_KEEP) {
16394
+ out[key] = item;
16395
+ }
16396
+ });
16397
+
16398
+ memoCache = null;
16399
+ writeJson(STORAGE.memos, out);
16400
+ };
16401
+
16402
+ const memoCount = () => Object.values(readMemos()).filter(item => item.t).length;
16403
+
16404
+ const memoForKeys = keys => {
16405
+ const memos = readMemos();
16406
+ const order = ['id', 'ip', 'nick'];
16407
+
16408
+ for (const type of order) {
16409
+ const entry = keys.find(k => k.type === type);
16410
+
16411
+ if (entry && memos[entry.key] && memos[entry.key].t) {
16412
+ return { key: entry.key, text: memos[entry.key].t };
16413
+ }
16414
+ }
16415
+
16416
+ return null;
16417
+ };
16418
+
16419
+ const setMemo = (key, text) => {
16420
+ const memos = readMemos();
16421
+
16422
+ memos[key] = { t: String(text || '').trim().slice(0, MEMO_MAX), at: Date.now() };
16423
+ writeMemos(memos);
16424
+ refreshUserDecorations();
16425
+ };
16426
+
16427
+ const primaryUserKey = keys => (keys.find(k => k.type === 'id') || keys.find(k => k.type === 'ip') || keys[0] || {}).key || '';
16428
+
16429
+ const describeUserKey = key => {
16430
+ const entry = parseBlockKey(key);
16431
+
16432
+ return `${BLOCK_TYPE_LABELS[entry.type] || ''} ${entry.value}`.trim();
16433
+ };
16434
+
16435
+ const openMemoEditor = (keys, title) => {
16436
+ const key = primaryUserKey(keys);
16437
+
16438
+ if (!key) {
16439
+ return;
16440
+ }
16441
+
16442
+ document.querySelector('.dc-memo-dialog')?.remove();
16443
+
16444
+ const current = (readMemos()[key] || {}).t || '';
16445
+ const wrap = document.createElement('div');
16446
+
16447
+ wrap.className = 'dc-ui dc-memo-dialog';
16448
+ wrap.setAttribute('role', 'dialog');
16449
+ wrap.setAttribute('aria-modal', 'true');
16450
+ wrap.innerHTML =
16451
+ '<div class="dc-memo-box">' +
16452
+ `<div class="dc-memo-head"><strong>📝 메모</strong><span>${escapeHtml(title || describeUserKey(key))}</span></div>` +
16453
+ `<textarea maxlength="${MEMO_MAX}" placeholder="이 이용자에 대한 메모 (나만 보임 · 개수 제한 없음)"></textarea>` +
16454
+ `<div class="dc-memo-sub">${escapeHtml(describeUserKey(key))} 기준 · 목록·댓글의 이름 옆에 보입니다</div>` +
16455
+ '<div class="dc-memo-actions">' +
16456
+ (current ? '<button type="button" data-act="del" class="danger">지우기</button>' : '') +
16457
+ '<span></span><button type="button" data-act="cancel">취소</button><button type="button" data-act="save" class="primary">저장</button>' +
16458
+ '</div></div>';
16459
+
16460
+ const textarea = wrap.querySelector('textarea');
16461
+
16462
+ textarea.value = current;
16463
+
16464
+ const close = () => wrap.remove();
16465
+
16466
+ wrap.addEventListener('click', event => {
16467
+ const act = event.target instanceof Element ? event.target.closest('[data-act]')?.dataset.act : '';
16468
+
16469
+ if (event.target === wrap || act === 'cancel') {
16470
+ close();
16471
+ } else if (act === 'save') {
16472
+ setMemo(key, textarea.value);
16473
+ close();
16474
+ showToast(textarea.value.trim() ? '📝 메모를 저장했습니다' : '📝 메모를 지웠습니다');
16475
+ } else if (act === 'del') {
16476
+ setMemo(key, '');
16477
+ close();
16478
+ showToast('📝 메모를 지웠습니다');
16479
+ }
16480
+ });
16481
+
16482
+ textarea.addEventListener('keydown', event => {
16483
+ if (event.key === 'Escape') {
16484
+ close();
16485
+ }
16486
+ });
16487
+
16488
+ document.body.appendChild(wrap);
16489
+ setTimeout(() => textarea.focus(), 30);
16490
+ };
16491
+
16492
+ const WATCH_WORD_MAX = 60;
16493
+
16494
+ const readWatch = () => {
16495
+ const rawText = rawStored(STORAGE.watch);
16496
+
16497
+ if (watchCache && watchCache.raw === rawText) {
16498
+ return { users: [...watchCache.users], words: [...watchCache.words] };
16499
+ }
16500
+
16501
+ const raw = readObject(STORAGE.watch);
16502
+ const users = Array.isArray(raw.users) ? raw.users.filter(key => typeof key === 'string' && /^(?:nick|ip|id)::.+/.test(key)) : [];
16503
+ const words = Array.isArray(raw.words)
16504
+ ? raw.words.map(word => normalizeWord(String(word || ''))).filter(word => word && word.length <= WATCH_WORD_MAX)
16505
+ : [];
16506
+
16507
+ watchCache = { raw: rawText, users: [...new Set(users)], words: [...new Set(words)] };
16508
+
16509
+ return { users: [...watchCache.users], words: [...watchCache.words] };
16510
+ };
16511
+
16512
+ const writeWatch = watch => {
16513
+ watchCache = null;
16514
+ writeJson(STORAGE.watch, { users: [...new Set(watch.users)], words: [...new Set(watch.words)] });
16515
+ refreshUserDecorations();
16516
+ };
16517
+
16518
+ const isWatchedKeys = keys => {
16519
+ const watched = readWatch().users;
16520
+
16521
+ return keys.some(k => watched.includes(k.key));
16522
+ };
16523
+
16524
+ const toggleWatchUser = keys => {
16525
+ const key = primaryUserKey(keys);
16526
+ const watch = readWatch();
16527
+ const on = keys.some(k => watch.users.includes(k.key));
16528
+
16529
+ watch.users = on ? watch.users.filter(item => !keys.some(k => k.key === item)) : [...watch.users, key];
16530
+ writeWatch(watch);
16531
+ showToast(on ? '☆ 관심 사용자에서 뺐습니다' : `⭐ 관심 사용자로 등록했습니다 · 새 글이 올라오면 알려 드립니다`);
16532
+ };
16533
+
16534
+ const addWatchWords = value => {
16535
+ const watch = readWatch();
16536
+ const words = String(value || '')
16537
+ .split(/[,\n]/)
16538
+ .map(normalizeWord)
16539
+ .filter(word => word && word.length <= WATCH_WORD_MAX && !watch.words.includes(word));
16540
+
16541
+ if (words.length) {
16542
+ watch.words = [...watch.words, ...words];
16543
+ writeWatch(watch);
16544
+ }
16545
+
16546
+ return words.length;
16547
+ };
16548
+
16549
+ const decoratedNodes = new Set();
16550
+
16551
+ const decorateUserNode = (nickElement, keys) => {
16552
+ if (!nickElement || !keys || !keys.length) {
16553
+ return;
16554
+ }
16555
+
16556
+ nickElement._dcDecorKeys = keys;
16557
+ decoratedNodes.add(nickElement);
16558
+
16559
+ const memo = memoForKeys(keys);
16560
+ const watched = isWatchedKeys(keys);
16561
+ let chip = nickElement._dcMemoChip;
16562
+
16563
+ nickElement.classList.toggle('dc-watch-user', watched);
16564
+
16565
+ if (!memo) {
16566
+ if (chip) {
16567
+ chip.remove();
16568
+ nickElement._dcMemoChip = null;
16569
+ }
16570
+
16571
+ return;
16572
+ }
16573
+
16574
+ if (!chip || !chip.isConnected) {
16575
+ chip = document.createElement('span');
16576
+ chip.className = 'dc-ui dc-memo-chip';
16577
+
16578
+ if (nickElement.tagName === 'LI') {
16579
+ nickElement.appendChild(chip);
16580
+ } else {
16581
+ nickElement.insertAdjacentElement('afterend', chip);
16582
+ }
16583
+ nickElement._dcMemoChip = chip;
16584
+ }
16585
+
16586
+ chip.textContent = `📝 ${memo.text.length > 18 ? `${memo.text.slice(0, 18)}…` : memo.text}`;
16587
+ chip.title = memo.text;
16588
+ };
16589
+
16590
+ const refreshUserDecorations = () =>
16591
+ withSelfMutation(() => {
16592
+ decoratedNodes.forEach(node => {
16593
+ if (!node.isConnected) {
16594
+ decoratedNodes.delete(node);
16595
+ return;
16596
+ }
16597
+
16598
+ decorateUserNode(node, node._dcDecorKeys);
16599
+ });
16600
+ });
16601
+
16602
+ const AUTHOR_HASH_RE = /(?:^#|&)dcx-author=([^&]+)/;
16603
+
16604
+ const authorSearchUrl = (keys, gallery = getGalleryId()) => {
16605
+ const nick = (keys.find(k => k.type === 'nick') || {}).value || '';
16606
+ const exact = keys.find(k => k.type === 'id') || keys.find(k => k.type === 'ip');
16607
+
16608
+ if (!gallery || gallery === HOME_GALLERY_KEY || !nick) {
16609
+ return '';
16610
+ }
16611
+
16612
+ let url;
16613
+
16614
+ if (IS_MOBILE_SITE) {
16615
+ const kind = (location.pathname.match(/^\/(board|mini|person)\//) || [])[1] || 'board';
16616
+
16617
+ url = `https://m.dcinside.com/${kind}/${encodeURIComponent(gallery)}?s_type=name&serval=${encodeURIComponent(nick)}`;
16618
+ } else {
16619
+ const prefix = (location.pathname.match(/^\/(mgallery|mini|person)\//) || [])[1];
16620
+
16621
+ url = `https://gall.dcinside.com/${prefix ? `${prefix}/` : ''}board/lists/?id=${encodeURIComponent(gallery)}&s_type=search_name&s_keyword=${encodeURIComponent(nick)}`;
16622
+ }
16623
+
16624
+ return exact ? `${url}#dcx-author=${encodeURIComponent(exact.key)}` : url;
16625
+ };
16626
+
16627
+ const readAuthorFilter = () => {
16628
+ const match = AUTHOR_HASH_RE.exec(location.hash);
16629
+
16630
+ if (!match) {
16631
+ return null;
16632
+ }
16633
+
16634
+ try {
16635
+ const key = decodeURIComponent(match[1]);
16636
+
16637
+ return /^(?:ip|id)::.+/.test(key) ? parseBlockKey(key) : null;
16638
+ } catch {
16639
+ return null;
16640
+ }
16641
+ };
16642
+
16643
+ let authorFilterOff = false;
16644
+
16645
+ const rowMatchesAuthor = (li, filter) => {
16646
+ const holder = li.dataset.dcProcessed ? li : li.querySelector('[data-dc-processed]');
16647
+
16648
+ if (!holder) {
16649
+ return null;
16650
+ }
16651
+
16652
+ return filter.type === 'id' ? holder.dataset.dcCode === filter.value : !holder.dataset.dcCode && holder.dataset.dcIp === filter.value;
16653
+ };
16654
+
16655
+ const applyAuthorFilter = () => {
16656
+ const filter = readAuthorFilter();
16657
+ const listEl = filter ? pickListElement(document) : null;
16658
+
16659
+ if (!listEl) {
16660
+ return;
16661
+ }
16662
+
16663
+ let hidden = 0;
16664
+
16665
+ withSelfMutation(() => {
16666
+ realListItems(listEl).forEach(li => {
16667
+ const match = rowMatchesAuthor(li, filter);
16668
+ const hide = !authorFilterOff && match === false;
16669
+
16670
+ li.classList.toggle('dc-author-other', hide);
16671
+ hidden += match === false ? 1 : 0;
16672
+ });
16673
+
16674
+ let bar = document.getElementById('dc-author-bar');
16675
+
16676
+ if (!bar) {
16677
+ bar = document.createElement('div');
16678
+ bar.id = 'dc-author-bar';
16679
+ bar.className = 'dc-ui dc-author-bar';
16680
+ streamAnchorOf(listEl).insertAdjacentElement('beforebegin', bar);
16681
+ bar.addEventListener('click', event => {
16682
+ if (event.target instanceof Element && event.target.closest('button')) {
16683
+ authorFilterOff = !authorFilterOff;
16684
+ applyAuthorFilter();
16685
+ }
16686
+ });
16687
+ }
16688
+
16689
+ const label = `${BLOCK_TYPE_LABELS[filter.type]} ${filter.value}`;
16690
+
16691
+ bar.innerHTML = authorFilterOff
16692
+ ? `<span>🔍 같은 닉네임의 글을 모두 보는 중</span><button type="button">${escapeHtml(label)} 글만</button>`
16693
+ : `<span>🔍 <b>${escapeHtml(label)}</b> 글만 보는 중${hidden ? ` · 같은 닉네임의 다른 사람 글 ${hidden}개 숨김` : ''}</span>${hidden ? '<button type="button">모두 보기</button>' : ''}`;
16694
+ });
16695
+ };
16696
+
16697
+ const LIVE_MIN_SEC = 30;
16698
+ const LIVE_IDLE_STOP = 10 * 60 * 1000;
16699
+ const LIVE_INSERT_MAX = 30;
16700
+
16701
+ let liveTimer = null;
16702
+ let liveBusy = false;
16703
+ let liveLastCheck = 0;
16704
+ let liveLastFound = 0;
16705
+ let liveInteractAt = 0;
16706
+ let livePill = null;
16707
+ let livePending = 0;
16708
+
16709
+ const liveIntervalMs = () => Math.max(LIVE_MIN_SEC, Number(settings.liveRefreshSec) || 60) * 1000;
16710
+
16711
+ const isLiveListPage = () => {
16712
+ if (settings.liveRefresh === false || isPostViewPage() || readAuthorFilter()) {
16713
+ return false;
16714
+ }
16715
+
16716
+ if (!/^\/(?:mgallery\/)?(?:board|mini|person)\//.test(location.pathname)) {
16717
+ return false;
16718
+ }
16719
+
16720
+ try {
16721
+ const params = new URL(location.href).searchParams;
16722
+
16723
+ if (['s_type', 'serval', 's_keyword', 'search_head'].some(name => params.get(name))) {
16724
+ return false;
16725
+ }
16726
+ } catch {
16727
+ return false;
16728
+ }
16729
+
16730
+ return currentPageNumber() === 1 && !!pickListElement(document);
16731
+ };
16732
+
16733
+ const topPostNumber = listEl =>
16734
+ realListItems(listEl).reduce((max, li) => Math.max(max, postNumber(li.dataset.dcPostKey || listItemKey(li))), 0);
16735
+
16736
+ const hideLivePill = () => {
16737
+ if (livePill) {
16738
+ livePill.classList.remove('show');
16739
+ }
16740
+
16741
+ livePending = 0;
16742
+ };
16743
+
16744
+ const showLivePill = (count, scrolled) => {
16745
+ if (!livePill) {
16746
+ livePill = document.createElement('button');
16747
+ livePill.type = 'button';
16748
+ livePill.className = 'dc-ui dc-live-pill';
16749
+ livePill.addEventListener('click', () => {
16750
+ hideLivePill();
16751
+ window.scrollTo({ top: 0, behavior: 'smooth' });
16752
+ });
16753
+ document.body.appendChild(livePill);
16754
+ }
16755
+
16756
+ livePending = scrolled ? livePending + count : count;
16757
+ livePill.textContent = scrolled ? `↑ 새 글 ${livePending}개` : `✨ 새 글 ${count}개`;
16758
+ livePill.classList.add('show');
16759
+ clearTimeout(livePill._timer);
16760
+
16761
+ if (!scrolled) {
16762
+ livePill._timer = setTimeout(hideLivePill, 2600);
16763
+ }
16764
+ };
16765
+
16766
+ const rowWriterKeys = li => {
16767
+ const holder = li.dataset.dcProcessed ? li : li.querySelector('[data-dc-processed]');
16768
+
16769
+ return holder
16770
+ ? buildBlockKeys({ nickname: holder.dataset.dcNick || '', ip: holder.dataset.dcIp || '', code: holder.dataset.dcCode || '' })
16771
+ : [];
16772
+ };
16773
+
16774
+ const rowTitle = li => cleanText((li.querySelector('.subjectin, .gall_tit a:not(.reply_numbox)') || {}).textContent || '');
16775
+
16776
+ const rowIsShown = li =>
16777
+ li.isConnected &&
16778
+ !li.classList.contains('dc-user-blocked') &&
16779
+ !li.classList.contains('dc-head-hidden') &&
16780
+ !li.classList.contains('dc-author-other') &&
16781
+ getComputedStyle(li).display !== 'none';
16782
+
16783
+ const notifyWatchHits = rows => {
16784
+ if (settings.watchAlerts === false || !rows.length) {
16785
+ return;
16786
+ }
16787
+
16788
+ const watch = readWatch();
16789
+
16790
+ if (!watch.users.length && !watch.words.length) {
16791
+ return;
16792
+ }
16793
+
16794
+ const gallery = getGalleryId();
16795
+ const seen = readObject(STORAGE.liveSeen);
16796
+ const seenNo = Number(seen[gallery]) || 0;
16797
+ let maxNo = seenNo;
16798
+ const hits = [];
16799
+
16800
+ rows.forEach(li => {
16801
+ const no = postNumber(li.dataset.dcPostKey || listItemKey(li));
16802
+
16803
+ if (!no || no <= seenNo) {
16804
+ return;
16805
+ }
16806
+
16807
+ maxNo = Math.max(maxNo, no);
16808
+
16809
+ const title = rowTitle(li);
16810
+ const lower = title.toLowerCase();
16811
+ const word = watch.words.find(item => lower.includes(item.toLowerCase()));
16812
+ const keys = rowWriterKeys(li);
16813
+ const user = keys.find(k => watch.users.includes(k.key));
16814
+
16815
+ if (word || user) {
16816
+ li.classList.add('dc-live-watch');
16817
+ hits.push({ li, title, reason: word ? `키워드 '${word}'` : `관심 사용자 ${user.value}` });
16818
+ }
16819
+ });
16820
+
16821
+ if (maxNo > seenNo) {
16822
+ writeJson(STORAGE.liveSeen, { ...seen, [gallery]: maxNo });
16823
+ }
16824
+
16825
+ if (!hits.length) {
16826
+ return;
16827
+ }
16828
+
16829
+ const first = hits[0];
16830
+ const link = first.li.querySelector(POST_LINK_SELECTOR);
16831
+ const more = hits.length > 1 ? ` 외 ${hits.length - 1}개` : '';
16832
+
16833
+ showToast(`🔔 ${first.reason} · ${first.title}${more}`, {
16834
+ actionLabel: '보기',
16835
+ onAction: () => {
16836
+ if (link) {
16837
+ link.click();
16838
+ }
16839
+ },
16840
+ duration: 7000
16841
+ });
16842
+
16843
+ if (canSystemNotify() && document.visibilityState === 'hidden') {
16844
+ try {
16845
+ const note = new window.Notification(`디시 관심 알림 · ${first.reason}`, { body: `${first.title}${more}`, tag: `dcx-watch-${gallery}` });
16846
+
16847
+ note.onclick = () => {
16848
+ window.focus();
16849
+ note.close();
16850
+
16851
+ if (link) {
16852
+ link.click();
16853
+ }
16854
+ };
16855
+ } catch {
16856
+ }
16857
+ }
16858
+ };
16859
+
16860
+ const checkLiveList = async () => {
16861
+ if (liveBusy || !isLiveListPage() || document.visibilityState === 'hidden' || netPausedUntil()) {
16862
+ return;
16863
+ }
16864
+
16865
+ if (Date.now() - liveInteractAt > LIVE_IDLE_STOP) {
16866
+ return;
16867
+ }
16868
+
16869
+ const listEl = pickListElement(document);
16870
+
16871
+ if (!listEl) {
16872
+ return;
16873
+ }
16874
+
16875
+ liveBusy = true;
16876
+ liveLastCheck = Date.now();
16877
+
16878
+ try {
16879
+ const html = await siteFetchText(buildPageUrl(1), { credentials: 'same-origin', cache: 'no-store' }, { auto: true });
16880
+ const doc = new DOMParser().parseFromString(html, 'text/html');
16881
+ const fetched = pickListElement(doc);
16882
+
16883
+ if (!fetched || !listEl.isConnected) {
16884
+ return;
16885
+ }
16886
+
16887
+ const existing = new Set(realListItems(listEl).map(li => li.dataset.dcPostKey || listItemKey(li)));
16888
+ const topNo = topPostNumber(listEl);
16889
+ const fresh = [...fetched.children]
16890
+ .filter(node => isListItemNode(node) && !node.classList.contains('adv-inner'))
16891
+ .filter(node => {
16892
+ const key = listItemKey(node);
16893
+
16894
+ return key && !existing.has(key) && postNumber(key) > topNo;
16895
+ })
16896
+ .slice(0, LIVE_INSERT_MAX);
16897
+
16898
+ if (!fresh.length || !topNo) {
16899
+ return;
16900
+ }
16901
+
16902
+ const anchor = realListItems(listEl).find(li => postNumber(li.dataset.dcPostKey || listItemKey(li)) === topNo);
16903
+
16904
+ if (!anchor) {
16905
+ return;
16906
+ }
16907
+
16908
+ const scrolled = window.scrollY > 120;
16909
+ const before = anchor.getBoundingClientRect().top;
16910
+
16911
+ fresh.forEach(li => {
16912
+ const node = document.adoptNode(li);
16913
+
16914
+ prepareAppendedItem(node);
16915
+ node.classList.add('dc-live-new');
16916
+ listEl.insertBefore(node, anchor);
16917
+ });
16918
+
16919
+ processElements();
16920
+
16921
+ if (scrolled) {
16922
+ const shift = anchor.getBoundingClientRect().top - before;
16923
+
16924
+ if (shift) {
16925
+ window.scrollBy(0, shift);
16926
+ }
16927
+ }
16928
+
16929
+ const shown = fresh.filter(rowIsShown);
16930
+
16931
+ liveLastFound = Date.now();
16932
+
16933
+ if (shown.length) {
16934
+ showLivePill(shown.length, scrolled);
16935
+ }
16936
+
16937
+ notifyWatchHits(shown);
16938
+ setTimeout(() => fresh.forEach(li => li.classList.remove('dc-live-new')), 4000);
16939
+ } catch {
16940
+ } finally {
16941
+ liveBusy = false;
16942
+ }
16943
+ };
16944
+
16945
+ const scheduleLive = () => {
16946
+ clearTimeout(liveTimer);
16947
+
16948
+ if (!isLiveListPage() || document.visibilityState === 'hidden' || Date.now() - liveInteractAt > LIVE_IDLE_STOP) {
16949
+ return;
16950
+ }
16951
+
16952
+ liveTimer = setTimeout(async () => {
16953
+ await checkLiveList();
16954
+ scheduleLive();
16955
+ }, liveIntervalMs());
16956
+ };
16957
+
16958
+ const liveStatusText = () => {
16959
+ if (settings.liveRefresh === false) {
16960
+ return null;
16961
+ }
16962
+
16963
+ if (!isLiveListPage()) {
16964
+ return '갤러리 목록 1페이지에서 동작합니다.';
16965
+ }
16966
+
16967
+ return liveLastCheck
16968
+ ? `마지막 확인 ${timeAgo(liveLastCheck)}${liveLastFound ? ` · 마지막 새 글 ${timeAgo(liveLastFound)}` : ''}`
16969
+ : '곧 첫 확인을 합니다.';
16970
+ };
16971
+
16972
+ const initLive = () => {
16973
+ const touched = () => {
16974
+ const wasIdle = Date.now() - liveInteractAt > LIVE_IDLE_STOP;
16975
+
16976
+ liveInteractAt = Date.now();
16977
+
16978
+ if (wasIdle) {
16979
+ if (Date.now() - liveLastCheck > liveIntervalMs()) {
16980
+ clearTimeout(liveTimer);
16981
+ liveTimer = setTimeout(() => checkLiveList().then(scheduleLive), 1500);
16982
+ } else {
16983
+ scheduleLive();
16984
+ }
16985
+ }
16986
+ };
16987
+
16988
+ ['pointerdown', 'keydown', 'wheel', 'touchstart'].forEach(type => window.addEventListener(type, touched, { passive: true, capture: true }));
16989
+ window.addEventListener('scroll', () => {
16990
+ touched();
16991
+
16992
+ if (livePill && window.scrollY < 60 && livePending) {
16993
+ hideLivePill();
16994
+ }
16995
+ }, { passive: true });
16996
+
16997
+ document.addEventListener('visibilitychange', () => {
16998
+ if (document.visibilityState === 'visible') {
16999
+ if (!liveInteractAt) {
17000
+ return;
17001
+ }
17002
+
17003
+ liveInteractAt = Date.now();
17004
+
17005
+ if (Date.now() - liveLastCheck > liveIntervalMs()) {
17006
+ clearTimeout(liveTimer);
17007
+ liveTimer = setTimeout(() => checkLiveList().then(scheduleLive), 2500);
17008
+ } else {
17009
+ scheduleLive();
17010
+ }
17011
+ } else {
17012
+ clearTimeout(liveTimer);
17013
+ }
17014
+ });
17015
+
17016
+ window.addEventListener('hashchange', () => {
17017
+ authorFilterOff = false;
17018
+ applyAuthorFilter();
17019
+ });
17020
+
17021
+ if (Number.isFinite(window.__dcEnhancerNetScale)) {
17022
+ window.__dcEnhancerLiveCheck = () => {
17023
+ liveInteractAt = Date.now();
17024
+
17025
+ return checkLiveList();
17026
+ };
17027
+ }
17028
+ };
17029
+
17030
+ const collectMemosForSync = () => readMemos();
17031
+
17032
+ const mergeMemos = (local = {}, remote) => {
17033
+ const out = { ...(remote && typeof remote === 'object' ? remote : {}) };
17034
+
17035
+ Object.entries(local || {}).forEach(([key, item]) => {
17036
+ if (!out[key] || (Number(item.at) || 0) >= (Number(out[key].at) || 0)) {
17037
+ out[key] = item;
17038
+ }
17039
+ });
17040
+
17041
+ return out;
17042
+ };
17043
+
17044
+ const applyMemosFromSync = memos => {
17045
+ if (!memos || typeof memos !== 'object' || Array.isArray(memos)) {
17046
+ return false;
17047
+ }
17048
+
17049
+ const next = JSON.stringify(mergeMemos(readMemos(), memos));
17050
+
17051
+ if (next === JSON.stringify(readMemos())) {
17052
+ return false;
17053
+ }
17054
+
17055
+ memoCache = null;
17056
+ writeJson(STORAGE.memos, JSON.parse(next));
17057
+ refreshUserDecorations();
17058
+
17059
+ return true;
17060
+ };
17061
+
17062
+ const mergeWatch = (local = {}, base, remote) => ({
17063
+ users: mergeList(local.users || [], base ? base.users || [] : null, remote ? remote.users || [] : undefined),
17064
+ words: mergeList(local.words || [], base ? base.words || [] : null, remote ? remote.words || [] : undefined)
17065
+ });
17066
+
17067
+ const applyWatchFromSync = watch => {
17068
+ if (!watch || typeof watch !== 'object') {
17069
+ return false;
17070
+ }
17071
+
17072
+ const next = { users: Array.isArray(watch.users) ? watch.users : [], words: Array.isArray(watch.words) ? watch.words : [] };
17073
+
17074
+ if (JSON.stringify(next) === JSON.stringify(readWatch())) {
17075
+ return false;
17076
+ }
17077
+
17078
+ watchCache = null;
17079
+ writeJson(STORAGE.watch, next);
17080
+ refreshUserDecorations();
17081
+
17082
+ return true;
17083
+ };
17084
+
17085
+ const renderWatchView = area => {
17086
+ const watch = readWatch();
17087
+ const memos = Object.entries(readMemos()).filter(([, item]) => item.t).sort((a, b) => b[1].at - a[1].at);
17088
+
17089
+ area.appendChild(
17090
+ makeNote(
17091
+ '관심 키워드·관심 사용자의 새 글이 목록에 올라오면 알려 드립니다 (설정 › 목록 보기 › 새 글 자동으로 이어 붙이기). 메모는 개수 제한 없이 이 기기에 저장되고 동기화됩니다.'
17092
+ )
17093
+ );
17094
+
17095
+ area.appendChild(makeSectionTitle(`🔔 관심 키워드 (${watch.words.length})`));
17096
+ area.appendChild(
17097
+ makeInputRow('제목에 들어갈 단어 (쉼표로 여러 개)', '추가', value => {
17098
+ addWatchWords(value);
17099
+ rerenderModal();
17100
+ }, { maxLength: 300 })
17101
+ );
17102
+
17103
+ if (!watch.words.length) {
17104
+ area.appendChild(makeEmpty('관심 키워드가 없습니다.'));
17105
+ }
17106
+
17107
+ watch.words.forEach(word => {
17108
+ area.appendChild(
17109
+ makeRow({
17110
+ title: word,
17111
+ chip: '키워드',
17112
+ chipClass: 'scope',
17113
+ actions: [{
17114
+ label: '빼기',
17115
+ className: 'on',
17116
+ onClick: () => {
17117
+ const next = readWatch();
17118
+
17119
+ next.words = next.words.filter(item => item !== word);
17120
+ writeWatch(next);
17121
+ rerenderModal();
17122
+ }
17123
+ }]
17124
+ })
17125
+ );
17126
+ });
17127
+
17128
+ area.appendChild(makeSectionTitle(`⭐ 관심 사용자 (${watch.users.length})`));
17129
+
17130
+ if (!watch.users.length) {
17131
+ area.appendChild(makeEmpty('닉네임을 누른 메뉴에서 ⭐ 관심 사용자로 등록할 수 있습니다.'));
17132
+ }
17133
+
17134
+ watch.users.forEach(key => {
17135
+ const entry = parseBlockKey(key);
17136
+ const memo = (readMemos()[key] || {}).t;
17137
+
17138
+ area.appendChild(
17139
+ makeRow({
17140
+ title: entry.value,
17141
+ chip: BLOCK_TYPE_LABELS[entry.type],
17142
+ chipClass: BLOCK_TYPE_CLASS[entry.type],
17143
+ meta: memo ? `📝 ${memo}` : '',
17144
+ actions: [{
17145
+ label: '빼기',
17146
+ className: 'on',
17147
+ onClick: () => {
17148
+ const next = readWatch();
17149
+
17150
+ next.users = next.users.filter(item => item !== key);
17151
+ writeWatch(next);
17152
+ rerenderModal();
17153
+ }
17154
+ }]
17155
+ })
17156
+ );
17157
+ });
17158
+
17159
+ area.appendChild(makeSectionTitle(`📝 이용자 메모 (${memos.length})`));
17160
+
17161
+ const filterRow = makeFilterRow(memos.length);
17162
+
17163
+ if (filterRow) {
17164
+ area.appendChild(filterRow);
17165
+ }
17166
+
17167
+ if (!memos.length) {
17168
+ area.appendChild(makeEmpty('닉네임을 누른 메뉴의 📝 메모로 남길 수 있습니다.'));
17169
+ }
17170
+
17171
+ memos.forEach(([key, item]) => {
17172
+ const entry = parseBlockKey(key);
17173
+
17174
+ area.appendChild(
17175
+ makeRow({
17176
+ title: item.t,
17177
+ chip: `${BLOCK_TYPE_LABELS[entry.type]} ${entry.value}`,
17178
+ chipClass: BLOCK_TYPE_CLASS[entry.type],
17179
+ meta: timeAgo(item.at),
17180
+ actions: [
17181
+ { label: '고치기', onClick: () => openMemoEditor([{ ...entry, key }]) },
17182
+ {
17183
+ label: '지우기',
17184
+ className: 'on',
17185
+ onClick: () => {
17186
+ setMemo(key, '');
17187
+ rerenderModal();
17188
+ }
17189
+ }
17190
+ ]
17191
+ })
17192
+ );
17193
+ });
17194
+
17195
+ applyListFilter(area);
17196
+ };
17197
+
17198
+ (() => {
17199
+ const tail = ['recent', 'settings'].filter(key => MODAL_VIEWS[key]).map(key => [key, MODAL_VIEWS[key]]);
17200
+
17201
+ tail.forEach(([key]) => delete MODAL_VIEWS[key]);
17202
+ MODAL_VIEWS.watch = { title: '관심·메모', tab: '🔔 관심', render: renderWatchView };
17203
+ tail.forEach(([key, view]) => {
17204
+ MODAL_VIEWS[key] = view;
17205
+ });
17206
+ })();
17207
+
15835
17208
  const FAB_DRAG_THRESHOLD = 8;
15836
17209
  const FAB_EDGE_MARGIN = 6;
15837
17210
 
@@ -16809,6 +18182,7 @@
16809
18182
  initSiteAlarms();
16810
18183
  initKeyboardShortcuts();
16811
18184
  initHoverPreview();
18185
+ initLive();
16812
18186
  restoreListSnapshot();
16813
18187
  processElements();
16814
18188