smodusermc commited on
Commit
07a4fc6
·
verified ·
1 Parent(s): 661abfd

Update static/script.js

Browse files
Files changed (1) hide show
  1. static/script.js +742 -39
static/script.js CHANGED
@@ -6,7 +6,7 @@
6
  (function () {
7
  'use strict';
8
 
9
- const APP_VERSION = '2.0.0';
10
 
11
  // ============================================================
12
  // Safe storage
@@ -129,6 +129,7 @@
129
  refreshBlocksBtn: $('refresh-blocks-btn'), blockedUsersList: $('blocked-users-list'),
130
 
131
  settingsDisplayName: $('settings-display-name'), settingsUsername: $('settings-username'),
 
132
  settingsAvatarPreview: $('settings-avatar-preview'), settingsAvatarImg: $('settings-avatar-img'),
133
  avatarInput: $('avatar-input'), avatarUploadBtn: $('avatar-upload-btn'),
134
  themeOptions: document.querySelectorAll('.theme-option'),
@@ -499,6 +500,7 @@
499
  store.set('token', State.token);
500
  showChat();
501
  connectWebSocket();
 
502
  }
503
 
504
  function showAuthError(msg) {
@@ -2369,16 +2371,14 @@
2369
  await uploadChunked(file);
2370
  });
2371
 
2372
- async function uploadChunked(file) {
2373
  if (file.size > 500 * 1024 * 1024) {
2374
- showToast('File too large (max 500MB)', 'error');
2375
- return;
2376
  }
2377
  const CHUNK_SIZE = 5 * 1024 * 1024;
2378
  const totalChunks = Math.max(1, Math.ceil(file.size / CHUNK_SIZE));
2379
  const uploadId = generateId();
2380
- DOM.uploadProgress.classList.remove('hidden');
2381
- updateProgress(0, file.name, file.size);
2382
 
2383
  for (let i = 0; i < totalChunks; i++) {
2384
  const blob = file.slice(i * CHUNK_SIZE, Math.min((i + 1) * CHUNK_SIZE, file.size));
@@ -2387,36 +2387,41 @@
2387
  const url = `${API}/api/upload/chunk?chunk_index=${i}&total_chunks=${totalChunks}` +
2388
  `&file_name=${encodeURIComponent(file.name)}&file_type=${encodeURIComponent(file.type || 'application/octet-stream')}` +
2389
  `&file_size=${file.size}&upload_id=${uploadId}`;
2390
- try {
2391
- const res = await fetch(url, {
2392
- method: 'POST', body: formData, headers: { 'X-Auth-Token': State.token }
2393
- });
2394
- if (!res.ok) {
2395
- const data = await res.json().catch(() => ({}));
2396
- throw new Error(data.detail || 'Upload failed');
2397
- }
2398
- const data = await res.json();
2399
- updateProgress(((i + 1) / totalChunks) * 100, file.name, file.size);
2400
- if (data.status === 'complete') {
2401
- State.pendingFile = {
2402
- file_path: data.file_path,
2403
- file_type: data.file_type || file.type,
2404
- file_name: data.file_name,
2405
- file_size: data.file_size
2406
- };
2407
- showUploadPreview(State.pendingFile);
2408
- DOM.uploadProgress.classList.add('hidden');
2409
- DOM.sendBtn.disabled = false;
2410
- DOM.messageInput.focus();
2411
- return;
2412
- }
2413
- } catch (err) {
2414
- showToast('Upload failed: ' + err.message, 'error');
2415
- DOM.uploadProgress.classList.add('hidden');
2416
- return;
2417
  }
2418
  }
2419
- DOM.uploadProgress.classList.add('hidden');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2420
  }
2421
 
