inhabitants_view.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. const { div, h2, p, section, button, form, img, a, textarea, input, br, span, strong } = require("../server/node_modules/hyperaxe");
  2. const { template, i18n, userLink} = require('./main_views');
  3. const { renderUrl } = require('../backend/renderUrl');
  4. const { getConfig } = require('../configs/config-manager');
  5. const DEFAULT_HASH_ENC = "%260000000000000000000000000000000000000000000%3D.sha256";
  6. const DEFAULT_HASH_PATH_RE = /\/image\/\d+\/%260000000000000000000000000000000000000000000%3D\.sha256$/;
  7. function isDefaultImageId(v){
  8. if (!v) return true;
  9. if (typeof v === 'string') {
  10. if (v === DEFAULT_HASH_ENC) return true;
  11. if (DEFAULT_HASH_PATH_RE.test(v)) return true;
  12. }
  13. return false;
  14. }
  15. function toImageUrl(imgId, size=256){
  16. if (!imgId || isDefaultImageId(imgId)) return '/assets/images/default-avatar.png';
  17. if (typeof imgId === 'string' && imgId.startsWith('/image/')) {
  18. return imgId.replace('/image/256/','/image/'+size+'/').replace('/image/512/','/image/'+size+'/');
  19. }
  20. return `/image/${size}/${encodeURIComponent(imgId)}`;
  21. }
  22. function extractAboutImageId(about){
  23. if (!about || typeof about !== 'object') return null;
  24. const aimg = about.image;
  25. if (!aimg) return null;
  26. if (typeof aimg === 'string') return aimg;
  27. return aimg.link || aimg.url || null;
  28. }
  29. function resolvePhoto(photoField, size = 256) {
  30. if (!photoField) return '/assets/images/default-avatar.png';
  31. if (typeof photoField === 'string') {
  32. if (photoField.startsWith('/assets/')) return photoField;
  33. if (photoField.startsWith('/blob/')) return photoField;
  34. if (photoField.startsWith('/image/')) {
  35. if (isDefaultImageId(photoField)) return '/assets/images/default-avatar.png';
  36. return photoField.replace('/image/256/','/image/'+size+'/').replace('/image/512/','/image/'+size+'/');
  37. }
  38. }
  39. return toImageUrl(photoField, size);
  40. }
  41. const generateFilterButtons = (filters, currentFilter) =>
  42. filters.map(mode =>
  43. form({ method: 'GET', action: '/inhabitants' },
  44. input({ type: 'hidden', name: 'filter', value: mode }),
  45. button({
  46. type: 'submit',
  47. class: currentFilter === mode ? 'filter-btn active' : 'filter-btn'
  48. }, i18n[mode + 'Button'] || i18n[mode + 'SectionTitle'] || mode)
  49. )
  50. );
  51. function lastActivityBadge(user, isMe) {
  52. const bucket = user && user.lastActivityBucket;
  53. const dotClass =
  54. bucket === 'green' ? 'green' : bucket === 'orange' ? 'orange' : bucket === 'red' ? 'red' : null;
  55. if (!dotClass) return [];
  56. const items = [
  57. span({ class: 'inhabitant-last-activity' },
  58. `${i18n.inhabitantActivityLevel}: `,
  59. span({ class: `activity-dot ${dotClass}` }, '●'))
  60. ];
  61. const currentTheme = getConfig().themes.current;
  62. const src = isMe ? (currentTheme === 'OasisKIT' ? 'KIT' : (currentTheme === 'OasisMobile' || process.env.OASIS_MOBILE === '1') ? 'MOBILE' : 'DESKTOP') : (user && user.deviceSource) || null;
  63. if (src) {
  64. const upper = String(src).toUpperCase();
  65. const deviceClass = upper === 'KIT' ? 'device-kit' : upper === 'MOBILE' ? 'device-mobile' : 'device-desktop';
  66. items.push(span({ class: 'inhabitant-last-activity' },
  67. `${i18n.deviceLabel || 'Device'}: `,
  68. span({ class: deviceClass }, src)));
  69. }
  70. return [div({ class: 'inhabitant-activity-group' }, ...items)];
  71. }
  72. const lightboxId = (id) => 'inhabitant_' + String(id || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
  73. const renderInhabitantCard = (user, filter, currentUserId) => {
  74. const isMe = user.id === currentUserId;
  75. return div({ class: 'inhabitant-card' },
  76. div({ class: 'inhabitant-left' },
  77. a(
  78. { href: `/author/${encodeURIComponent(user.id)}` },
  79. img({ class: 'inhabitant-photo-details', src: resolvePhoto(user.photo, 256), alt: user.name || 'Anonymous' })
  80. ),
  81. br(),
  82. ...lastActivityBadge(user, isMe),
  83. div({ class: 'inhabitant-karma-ubi' },
  84. span({ class: 'karma-line' }, `${i18n.bankingUserEngagementScore}: `, strong(String(typeof user.karmaScore === 'number' ? user.karmaScore : 0)))
  85. )
  86. ),
  87. div({ class: 'inhabitant-details' },
  88. h2(user.name || 'Anonymous'),
  89. user.description ? p(...renderUrl(user.description)) : null,
  90. filter === 'MATCHSKILLS' && user.commonSkills?.length
  91. ? div({ class: 'matchskills' },
  92. p(`${i18n.commonSkills}: ${user.commonSkills.join(', ')}`),
  93. p(`${i18n.matchScore}: ${Math.round(user.matchScore * 100)}%`)
  94. )
  95. : null,
  96. filter === 'SUGGESTED' && user.mutualCount
  97. ? p(`${i18n.mutualFollowers}: ${user.mutualCount}`) : null,
  98. filter === 'blocked' && user.isBlocked
  99. ? p(i18n.blockedLabel) : null,
  100. p(userLink(user.id)),
  101. user.ecoAddress
  102. ? div({ class: "eco-wallet" },
  103. p(`${i18n.bankWalletConnected}: `, strong(user.ecoAddress))
  104. )
  105. : div({ class: "eco-wallet" },
  106. p(i18n.ecoWalletNotConfigured || "ECOin Wallet not configured")
  107. ),
  108. div(
  109. { class: 'cv-actions' },
  110. !isMe
  111. ? form(
  112. { method: 'GET', action: `/inhabitant/${encodeURIComponent(user.id)}` },
  113. button({ type: 'submit', class: 'btn' }, i18n.inhabitantviewDetails)
  114. )
  115. : p(i18n.relationshipYou),
  116. !isMe
  117. ? form(
  118. { method: 'GET', action: '/pm' },
  119. input({ type: 'hidden', name: 'recipients', value: user.id }),
  120. button({ type: 'submit', class: 'btn' }, i18n.pmCreateButton)
  121. )
  122. : null
  123. )
  124. )
  125. );
  126. };
  127. const renderGalleryInhabitants = inhabitants =>
  128. div(
  129. { class: "gallery" },
  130. inhabitants.length
  131. ? inhabitants.map(u =>
  132. a({ href: `#${lightboxId(u.id)}`, class: "gallery-item" },
  133. img({ src: resolvePhoto(u.photo, 256), alt: u.name || "Anonymous", class: "gallery-image" })
  134. )
  135. )
  136. : p(i18n.noInhabitantsFound)
  137. );
  138. const renderLightbox = inhabitants =>
  139. inhabitants.map(u =>
  140. div(
  141. { id: lightboxId(u.id), class: "lightbox" },
  142. a({ href: "#", class: "lightbox-close" }, "×"),
  143. img({ src: resolvePhoto(u.photo, 256), class: "lightbox-image", alt: u.name || "Anonymous" })
  144. )
  145. );
  146. function stripAndCollectImgs(text) {
  147. if (!text || typeof text !== 'string') return { clean: '', imgs: [] };
  148. const imgs = [];
  149. let clean = text;
  150. const rawImgRe = /<img[^>]*src="([^"]+)"[^>]*>/gi;
  151. clean = clean.replace(rawImgRe, (_, src) => { imgs.push(src); return ''; });
  152. const encImgRe = /&lt;img[^&]*src=&quot;([^&]*)&quot;[^&]*&gt;/gi;
  153. clean = clean.replace(encImgRe, (_, src) => { imgs.push(src.replace(/&amp;/g, '&')); return ''; });
  154. return { clean, imgs };
  155. }
  156. function msgIdOf(m) {
  157. return m && (m.key || m.value?.key || m.value?.content?.root || m.value?.content?.branch || null);
  158. }
  159. exports.inhabitantsView = (inhabitants, filter, query, currentUserId) => {
  160. const title = filter === 'contacts' ? i18n.yourContacts
  161. : filter === 'CVs' ? i18n.allCVs
  162. : filter === 'MATCHSKILLS' ? i18n.matchSkills
  163. : filter === 'SUGGESTED' ? i18n.suggestedSectionTitle
  164. : filter === 'blocked' ? i18n.blockedSectionTitle
  165. : filter === 'GALLERY' ? i18n.gallerySectionTitle
  166. : filter === 'TOP KARMA' ? i18n.topkarmaSectionTitle
  167. : filter === 'TOP ACTIVITY' ? i18n.topactivitySectionTitle
  168. : i18n.allInhabitants;
  169. const showCVFilters = filter === 'CVs' || filter === 'MATCHSKILLS';
  170. const filters = ['all', 'TOP ACTIVITY', 'TOP KARMA', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'];
  171. return template(
  172. title,
  173. section(
  174. div({ class: 'tags-header' },
  175. h2(title),
  176. p(i18n.discoverPeople)
  177. ),
  178. div({ class: 'filters' },
  179. form({ method: 'GET', action: '/inhabitants' },
  180. input({ type: 'hidden', name: 'filter', value: filter }),
  181. input({
  182. type: 'text',
  183. name: 'search',
  184. placeholder: i18n.searchInhabitantsPlaceholder,
  185. value: (query && query.search) || ''
  186. }),
  187. showCVFilters
  188. ? [
  189. input({ type: 'text', name: 'location', placeholder: i18n.filterLocation, value: (query && query.location) || '' }),
  190. input({ type: 'text', name: 'language', placeholder: i18n.filterLanguage, value: (query && query.language) || '' }),
  191. input({ type: 'text', name: 'skills', placeholder: i18n.filterSkills, value: (query && query.skills) || '' })
  192. ]
  193. : null,
  194. br(),
  195. button({ type: 'submit' }, i18n.applyFilters)
  196. )
  197. ),
  198. div({ class: 'inhabitant-action' },
  199. ...generateFilterButtons(filters, filter)
  200. ),
  201. filter === 'GALLERY'
  202. ? renderGalleryInhabitants(inhabitants)
  203. : div({ class: 'inhabitants-list' },
  204. inhabitants.length
  205. ? inhabitants.map(user => renderInhabitantCard(user, filter, currentUserId))
  206. : p({ class: 'no-results' }, i18n.noInhabitantsFound)
  207. ),
  208. ...renderLightbox(inhabitants)
  209. )
  210. );
  211. };
  212. exports.inhabitantsProfileView = (payload, currentUserId) => {
  213. const safe = payload && typeof payload === 'object' ? payload : {};
  214. const about = (safe.about && typeof safe.about === 'object') ? safe.about : {};
  215. const cv = (safe.cv && typeof safe.cv === 'object') ? safe.cv : {};
  216. const feed = Array.isArray(safe.feed) ? safe.feed : [];
  217. const viewedId = typeof safe.viewedId === 'string' ? safe.viewedId : '';
  218. const id = (cv && cv.author) || (about && about.about) || viewedId || '';
  219. const baseName = ((cv && cv.name) || (about && about.name) || '').trim();
  220. const name = baseName || (i18n.unnamed || 'Anonymous');
  221. const description = (cv && cv.description) || (about && about.description) || '';
  222. const listPhoto = (typeof safe.photo === 'string' && safe.photo.trim()) ? safe.photo : null;
  223. const rawCandidate = listPhoto || extractAboutImageId(about) || (cv && cv.photo) || null;
  224. const image = (
  225. typeof rawCandidate === 'string' &&
  226. rawCandidate.startsWith('/image/') &&
  227. !DEFAULT_HASH_PATH_RE.test(rawCandidate) &&
  228. rawCandidate.indexOf(DEFAULT_HASH_ENC) === -1
  229. )
  230. ? rawCandidate.replace('/image/512/','/image/256/').replace('/image/1024/','/image/256/')
  231. : resolvePhoto(rawCandidate, 256);
  232. const location = (cv && cv.location) || '';
  233. const languages = typeof (cv && cv.languages) === 'string'
  234. ? (cv.languages || '').split(',').map(x => x.trim()).filter(Boolean)
  235. : Array.isArray(cv && cv.languages) ? cv.languages : [];
  236. const skills = [
  237. ...((cv && cv.personalSkills) || []),
  238. ...((cv && cv.oasisSkills) || []),
  239. ...((cv && cv.educationalSkills) || []),
  240. ...((cv && cv.professionalSkills) || [])
  241. ];
  242. const status = (cv && cv.status) || '';
  243. const preferences = (cv && cv.preferences) || '';
  244. const createdAt = (cv && cv.createdAt) ? new Date(cv.createdAt).toLocaleString() : '';
  245. const isMe = id && id === currentUserId;
  246. const title = i18n.inhabitantProfileTitle || i18n.inhabitantviewDetails;
  247. const karmaScore = typeof safe.karmaScore === 'number' ? safe.karmaScore : 0;
  248. const providedBucket = typeof safe.lastActivityBucket === 'string' ? safe.lastActivityBucket : null;
  249. const dotClass = providedBucket === 'green' ? 'green' : providedBucket === 'orange' ? 'orange' : 'red';
  250. const detailNodes = [
  251. description ? p(...renderUrl(description)) : null,
  252. location ? p(`${i18n.locationLabel}: ${location}`) : null,
  253. languages.length ? p(`${i18n.languagesLabel}: ${languages.join(', ').toUpperCase()}`) : null,
  254. skills.length ? p(`${i18n.skillsLabel}: ${skills.join(', ')}`) : null,
  255. status ? p(`${i18n.statusLabel || 'Status'}: ${status}`) : null,
  256. preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${preferences}`) : null,
  257. createdAt ? p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`) : null
  258. ].filter(Boolean);
  259. return template(
  260. name,
  261. section(
  262. div({ class: 'tags-header' },
  263. h2(title),
  264. p(i18n.discoverPeople)
  265. ),
  266. div({ class: 'mode-buttons' },
  267. ...generateFilterButtons(['all', 'TOP ACTIVITY', 'TOP KARMA', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
  268. ),
  269. div({ class: 'inhabitant-card' },
  270. div({ class: 'inhabitant-left' },
  271. img({ class: 'inhabitant-photo-details', src: image, alt: name || 'Anonymous' }),
  272. h2(name || 'Anonymous'),
  273. ...lastActivityBadge({ lastActivityBucket: dotClass, deviceSource: safe.deviceSource }, isMe),
  274. div({ class: 'inhabitant-karma-ubi' },
  275. span({ class: 'karma-line' }, `${i18n.bankingUserEngagementScore}: `, strong(String(karmaScore)))
  276. ),
  277. (!isMe && (id || viewedId))
  278. ? form(
  279. { method: 'GET', action: '/pm' },
  280. input({ type: 'hidden', name: 'recipients', value: id || viewedId }),
  281. button({ type: 'submit', class: 'btn' }, i18n.pmCreateButton)
  282. )
  283. : null
  284. ),
  285. detailNodes.length ? div({ class: 'inhabitant-details' }, ...detailNodes) : null
  286. ),
  287. feed.length
  288. ? section({ class: 'profile-feed' },
  289. h2(i18n.latestInteractions),
  290. ...feed.map(m => {
  291. const raw = (m.value?.content?.text || '').replace(/<br\s*\/?>/g, '');
  292. const parts = stripAndCollectImgs(raw);
  293. const tid = msgIdOf(m);
  294. const visitBtn = tid
  295. ? form({ method: 'GET', action: `/thread/${encodeURIComponent(tid)}#${encodeURIComponent(tid)}` },
  296. button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
  297. )
  298. : null;
  299. return div({ class: 'post' },
  300. visitBtn,
  301. parts.clean && parts.clean.trim() ? p(...renderUrl(parts.clean)) : null,
  302. ...(parts.imgs || []).map(src => img({ src, class: 'post-image', alt: 'image' }))
  303. );
  304. })
  305. )
  306. : null
  307. )
  308. );
  309. };
  310. exports.lastActivityBadge = lastActivityBadge;