2422
  function updateProgress(percent, filename, size) {
@@ -2894,6 +2899,9 @@
2894
  function openSettings() {
2895
  DOM.settingsDisplayName.value = State.user?.display_name || '';
2896
  DOM.settingsUsername.value = State.user?.username || '';
 
 
 
2897
  DOM.pwCurrent.value = '';
2898
  DOM.pwNew.value = '';
2899
  DOM.pwMsg.textContent = '';
@@ -2928,22 +2936,37 @@
2928
 
2929
  async function saveSettings() {
2930
  const newName = DOM.settingsDisplayName.value.trim();
2931
- if (newName && newName !== State.user?.display_name) {
 
 
 
 
 
 
2932
  try {
2933
- const res = await fetch(`${API}/api/profile?display_name=${encodeURIComponent(newName)}`, {
 
 
 
 
 
2934
  method: 'PATCH', headers: { 'X-Auth-Token': State.token }
2935
  });
2936
  if (res.ok) {
2937
- State.user.display_name = newName;
 
 
 
2938
  updateSidebarMe();
2939
  renderSidebar();
2940
  renderConversationHeader(State.activeConvId);
2941
- showToast('Display name updated', 'success');
 
2942
  } else {
2943
  const data = await res.json().catch(() => ({}));
2944
  throw new Error(data.detail || 'Failed');
2945
  }
2946
- } catch (e) { showToast('Failed to update name', 'error'); }
2947
  }
2948
 
2949
  const avatarFile = DOM.avatarInput.files[0];
@@ -3067,6 +3090,683 @@
3067
  // ============================================================
3068
  // Auth lifecycle
3069
  // ============================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3070
  async function init() {
3071
  applyPrefs();
3072
  DOM.aboutVersion.textContent = APP_VERSION;
@@ -3078,6 +3778,7 @@
3078
  State.user = data.user;
3079
  showChat();
3080
  connectWebSocket();
 
3081
  return;
3082
  }
3083
  } catch (e) { /* fall through to auth screen */ }
@@ -3095,6 +3796,8 @@
3095
 
3096
  function showChat() {
3097
  DOM.authScreen.classList.remove('active');
 
 
3098
  DOM.chatScreen.classList.add('active');
3099
  updateSidebarMe();
3100
  }
 
6
  (function () {
7
  'use strict';
8
 
9
+ const APP_VERSION = '3.0.0';
10
 
11
  // ============================================================
12
  // Safe storage
 
129
  refreshBlocksBtn: $('refresh-blocks-btn'), blockedUsersList: $('blocked-users-list'),
130
 
131
  settingsDisplayName: $('settings-display-name'), settingsUsername: $('settings-username'),
132
+ settingsBio: $('settings-bio'), settingsLocation: $('settings-location'), settingsWebsite: $('settings-website'),
133
  settingsAvatarPreview: $('settings-avatar-preview'), settingsAvatarImg: $('settings-avatar-img'),
134
  avatarInput: $('avatar-input'), avatarUploadBtn: $('avatar-upload-btn'),
135
  themeOptions: document.querySelectorAll('.theme-option'),
 
500
  store.set('token', State.token);
501
  showChat();
502
  connectWebSocket();
503
+ initSocial();
504
  }
505
 
506
  function showAuthError(msg) {
 
2371
  await uploadChunked(file);
2372
  });
2373
 
2374
+ async function uploadFileChunked(file, onProgress) {
2375
  if (file.size > 500 * 1024 * 1024) {
2376
+ throw new Error('File too large (max 500MB)');
 
2377
  }
2378
  const CHUNK_SIZE = 5 * 1024 * 1024;
2379
  const totalChunks = Math.max(1, Math.ceil(file.size / CHUNK_SIZE));
2380
  const uploadId = generateId();
2381
+ if (onProgress) onProgress(0, file.name, file.size, totalChunks);
 
2382
 
2383
  for (let i = 0; i < totalChunks; i++) {
2384
  const blob = file.slice(i * CHUNK_SIZE, Math.min((i + 1) * CHUNK_SIZE, file.size));
 
2387
  const url = `${API}/api/upload/chunk?chunk_index=${i}&total_chunks=${totalChunks}` +
2388
  `&file_name=${encodeURIComponent(file.name)}&file_type=${encodeURIComponent(file.type || 'application/octet-stream')}` +
2389
  `&file_size=${file.size}&upload_id=${uploadId}`;
2390
+ const res = await fetch(url, {
2391
+ method: 'POST', body: formData, headers: { 'X-Auth-Token': State.token }
2392
+ });
2393
+ if (!res.ok) {
2394
+ const data = await res.json().catch(() => ({}));
2395
+ throw new Error(data.detail || 'Upload failed');
2396
+ }
2397
+ const data = await res.json();
2398
+ const percent = ((i + 1) / totalChunks) * 100;
2399
+ if (onProgress) onProgress(percent, file.name, file.size, totalChunks);
2400
+ if (data.status === 'complete') {
2401
+ return {
2402
+ file_path: data.file_path,
2403
+ file_type: data.file_type || file.type,
2404
+ file_name: data.file_name,
2405
+ file_size: data.file_size
2406
+ };
 
 
 
 
 
 
 
 
 
 
2407
  }
2408
  }
2409
+ throw new Error('Upload did not complete');
2410
+ }
2411
+
2412
+ async function uploadChunked(file) {
2413
+ try {
2414
+ DOM.uploadProgress.classList.remove('hidden');
2415
+ updateProgress(0, file.name, file.size);
2416
+ State.pendingFile = await uploadFileChunked(file, (pct) => updateProgress(pct, file.name, file.size));
2417
+ showUploadPreview(State.pendingFile);
2418
+ DOM.uploadProgress.classList.add('hidden');
2419
+ DOM.sendBtn.disabled = false;
2420
+ DOM.messageInput.focus();
2421
+ } catch (err) {
2422
+ showToast('Upload failed: ' + err.message, 'error');
2423
+ DOM.uploadProgress.classList.add('hidden');
2424
+ }
2425
  }
2426
 
2427
  function updateProgress(percent, filename, size) {
 
2899
  function openSettings() {
2900
  DOM.settingsDisplayName.value = State.user?.display_name || '';
2901
  DOM.settingsUsername.value = State.user?.username || '';
2902
+ DOM.settingsBio.value = State.user?.bio || '';
2903
+ DOM.settingsLocation.value = State.user?.location || '';
2904
+ DOM.settingsWebsite.value = State.user?.website || '';
2905
  DOM.pwCurrent.value = '';
2906
  DOM.pwNew.value = '';
2907
  DOM.pwMsg.textContent = '';
 
2936
 
2937
  async function saveSettings() {
2938
  const newName = DOM.settingsDisplayName.value.trim();
2939
+ const newBio = DOM.settingsBio.value.trim();
2940
+ const newLocation = DOM.settingsLocation.value.trim();
2941
+ const newWebsite = DOM.settingsWebsite.value.trim();
2942
+ const profileChanged = (newName && newName !== State.user?.display_name) ||
2943
+ newBio !== (State.user?.bio || '') || newLocation !== (State.user?.location || '') ||
2944
+ newWebsite !== (State.user?.website || '');
2945
+ if (profileChanged) {
2946
  try {
2947
+ const params = [];
2948
+ if (newName && newName !== State.user?.display_name) params.push('display_name=' + encodeURIComponent(newName));
2949
+ if (newBio !== (State.user?.bio || '')) params.push('bio=' + encodeURIComponent(newBio));
2950
+ if (newLocation !== (State.user?.location || '')) params.push('location=' + encodeURIComponent(newLocation));
2951
+ if (newWebsite !== (State.user?.website || '')) params.push('website=' + encodeURIComponent(newWebsite));
2952
+ const res = await fetch(`${API}/api/profile?${params.join('&')}`, {
2953
  method: 'PATCH', headers: { 'X-Auth-Token': State.token }
2954
  });
2955
  if (res.ok) {
2956
+ const data = await res.json();
2957
+ if (data.user) { State.user = data.user; }
2958
+ if (newName) State.user.display_name = newName;
2959
+ State.user.bio = newBio; State.user.location = newLocation; State.user.website = newWebsite;
2960
  updateSidebarMe();
2961
  renderSidebar();
2962
  renderConversationHeader(State.activeConvId);
2963
+ updateSocialMe();
2964
+ showToast('Profile updated', 'success');
2965
  } else {
2966
  const data = await res.json().catch(() => ({}));
2967
  throw new Error(data.detail || 'Failed');
2968
  }
2969
+ } catch (e) { showToast('Failed to update profile', 'error'); }
2970
  }
2971
 
2972
  const avatarFile = DOM.avatarInput.files[0];
 
3090
  // ============================================================
3091
  // Auth lifecycle
3092
  // ============================================================
3093
+ // ============================================================
3094
+ // Social feed page (Twitter/X-like)
3095
+ // ============================================================
3096
+ const MAX_POST_MEDIA = 4;
3097
+ const DEFAULT_SOCIAL_SETTINGS = {
3098
+ autoplay: false, muted: true, hideReposts: false, compact: false,
3099
+ live: true, badges: true, defaultFeed: 'home'
3100
+ };
3101
+
3102
+ function loadSocialSettings() {
3103
+ let out = { ...DEFAULT_SOCIAL_SETTINGS };
3104
+ try {
3105
+ const raw = store.get('social-settings');
3106
+ if (raw) Object.assign(out, JSON.parse(raw));
3107
+ } catch (e) { /* defaults */ }
3108
+ return out;
3109
+ }
3110
+
3111
+ function saveSocialSettings() {
3112
+ store.set('social-settings', JSON.stringify(SocialState.settings));
3113
+ }
3114
+
3115
+ const SocialState = {
3116
+ inited: false, view: 'home', feedCursor: null,
3117
+ media: [], modalMedia: [], modalMode: 'compose',
3118
+ replyTarget: null, quoteTarget: null, profileUser: null,
3119
+ posts: new Map(), following: new Set(), isSearching: false,
3120
+ searchTimer: null, pollTimer: null, editingPost: null,
3121
+ settings: loadSocialSettings()
3122
+ };
3123
+
3124
+ function _el(id) { return document.getElementById(id); }
3125
+
3126
+ function socialInitial(name) { return initialOf(name || '?'); }
3127
+
3128
+ function socialAvatarHtml(user, cls) {
3129
+ const url = user && user.avatar_path ? fileUrl(user.avatar_path) : '';
3130
+ if (url) return `<img class="${cls || ''}" src="${escapeHtml(url)}" alt="" loading="lazy">`;
3131
+ const name = displayNameOf(user);
3132
+ return `<span class="social-avatar-initial ${avatarColorClass('social-' + (name||'?').toLowerCase())}">${escapeHtml(initialOf(name))}</span>`;
3133
+ }
3134
+
3135
+ function socialTime(ms) {
3136
+ if (!ms) return '';
3137
+ const d = new Date(ms), n = new Date();
3138
+ const diff = n - d;
3139
+ if (diff < 60000) return 'now';
3140
+ if (diff < 3600000) return Math.floor(diff/60000) + 'm';
3141
+ if (d.toDateString() === n.toDateString()) return d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
3142
+ if (diff < 86400000 * 7) return d.toLocaleDateString([], {weekday:'short'}) + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
3143
+ return d.toLocaleDateString([], {month:'short', day:'numeric'});
3144
+ }
3145
+
3146
+ function socialCount(n) {
3147
+ if (!n) return '0';
3148
+ if (n >= 1000000) return (n/1000000).toFixed(1).replace(/\.0$/,'') + 'M';
3149
+ if (n >= 1000) return (n/1000).toFixed(1).replace(/\.0$/,'') + 'K';
3150
+ return String(n);
3151
+ }
3152
+
3153
+ function linkifySocial(text) {
3154
+ if (!text) return '';
3155
+ let html = escapeHtml(text);
3156
+ html = html.replace(/&quot;/g, '"').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
3157
+ html = html.replace(/(^|[\s(])(#([A-Za-z0-9_]+))/g,
3158
+ (m, pre, tag, body) => pre + `<a class="social-hashtag" data-hashtag="${encodeURIComponent(body)}" href="javascript:void(0)">#${escapeHtml(body)}</a>`);
3159
+ html = html.replace(/(^|[\s(])(@([A-Za-z0-9_]{1,30}))/g,
3160
+ (m, pre, handle, body) => pre + `<a class="social-mention" data-username="${encodeURIComponent(body)}" href="javascript:void(0)">@${escapeHtml(body)}</a>`);
3161
+ html = html.replace(/(https?:\/\/[^\s<]+)/g,
3162
+ (u) => `<a href="${escapeHtml(u)}" target="_blank" rel="noopener noreferrer">${escapeHtml(u)}</a>`);
3163
+ return html;
3164
+ }
3165
+
3166
+ function socialMediaHtml(media) {
3167
+ if (!media || !media.length) return '';
3168
+ const items = media.map(m => {
3169
+ const fp = m.file_path || '';
3170
+ const name = (m.file_name || fp || '').split('/').pop();
3171
+ const type = (m.file_type || '').toLowerCase();
3172
+ const img = type.startsWith('image/') || /\.(jpg|jpeg|png|gif|webp|bmp|avif)$/i.test(fp);
3173
+ const video = type.startsWith('video/') || /\.(mp4|webm|mov|ogg|m4v|mpeg|mpg)$/i.test(fp);
3174
+ let c = '';
3175
+ const videoAttrs = SocialState.settings.autoplay ? (SocialState.settings.muted ? ' autoplay muted loop ' : ' autoplay ') : '';
3176
+ if (img) c = `<img src="${escapeHtml(fileUrl(fp))}" alt="${escapeHtml(name)}" loading="lazy" data-media-image="${escapeHtml(fileUrl(fp))}" data-media-name="${escapeHtml(name)}">`;
3177
+ else if (video) c = `<video controls preload="metadata" src="${escapeHtml(fileUrl(fp))}" ${videoAttrs}></video>`;
3178
+ else c = `<a class="social-file" href="${escapeHtml(fileUrl(fp))}" target="_blank" rel="noopener"><i class="fas fa-file"></i> ${escapeHtml(name)}</a>`;
3179
+ return `<div class="social-media-item${img ? ' img' : ''}">${c}</div>`;
3180
+ }).join('');
3181
+ return `<div class="social-post-media">${items}</div>`;
3182
+ }
3183
+
3184
+ function socialPostHtml(post) {
3185
+ if (!post) return '';
3186
+ const author = post.author || {};
3187
+ const isOwn = State.user && author.id === State.user.id;
3188
+ const repostLabel = post.reposter
3189
+ ? `<div class="social-repost-label"><i class="fas fa-retweet"></i> ${escapeHtml(post.reposter.username || '')} reposted</div>`
3190
+ : (post.reposter_id ? `<div class="social-repost-label"><i class="fas fa-retweet"></i> Reposted</div>` : '');
3191
+ const quote = post.quote ? `
3192
+ <div class="social-quote">
3193
+ <div class="social-post-head"><strong>${escapeHtml(displayNameOf(post.quote.author))}</strong>
3194
+ <span class="social-username">@${escapeHtml(post.quote.author.username || '')}</span></div>
3195
+ <div class="social-post-body">${linkifySocial(post.quote.body || '')}</div>
3196
+ </div>` : '';
3197
+ const actions = `
3198
+ <div class="social-post-actions">
3199
+ <button class="sp-action" data-action="reply" data-id="${post.id}" title="Reply">
3200
+ <i class="fas fa-comment"></i><span>${socialCount(post.reply_count || 0)}</span>
3201
+ </button>
3202
+ <button class="sp-action${post.reposted ? ' active' : ''}" data-action="repost" data-id="${post.id}" title="Repost">
3203
+ <i class="fas fa-retweet"></i><span>${socialCount(post.repost_count || 0)}</span>
3204
+ </button>
3205
+ <button class="sp-action${post.liked ? ' active' : ''}" data-action="like" data-id="${post.id}" title="Like">
3206
+ <i class="fas fa-heart"></i><span>${socialCount(post.like_count || 0)}</span>
3207
+ </button>
3208
+ <button class="sp-action${post.bookmarked ? ' active' : ''}" data-action="bookmark" data-id="${post.id}" title="Bookmark">
3209
+ <i class="fas fa-bookmark"></i><span>${socialCount(post.bookmark_count || 0)}</span>
3210
+ </button>
3211
+ <button class="sp-action" data-action="quote" data-id="${post.id}" title="Quote">
3212
+ <i class="fas fa-quote-right"></i><span></span>
3213
+ </button>
3214
+ ${isOwn ? `<button class="sp-action" data-action="edit" data-id="${post.id}" title="Edit"><i class="fas fa-pen"></i></button>` : ''}
3215
+ ${isOwn ? `<button class="sp-action danger" data-action="delete" data-id="${post.id}" title="Delete"><i class="fas fa-trash"></i></button>` : ''}
3216
+ ${!isOwn ? `<button class="sp-action" data-action="profile" data-username="${escapeHtml(author.username || '')}" title="Profile"><i class="fas fa-user"></i></button>` : ''}
3217
+ <button class="sp-action" data-action="share" data-id="${post.id}" title="Copy link"><i class="fas fa-link"></i></button>
3218
+ </div>`;
3219
+ return `<article class="social-post" data-post-id="${post.id}">
3220
+ <div class="social-avatar${author.avatar_path ? '' : ' ah'}">${socialAvatarHtml(author, 'social-avatar-img')}</div>
3221
+ <div class="social-post-main">
3222
+ ${repostLabel}
3223
+ <div class="social-post-head">
3224
+ <a class="social-profile-link" data-username="${escapeHtml(author.username || '')}" href="javascript:void(0)"><strong>${escapeHtml(displayNameOf(author))}</strong>
3225
+ <span class="social-username">@${escapeHtml(author.username || '')}</span></a>
3226
+ <span class="social-dot">·</span><time class="social-time" title="${new Date(post.created_at_ms || 0).toLocaleString()}">${socialTime(post.created_at_ms)}</time>
3227
+ ${post.edited_at_ms ? '<span class="social-edited">· edited</span>' : ''}
3228
+ </div>
3229
+ <div class="social-post-body">${linkifySocial(post.body || '')}</div>
3230
+ ${socialMediaHtml(post.media)}
3231
+ ${quote}
3232
+ ${actions}
3233
+ </div>
3234
+ </article>`;
3235
+ }
3236
+
3237
+ function renderSocialPosts(posts, container) {
3238
+ const el = container || _el('social-feed');
3239
+ if (!el) return;
3240
+ if (!posts || !posts.length) {
3241
+ const empty = document.createElement('div');
3242
+ empty.className = 'social-feed-empty';
3243
+ empty.innerHTML = '<i class="fas fa-feather"></i><h3>Nothing here yet</h3><p>Follow people or make the first post.</p>';
3244
+ el.innerHTML = '';
3245
+ el.appendChild(empty);
3246
+ return;
3247
+ }
3248
+ posts.forEach(p => SocialState.posts.set(p.id, p));
3249
+ el.innerHTML = posts.map(socialPostHtml).join('');
3250
+ }
3251
+
3252
+ async function openSocial() {
3253
+ if (!State.user) return;
3254
+ _el('social-screen').classList.add('active');
3255
+ DOM.chatScreen.classList.remove('active');
3256
+ updateSocialMe();
3257
+ if (!SocialState.inited) initSocial();
3258
+ const feed = SocialState.settings.defaultFeed === 'explore' ? 'explore' : SocialState.view || 'home';
3259
+ SocialState.view = feed;
3260
+ await SocialLoad.view(feed);
3261
+ startSocialPoll();
3262
+ const hashMatch = location.hash.match(/^#social\/(\d+)$/);
3263
+ if (hashMatch) { try { const data = await apiGet(`/api/social/posts/${hashMatch[1]}`); renderSocialPosts([data.post]); _el('social-compose').style.display='none'; } catch(e) {} }
3264
+ }
3265
+
3266
+ function backToChat() {
3267
+ SocialState.isSearching = false;
3268
+ SocialState.profileUser = null;
3269
+ stopSocialPoll();
3270
+ _el('social-screen').classList.remove('active');
3271
+ DOM.chatScreen.classList.add('active');
3272
+ updateSidebarMe();
3273
+ }
3274
+
3275
+ function startSocialPoll() {
3276
+ stopSocialPoll();
3277
+ if (!SocialState.settings.live) return;
3278
+ SocialState.pollTimer = setInterval(async () => {
3279
+ if (!_el('social-screen').classList.contains('active') || SocialState.isSearching) return;
3280
+ if (SocialState.view !== 'home' && SocialState.view !== 'explore') return;
3281
+ try { await SocialLoad.feed(SocialState.view, true); } catch (e) { /* non-fatal */ }
3282
+ }, 15000);
3283
+ }
3284
+
3285
+ function stopSocialPoll() {
3286
+ if (SocialState.pollTimer) { clearInterval(SocialState.pollTimer); SocialState.pollTimer = null; }
3287
+ }
3288
+
3289
+ function updateSocialMe() {
3290
+ if (!State.user) return;
3291
+ _el('social-me-name').textContent = displayNameOf(State.user);
3292
+ _el('social-me-username').textContent = '@' + (State.user.username || '');
3293
+ const av = _el('social-me-avatar'), ph = _el('social-me-avatar-ph');
3294
+ if (State.user.avatar_path) { av.src = fileUrl(State.user.avatar_path); av.classList.remove('hidden'); ph.classList.add('hidden'); }
3295
+ else { av.classList.add('hidden'); ph.classList.remove('hidden'); ph.textContent = socialInitial(displayNameOf(State.user)); }
3296
+ const cAv = _el('social-compose-avatar'), cPh = _el('social-compose-avatar-ph');
3297
+ if (State.user.avatar_path) { cAv.src = fileUrl(State.user.avatar_path); cAv.classList.remove('hidden'); cPh.classList.add('hidden'); }
3298
+ else { cAv.classList.add('hidden'); cPh.classList.remove('hidden'); cPh.textContent = socialInitial(displayNameOf(State.user)); }
3299
+ }
3300
+
3301
+ const SOCIAL_MEDIA_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.mp4', '.webm', '.mov', '.m4v', '.ogg', '.mpeg', '.mpg'];
3302
+
3303
+ function isSocialMediaFile(file) {
3304
+ if (!file) return false;
3305
+ const t = (file.type || '').toLowerCase();
3306
+ const ext = ('.' + (file.name || '').split('.').pop().toLowerCase());
3307
+ return t.startsWith('image/') || t.startsWith('video/') || SOCIAL_MEDIA_EXTS.includes(ext);
3308
+ }
3309
+
3310
+ async function uploadSocialFile(file, onProgress) {
3311
+ if (!file) return null;
3312
+ if (!isSocialMediaFile(file)) throw new Error('Only images and videos can be attached to posts');
3313
+ if (file.size > 200 * 1024 * 1024) throw new Error('Social media files must be under 200MB');
3314
+ if (!State.user) throw new Error('Not signed in');
3315
+ return await uploadFileChunked(file, onProgress);
3316
+ }
3317
+
3318
+ function socialPreviewMedia() {
3319
+ const list = SocialState.media;
3320
+ const box = _el('social-media-preview');
3321
+ if (!list.length) { box.classList.add('hidden'); box.innerHTML = ''; return; }
3322
+ box.classList.remove('hidden');
3323
+ box.innerHTML = list.map((m, i) => `
3324
+ <div class="social-media-preview-item">
3325
+ ${m.file_type && m.file_type.startsWith('video/') ? `<video src="${escapeHtml(m.preview_url || fileUrl(m.file_path))}" muted></video>` : `<img src="${escapeHtml(m.preview_url || fileUrl(m.file_path))}" alt="">`}
3326
+ <button class="btn-icon btn-icon-sm" data-remove-media="${i}"><i class="fas fa-times"></i></button>
3327
+ </div>`).join('');
3328
+ }
3329
+
3330
+ async function addSocialMedia(files, modal) {
3331
+ if (!files || !files.length) return;
3332
+ const progress = modal ? _el('social-compose-modal-progress') : _el('social-upload-progress');
3333
+ const fill = progress ? progress.querySelector('.social-progress-fill') : null;
3334
+ const label = progress ? progress.querySelector('span') : null;
3335
+ if (progress) progress.classList.remove('hidden');
3336
+ try {
3337
+ const target = modal ? SocialState.modalMedia : SocialState.media;
3338
+ for (const f of Array.from(files)) {
3339
+ if (!isSocialMediaFile(f)) { showToast(f.name + ' is not an image or video', 'warning'); continue; }
3340
+ if (target.length >= MAX_POST_MEDIA) { showToast('A post can contain at most ' + MAX_POST_MEDIA + ' media files', 'warning'); break; }
3341
+ const up = await uploadSocialFile(f, (pct, name) => {
3342
+ if (fill) fill.style.width = Math.round(pct) + '%';
3343
+ if (label) label.textContent = 'Uploading ' + name + '… ' + Math.round(pct) + '%';
3344
+ });
3345
+ if (!up) continue;
3346
+ target.push({ file_path: up.file_path, file_name: up.file_name, file_type: up.file_type, file_size: up.file_size, preview_url: URL.createObjectURL(f) });
3347
+ if (fill) fill.style.width = '0%';
3348
+ }
3349
+ socialPreviewMedia();
3350
+ if (modal) socialPreviewModalMedia();
3351
+ } catch (e) { showToast(e.message, 'error'); }
3352
+ finally {
3353
+ if (progress) progress.classList.add('hidden');
3354
+ if (fill) fill.style.width = '0%';
3355
+ if (label) label.textContent = '';
3356
+ if (modal) _el('social-compose-modal-media').value = ''; else _el('social-media-input').value = '';
3357
+ }
3358
+ }
3359
+
3360
+ function socialPreviewModalMedia() {
3361
+ const list = SocialState.modalMedia;
3362
+ const box = _el('social-compose-modal-media-preview');
3363
+ if (!list.length) { box.classList.add('hidden'); box.innerHTML = ''; return; }
3364
+ box.classList.remove('hidden');
3365
+ box.innerHTML = list.map((m, i) => `
3366
+ <div class="social-media-preview-item">
3367
+ ${m.file_type && m.file_type.startsWith('video/') ? `<video src="${escapeHtml(m.preview_url || fileUrl(m.file_path))}" muted></video>` : `<img src="${escapeHtml(m.preview_url || fileUrl(m.file_path))}" alt="">`}
3368
+ <button class="btn-icon btn-icon-sm" data-remove-modal-media="${i}"><i class="fas fa-times"></i></button>
3369
+ </div>`).join('');
3370
+ }
3371
+
3372
+ async function submitSocialPost() {
3373
+ const body = _el('social-post-input').value.trim();
3374
+ if (!body && !SocialState.media.length) return;
3375
+ SocialState.media = SocialState.media.slice(0, MAX_POST_MEDIA);
3376
+ const mediaPayload = SocialState.media.map((m) => { const { preview_url, ...rest } = m; return rest; });
3377
+ const url = `/api/social/posts?body=${encodeURIComponent(body)}&media_json=${encodeURIComponent(JSON.stringify(mediaPayload))}`;
3378
+ try {
3379
+ const data = await apiPost(url);
3380
+ if (data.post) {
3381
+ _el('social-post-input').value = '';
3382
+ SocialState.media = [];
3383
+ socialPreviewMedia();
3384
+ _el('social-post-btn').disabled = true;
3385
+ showToast('Posted!', 'success');
3386
+ await SocialLoad.view(SocialState.view, true);
3387
+ }
3388
+ } catch (e) { showToast(e.message, 'error'); }
3389
+ }
3390
+
3391
+ async function submitModalSocialPost() {
3392
+ const body = _el('social-compose-modal-input').value.trim();
3393
+ const media = SocialState.modalMedia;
3394
+ if (!body && !media.length) return;
3395
+ try {
3396
+ let data;
3397
+ if (SocialState.editingPost) {
3398
+ if (!body.trim()) throw new Error('Post body cannot be empty');
3399
+ data = await apiPatch(`/api/social/posts/${SocialState.editingPost.id}?body=${encodeURIComponent(body)}`);
3400
+ } else {
3401
+ const q = [];
3402
+ q.push('body=' + encodeURIComponent(body));
3403
+ if (media.length) q.push('media_json=' + encodeURIComponent(JSON.stringify(media.map((m) => { const { preview_url, ...rest } = m; return rest; }))));
3404
+ if (SocialState.replyTarget) q.push('reply_to_id=' + socialInt(SocialState.replyTarget.id));
3405
+ if (SocialState.quoteTarget) q.push('quote_id=' + socialInt(SocialState.quoteTarget.id));
3406
+ data = await apiPost(`/api/social/posts?${q.join('&')}`);
3407
+ }
3408
+ if (data.post) {
3409
+ const wasEdit = !!SocialState.editingPost;
3410
+ closeModal(_el('social-compose-modal'));
3411
+ SocialState.editingPost = null;
3412
+ SocialState.modalMedia = []; SocialState.replyTarget = null; SocialState.quoteTarget = null;
3413
+ _el('social-compose-modal-input').value = '';
3414
+ _el('social-compose-modal-context').classList.add('hidden');
3415
+ socialPreviewModalMedia();
3416
+ showToast(wasEdit ? 'Post updated!' : 'Posted!', 'success');
3417
+ await SocialLoad.view(SocialState.view, true);
3418
+ }
3419
+ } catch (e) { showToast(e.message, 'error'); }
3420
+ }
3421
+
3422
+ function openSocialEditModal(post) {
3423
+ if (!post) return;
3424
+ SocialState.editingPost = post;
3425
+ SocialState.modalMode = 'edit';
3426
+ SocialState.replyTarget = null;
3427
+ SocialState.quoteTarget = null;
3428
+ _el('social-compose-modal-title').innerHTML = '<i class="fas fa-pen"></i> Edit Post';
3429
+ _el('social-compose-modal-input').value = post.body || '';
3430
+ _el('social-compose-modal-context').classList.add('hidden');
3431
+ _el('social-compose-modal-media').value = '';
3432
+ SocialState.modalMedia = [];
3433
+ socialPreviewModalMedia();
3434
+ openModal(_el('social-compose-modal'));
3435
+ setTimeout(() => _el('social-compose-modal-input').focus(), 50);
3436
+ }
3437
+
3438
+ function copySocialPostLink(post) {
3439
+ const url = `${location.origin}/#social/${post.id}`;
3440
+ const done = () => showToast('Post link copied', 'success');
3441
+ if (navigator.clipboard && navigator.clipboard.writeText) {
3442
+ navigator.clipboard.writeText(url).then(done).catch(() => { prompt('Post link', url); done(); });
3443
+ } else {
3444
+ prompt('Post link', url);
3445
+ done();
3446
+ }
3447
+ }
3448
+
3449
+
3450
+ function openSocialLightbox(name, url) {
3451
+ if (!url) return;
3452
+ DOM.lightboxName.textContent = name || 'Media';
3453
+ DOM.lightboxImg.src = url;
3454
+ DOM.lightboxDownload.href = url;
3455
+ openModal(DOM.lightboxModal);
3456
+ }
3457
+
3458
+ function socialInt(v) { const n = Number(v); return Number.isFinite(n) ? Math.floor(n) : null; }
3459
+
3460
+ function openSocialComposeModal(mode, target) {
3461
+ SocialState.editingPost = null;
3462
+ SocialState.modalMode = mode || 'compose';
3463
+ SocialState.replyTarget = mode === 'reply' ? target : null;
3464
+ SocialState.quoteTarget = mode === 'quote' ? target : null;
3465
+ const context = _el('social-compose-modal-context');
3466
+ if (SocialState.replyTarget || SocialState.quoteTarget) {
3467
+ const post = SocialState.replyTarget || SocialState.quoteTarget;
3468
+ context.classList.remove('hidden');
3469
+ const label = SocialState.replyTarget ? 'Replying to' : 'Quoting';
3470
+ context.innerHTML = `${label} <strong>@${escapeHtml(post.author?.username || '')}</strong> — ${escapeHtml(truncate(post.body || '', 120))}`;
3471
+ } else context.classList.add('hidden');
3472
+ _el('social-compose-modal-title').innerHTML = mode === 'reply' ? '<i class="fas fa-reply"></i> Reply' : mode === 'quote' ? '<i class="fas fa-quote-right"></i> Quote' : '<i class="fas fa-feather"></i> Compose';
3473
+ openModal(_el('social-compose-modal'));
3474
+ setTimeout(() => _el('social-compose-modal-input').focus(), 50);
3475
+ }
3476
+
3477
+ function userCardHtml(u) {
3478
+ const following = !!u.is_following;
3479
+ return `<div class="social-user-card" data-username="${escapeHtml(u.username || '')}">
3480
+ <div class="user-avatar">${socialAvatarHtml(u)}</div>
3481
+ <div class="social-user-info"><strong>${escapeHtml(displayNameOf(u))}</strong><span>@${escapeHtml(u.username || '')}</span></div>
3482
+ <button class="btn btn-primary btn-sm social-follow-btn${following ? ' following' : ''}" data-follow="${escapeHtml(u.username || '')}">${following ? 'Following' : 'Follow'}</button>
3483
+ </div>`;
3484
+ }
3485
+
3486
+ async function toggleFollow(username, btn) {
3487
+ try {
3488
+ const data = await apiPost(`/api/social/users/${encodeURIComponent(username)}/follow`);
3489
+ if (data.following) { SocialState.following.add(username.toLowerCase()); }
3490
+ else { SocialState.following.delete(username.toLowerCase()); }
3491
+ document.querySelectorAll(`[data-follow="${CSS.escape(username)}"], [data-profile-follow="${CSS.escape(username)}"]`).forEach(b => {
3492
+ b.textContent = data.following ? 'Following' : 'Follow';
3493
+ b.classList.toggle('following', data.following);
3494
+ });
3495
+ await SocialLoad.suggestions();
3496
+ } catch (e) { showToast(e.message, 'error'); }
3497
+ }
3498
+
3499
+ async function deleteSocialPost(id) {
3500
+ try {
3501
+ await apiDelete(`/api/social/posts/${id}`);
3502
+ const el = document.querySelector(`[data-post-id="${id}"]`);
3503
+ if (el) el.remove();
3504
+ showToast('Post deleted', 'success');
3505
+ } catch (e) { showToast(e.message, 'error'); }
3506
+ }
3507
+
3508
+ async function socialAction(id, action) {
3509
+ try {
3510
+ if (action === 'delete') { await deleteSocialPost(id); return; }
3511
+ const url = `/api/social/posts/${id}/${action}`;
3512
+ const data = await apiPost(url);
3513
+ const post = SocialState.posts.get(Number(id));
3514
+ if (post) {
3515
+ if (action === 'like') { post.liked = data.liked; post.like_count = data.like_count; }
3516
+ if (action === 'repost') { post.reposted = data.reposted; post.repost_count = data.repost_count; }
3517
+ if (action === 'bookmark') { post.bookmarked = data.bookmarked; post.bookmark_count = data.bookmark_count || post.bookmark_count; }
3518
+ }
3519
+ const card = document.querySelector(`[data-post-id="${id}"]`);
3520
+ if (card) {
3521
+ const b = card.querySelector(`[data-action="${action}"]`);
3522
+ if (b) {
3523
+ if (action === 'like') b.classList.toggle('active', data.liked);
3524
+ if (action === 'repost') b.classList.toggle('active', data.reposted);
3525
+ if (action === 'bookmark') b.classList.toggle('active', data.bookmarked);
3526
+ const span = b.querySelector('span');
3527
+ if (span && action !== 'bookmark') span.textContent = socialCount(data.like_count != null ? data.like_count : data.repost_count);
3528
+ }
3529
+ }
3530
+ } catch (e) { showToast(e.message, 'error'); }
3531
+ }
3532
+
3533
+ async function openProfile(username) {
3534
+ if (!username) return;
3535
+ try {
3536
+ SocialState.profileUser = { username };
3537
+ SocialState.isSearching = false;
3538
+ const prof = await apiGet(`/api/social/profile/${encodeURIComponent(username)}`);
3539
+ const posts = await apiGet(`/api/social/users/${encodeURIComponent(username)}/posts`);
3540
+ SocialState.profileUser = prof.profile;
3541
+ SocialState.posts.clear();
3542
+ (posts.posts || []).forEach(p => SocialState.posts.set(p.id, p));
3543
+ _el('social-compose').style.display = 'none';
3544
+ _el('social-page-title').textContent = '@' + username;
3545
+ _el('social-page-sub').textContent = displayNameOf(prof.profile) + "'s posts";
3546
+ document.querySelectorAll('.social-nav-link').forEach(l => l.classList.toggle('active', l.dataset.socialView === 'profile' && SocialState.user?.username === username));
3547
+ const p = prof.profile;
3548
+ const followBtn = p.is_self ? '' : `<button class="btn btn-primary btn-sm ${p.is_following ? 'following' : ''}" data-profile-follow="${escapeHtml(p.username)}">${p.is_following ? 'Following' : 'Follow'}</button>`;
3549
+ const header = `
3550
+ <section class="social-profile-card">
3551
+ <div class="social-profile-avatar">${p.avatar_path ? `<img src="${escapeHtml(fileUrl(p.avatar_path))}" alt="">` : `<span>${escapeHtml(initialOf(displayNameOf(p)))}</span>`}</div>
3552
+ <div class="social-profile-info">
3553
+ <div class="social-profile-name"><h2>${escapeHtml(displayNameOf(p))}</h2> <span class="text-secondary">@${escapeHtml(p.username)}</span> ${followBtn}</div>
3554
+ ${p.bio ? `<p class="social-profile-bio">${linkifySocial(p.bio)}</p>` : ''}
3555
+ <div class="social-profile-meta">
3556
+ ${p.location ? `<span><i class="fas fa-map-marker-alt"></i> ${escapeHtml(p.location)}</span>` : ''}
3557
+ ${p.website ? `<a href="${escapeHtml(p.website)}" target="_blank" rel="noopener"><i class="fas fa-link"></i> ${escapeHtml(p.website.replace(/^https?:\/\//,''))}</a>` : ''}
3558
+ </div>
3559
+ <div class="social-profile-stats">
3560
+ <span><strong>${socialCount(p.posts_count || 0)}</strong> Posts</span>
3561
+ <span><strong>${socialCount(p.followers_count || 0)}</strong> Followers</span>
3562
+ <span><strong>${socialCount(p.following_count || 0)}</strong> Following</span>
3563
+ </div>
3564
+ </div>
3565
+ </section>`;
3566
+ renderSocialPosts([], _el('social-feed'));
3567
+ _el('social-feed').innerHTML = header + (posts.posts && posts.posts.length ? posts.posts.map(socialPostHtml).join('') : '<div class="social-feed-empty"><i class="fas fa-feather"></i><h3>No posts yet</h3></div>');
3568
+ _el('social-search-input').value = '';
3569
+ _el('social-search-clear').classList.add('hidden');
3570
+ } catch (e) { showToast(e.message, 'error'); }
3571
+ }
3572
+
3573
+ const SocialLoad = {
3574
+ async view(name, force) {
3575
+ SocialState.isSearching = false;
3576
+ SocialState.profileUser = null;
3577
+ SocialState.view = name;
3578
+ document.querySelectorAll('.social-nav-link').forEach(l => l.classList.toggle('active', l.dataset.socialView === name));
3579
+ const titles = { home: ['Home', 'Posts from people you follow'], explore: ['Explore', 'Discover what is happening'], notifications: ['Notifications', 'Your social activity'], bookmarks: ['Bookmarks', 'Your saved posts'], profile: ['My Profile', 'Your public profile'] };
3580
+ const t = titles[name] || ['Home', ''];
3581
+ _el('social-page-title').textContent = t[0]; _el('social-page-sub').textContent = t[1];
3582
+ _el('social-compose').style.display = (name === 'home' || name === 'explore') ? '' : 'none';
3583
+ _el('social-search-input').value = '';
3584
+ _el('social-search-clear').classList.add('hidden');
3585
+ if (name === 'notifications') { await this.notifications(); return; }
3586
+ if (name === 'bookmarks') { await this.bookmarks(); return; }
3587
+ if (name === 'profile') { await openProfile(State.user.username); return; }
3588
+ const feed = name === 'explore' ? 'explore' : 'home';
3589
+ await this.feed(feed, force);
3590
+ },
3591
+ async feed(feed, force) {
3592
+ const loading = _el('social-loading');
3593
+ loading.classList.remove('hidden');
3594
+ try {
3595
+ const data = await apiGet(`/api/social/feed?feed=${feed}&limit=50`);
3596
+ if (!force) SocialState.posts.clear();
3597
+ let posts = data.posts || [];
3598
+ if (feed === 'home' && SocialState.settings.hideReposts) posts = posts.filter(p => !p.reposter_id);
3599
+ renderSocialPosts(posts);
3600
+ } catch (e) { showToast(e.message, 'error'); }
3601
+ finally { loading.classList.add('hidden'); }
3602
+ },
3603
+ async notifications() {
3604
+ try {
3605
+ const data = await apiGet(`/api/social/notifications`);
3606
+ const list = data.notifications || [];
3607
+ if (!list.length) { renderSocialPosts([], _el('social-feed')); return; }
3608
+ const unread = list.filter(n => !n.read).length;
3609
+ _el('social-unread-badge').textContent = unread;
3610
+ _el('social-unread-badge').classList.toggle('hidden', !unread || !SocialState.settings.badges);
3611
+ _el('social-feed').innerHTML = list.map(n => `
3612
+ <article class="social-notification" data-post-id="${n.post_id || ''}">
3613
+ <div class="social-avatar ah">${socialAvatarHtml(n.actor, 'social-avatar-img')}</div>
3614
+ <div class="social-post-main">
3615
+ <div class="social-notification-body"><strong>${escapeHtml(displayNameOf(n.actor))}</strong>
3616
+ <span class="social-username">@${escapeHtml(n.actor.username || '')}</span> ${escapeHtml(n.type)}d</div>
3617
+ <span class="social-time">${socialTime(n.created_at_ms)}</span>
3618
+ </div>
3619
+ </article>`).join('');
3620
+ await apiPost(`/api/social/notifications/read`);
3621
+ _el('social-unread-badge').classList.add('hidden');
3622
+ } catch (e) { showToast(e.message, 'error'); }
3623
+ },
3624
+ async bookmarks() {
3625
+ try {
3626
+ const data = await apiGet(`/api/social/bookmarks`);
3627
+ renderSocialPosts(data.posts || []);
3628
+ } catch (e) { showToast(e.message, 'error'); }
3629
+ },
3630
+ async trending() {
3631
+ try {
3632
+ const data = await apiGet(`/api/social/trending`);
3633
+ const box = _el('social-trending-list');
3634
+ if (!data.trending || !data.trending.length) { box.innerHTML = '<div class="text-secondary">No trends yet</div>'; return; }
3635
+ box.innerHTML = data.trending.slice(0, 8).map(t => `<button class="social-trend-item" data-hashtag="${encodeURIComponent(t.tag)}"><span class="social-trend-rank">${'#'}</span><span>#${escapeHtml(t.tag)}</span><span class="social-trend-count">${socialCount(t.count)} posts</span></button>`).join('');
3636
+ } catch (e) { /* non-fatal */ }
3637
+ },
3638
+ async suggestions() {
3639
+ try {
3640
+ const data = await apiGet(`/api/social/suggestions?limit=5`);
3641
+ const box = _el('social-suggest-list');
3642
+ if (!data.users || !data.users.length) { box.innerHTML = '<div class="text-secondary">You are all caught up</div>'; return; }
3643
+ box.innerHTML = data.users.map(userCardHtml).join('');
3644
+ } catch (e) { box.innerHTML = '<div class="text-secondary">Could not load suggestions</div>'; }
3645
+ }
3646
+ };
3647
+
3648
+ async function socialSearch(q) {
3649
+ q = (q || '').trim();
3650
+ _el('social-search-clear').classList.toggle('hidden', !q);
3651
+ if (!q) { await SocialLoad.view(SocialState.view || 'home'); return; }
3652
+ SocialState.isSearching = true;
3653
+ _el('social-compose').style.display = 'none';
3654
+ _el('social-page-title').textContent = 'Search';
3655
+ _el('social-page-sub').textContent = 'Results for "' + q + '"';
3656
+ try {
3657
+ const data = await apiGet(`/api/social/search?q=${encodeURIComponent(q)}`);
3658
+ (data.posts || []).forEach(p => SocialState.posts.set(p.id, p));
3659
+ const users = (data.users || []).map(userCardHtml).join('');
3660
+ const posts = (data.posts || []).map(socialPostHtml).join('');
3661
+ _el('social-feed').innerHTML = (users ? `<section class="social-search-users"><h3>People</h3>${users}</section>` : '') + (posts ? `<section class="social-search-posts"><h3>Posts</h3>${posts}</section>` : (!users && !posts ? '<div class="social-feed-empty"><i class="fas fa-search"></i><h3>No results</h3></div>' : ''));
3662
+ } catch (e) { showToast(e.message, 'error'); }
3663
+ }
3664
+
3665
+ function initSocial() {
3666
+ if (SocialState.inited) return;
3667
+ SocialState.inited = true;
3668
+ updateSocialMe();
3669
+ const sc = _el('social-screen');
3670
+ if (!sc) return;
3671
+ _el('social-back-btn').addEventListener('click', backToChat);
3672
+ _el('social-btn')?.addEventListener('click', openSocial);
3673
+ _el('social-compose-nav-btn')?.addEventListener('click', () => openSocialComposeModal());
3674
+ _el('social-post-btn')?.addEventListener('click', submitSocialPost);
3675
+ document.querySelectorAll('.social-nav-link').forEach(l => l.addEventListener('click', () => SocialLoad.view(l.dataset.socialView)));
3676
+ _el('social-media-input')?.addEventListener('change', e => addSocialMedia(e.target.files, false));
3677
+ _el('social-compose-modal-media')?.addEventListener('change', e => addSocialMedia(e.target.files, true));
3678
+ _el('social-compose-modal-submit')?.addEventListener('click', submitModalSocialPost);
3679
+ _el('social-search-input')?.addEventListener('input', debounce(e => socialSearch(e.target.value), 350));
3680
+ _el('social-search-clear')?.addEventListener('click', () => { _el('social-search-input').value=''; socialSearch(''); });
3681
+ _el('social-settings-btn')?.addEventListener('click', openSocialSettings);
3682
+ _el('social-settings-save')?.addEventListener('click', closeSocialSettingsAndSave);
3683
+ _el('social-post-input')?.addEventListener('input', () => { _el('social-post-btn').disabled = !_el('social-post-input').value.trim() && !SocialState.media.length; _el('social-post-count').textContent = _el('social-post-input').value.length ? `${_el('social-post-input').value.length}/28000` : ''; });
3684
+ _el('social-feed')?.addEventListener('click', async (e) => {
3685
+ const fbtn = e.target.closest('[data-follow]'), pbtn = e.target.closest('[data-profile-follow]');
3686
+ if (fbtn) { await toggleFollow(fbtn.dataset.follow, fbtn); return; }
3687
+ if (pbtn) { await toggleFollow(pbtn.dataset.profileFollow, pbtn); return; }
3688
+ const profile = e.target.closest('[data-username]');
3689
+ if (profile && !e.target.closest('.sp-action') && !e.target.closest('.social-follow-btn')) { openProfile(profile.dataset.username); return; }
3690
+ const mention = e.target.closest('[data-username]');
3691
+ if (mention) { openProfile(mention.dataset.username); return; }
3692
+ const hashtag = e.target.closest('[data-hashtag]');
3693
+ if (hashtag) { socialSearch('#' + decodeURIComponent(hashtag.dataset.hashtag)); _el('social-search-input').value = '#' + decodeURIComponent(hashtag.dataset.hashtag); return; }
3694
+ const mediaImg = e.target.closest('[data-media-image]');
3695
+ if (mediaImg && e.target.tagName.toLowerCase() === 'img') { openSocialLightbox(mediaImg.dataset.mediaName, mediaImg.dataset.mediaImage); return; }
3696
+ const actionBtn = e.target.closest('[data-action]');
3697
+ if (actionBtn) {
3698
+ const id = Number(actionBtn.dataset.id), action = actionBtn.dataset.action;
3699
+ if (action === 'reply') { const post = SocialState.posts.get(id); openSocialComposeModal('reply', post); return; }
3700
+ if (action === 'quote') { const post = SocialState.posts.get(id); openSocialComposeModal('quote', post); return; }
3701
+ if (action === 'edit') { const post = SocialState.posts.get(id); openSocialEditModal(post); return; }
3702
+ if (action === 'share') { const post = SocialState.posts.get(id); copySocialPostLink(post); return; }
3703
+ if (action === 'profile') { const un = actionBtn.dataset.username; openProfile(un); return; }
3704
+ await socialAction(id, action); return;
3705
+ }
3706
+ const notif = e.target.closest('.social-notification');
3707
+ if (notif && notif.dataset.postId) {
3708
+ try { const data = await apiGet(`/api/social/posts/${notif.dataset.postId}`); SocialState.posts.set(data.post.id, data.post); renderSocialPosts([data.post]); _el('social-compose').style.display='none'; }
3709
+ catch (e2) { showToast(e2.message, 'error'); }
3710
+ }
3711
+ });
3712
+ _el('social-media-preview')?.addEventListener('click', (e) => { const b = e.target.closest('[data-remove-media]'); if (b) { SocialState.media.splice(Number(b.dataset.removeMedia), 1); socialPreviewMedia(); } });
3713
+ _el('social-compose-modal-media-preview')?.addEventListener('click', (e) => { const b = e.target.closest('[data-remove-modal-media]'); if (b) { SocialState.modalMedia.splice(Number(b.dataset.removeModalMedia), 1); socialPreviewModalMedia(); } });
3714
+ _el('social-logout-btn')?.addEventListener('click', () => { const logoutBtn = document.getElementById('logout-btn'); if (logoutBtn) logoutBtn.click(); });
3715
+ _el('social-feed')?.classList.toggle('social-feed-compact', !!SocialState.settings.compact);
3716
+ _el('social-unread-badge')?.classList.toggle('hidden', !SocialState.settings.badges);
3717
+ if (location.hash.startsWith('#social/')) {
3718
+ const id = Number(location.hash.split('/').pop());
3719
+ if (id) {
3720
+ apiGet(`/api/social/posts/${id}`).then(d => {
3721
+ SocialState.posts.set(d.post.id, d.post);
3722
+ renderSocialPosts([d.post]);
3723
+ _el('social-compose').style.display = 'none';
3724
+ }).catch(() => {});
3725
+ }
3726
+ }
3727
+ SocialLoad.trending();
3728
+ SocialLoad.suggestions();
3729
+ }
3730
+
3731
+ function openSocialSettings() {
3732
+ const s = SocialState.settings;
3733
+ _el('social-setting-autoplay').checked = !!s.autoplay;
3734
+ _el('social-setting-muted').checked = !!s.muted;
3735
+ _el('social-setting-hide-reposts').checked = !!s.hideReposts;
3736
+ _el('social-setting-compact').checked = !!s.compact;
3737
+ _el('social-setting-live').checked = !!s.live;
3738
+ _el('social-setting-badges').checked = !!s.badges;
3739
+ _el('social-setting-default').value = s.defaultFeed || 'home';
3740
+ openModal(_el('social-settings-modal'));
3741
+ }
3742
+
3743
+ function applySocialSettings() {
3744
+ const s = SocialState.settings;
3745
+ saveSocialSettings();
3746
+ const feed = _el('social-feed');
3747
+ if (feed) feed.classList.toggle('social-feed-compact', !!s.compact);
3748
+ _el('social-unread-badge').classList.toggle('hidden', !s.badges);
3749
+ if (_el('social-screen').classList.contains('active')) {
3750
+ if (s.live) startSocialPoll(); else stopSocialPoll();
3751
+ SocialLoad.view(SocialState.view, true);
3752
+ }
3753
+ }
3754
+
3755
+ function closeSocialSettingsAndSave() {
3756
+ const s = SocialState.settings;
3757
+ s.autoplay = _el('social-setting-autoplay').checked;
3758
+ s.muted = _el('social-setting-muted').checked;
3759
+ s.hideReposts = _el('social-setting-hide-reposts').checked;
3760
+ s.compact = _el('social-setting-compact').checked;
3761
+ s.live = _el('social-setting-live').checked;
3762
+ s.badges = _el('social-setting-badges').checked;
3763
+ s.defaultFeed = _el('social-setting-default').value;
3764
+ applySocialSettings();
3765
+ closeModal(_el('social-settings-modal'));
3766
+ showToast('Social settings saved', 'success');
3767
+ }
3768
+
3769
+
3770
  async function init() {
3771
  applyPrefs();
3772
  DOM.aboutVersion.textContent = APP_VERSION;
 
3778
  State.user = data.user;
3779
  showChat();
3780
  connectWebSocket();
3781
+ initSocial();
3782
  return;
3783
  }
3784
  } catch (e) { /* fall through to auth screen */ }
 
3796
 
3797
  function showChat() {
3798
  DOM.authScreen.classList.remove('active');
3799
+ const sc = document.getElementById('social-screen');
3800
+ if (sc) sc.classList.remove('active');
3801
  DOM.chatScreen.classList.add('active');
3802
  updateSidebarMe();
3803
  }