FeedList.coffee 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. class FeedList extends Class
  2. constructor: ->
  3. @feeds = null
  4. @searching = null
  5. @searching_text = null
  6. @searched = null
  7. @res = null
  8. @loading = false
  9. @filter = null
  10. @feed_types = {}
  11. @need_update = false
  12. @updating = false
  13. @limit = 30
  14. @query_limit = 20
  15. @query_day_limit = 3
  16. @show_stats = false
  17. @feed_keys = {}
  18. @date_feed_visit = null
  19. @date_save_feed_visit = 0
  20. Page.on_settings.then =>
  21. @need_update = true
  22. document.body.onscroll = =>
  23. RateLimit 300, =>
  24. @checkScroll()
  25. @
  26. checkScroll: =>
  27. scroll_top = window.pageYOffset or document.documentElement.scrollTop or document.body.scrollTop or 0
  28. if scroll_top + window.innerHeight > document.getElementById("FeedList").clientHeight - 400 and not @updating and @feeds?.length > 5 and Page.mode == "Sites" and @limit < 300
  29. @limit += 30
  30. @query_limit += 30
  31. if @query_day_limit != null
  32. @query_day_limit += 5
  33. if @query_day_limit > 30
  34. @query_day_limit = null
  35. @log "checkScroll update"
  36. if @searching and Page.server_info.rev >= 3817
  37. @search(@searching)
  38. else
  39. @update()
  40. return true
  41. else
  42. return false
  43. displayRows: (rows, search) =>
  44. @feeds = []
  45. @feed_keys = {}
  46. if not rows
  47. return false
  48. rows.sort (a, b) ->
  49. return a.date_added + (if a.type == "mention" then 1 else 0) - b.date_added - (if b.type == "mention" then 1 else 0) # Prefer mention
  50. row_group = {}
  51. last_row = {}
  52. @feed_types = {}
  53. rows.reverse()
  54. for row in rows
  55. if last_row.body == row.body and last_row.date_added == row.date_added
  56. continue # Duplicate (eg. also signed up for comments and mentions)
  57. if row_group.type == row.type and row.url == row_group.url and row.site == row_group.site
  58. if not row_group.body_more?
  59. row_group.body_more = []
  60. row_group.body_more.push(row.body)
  61. else if row_group.body_more.length < 3
  62. row_group.body_more.push(row.body)
  63. else
  64. row_group.more ?= 0
  65. row_group.more += 1
  66. row_group.feed_id = row.date_added
  67. else
  68. row.feed_id ?= row.date_added
  69. row.key = row.site + row.type + row.title + row.feed_id
  70. if @feed_keys[row.key]
  71. @log "Duplicate feed key: #{row.key}"
  72. else
  73. @feeds.push(row)
  74. @feed_keys[row.key] = true
  75. row_group = row
  76. @feed_types[row.type] = true
  77. last_row = row
  78. Page.projector.scheduleRender()
  79. update: (cb) =>
  80. if @searching or @updating
  81. return false
  82. if not Page.server_info or Page.server_info.rev < 1850
  83. params = []
  84. else
  85. params = {limit: @query_limit, day_limit: @query_day_limit}
  86. @logStart "Updating feed", params
  87. @updating = true
  88. Page.cmd "feedQuery", params, (res) =>
  89. if res.rows
  90. rows = res.rows
  91. else
  92. rows = res
  93. @res = res
  94. if rows.length < 10 and @query_day_limit != null
  95. @log "Only #{res.rows.length} results, query without day limit"
  96. @query_limit = 20
  97. @query_day_limit = null
  98. @updating = false
  99. @update()
  100. return false
  101. @displayRows(rows)
  102. setTimeout @checkScroll, 100
  103. @logEnd "Updating feed"
  104. if cb then cb()
  105. @updating = false
  106. search: (search, cb) =>
  107. if Page.server_info.rev < 1230
  108. @displayRows([])
  109. if cb then cb()
  110. return
  111. if not Page.server_info or Page.server_info.rev < 3817
  112. params = search
  113. else
  114. params = {search: search, limit: @query_limit * 3, day_limit: @query_day_limit * 10 or null}
  115. @log "Searching for", params
  116. @loading = true
  117. Page.projector.scheduleRender()
  118. Page.cmd "feedSearch", params, (res) =>
  119. @loading = false
  120. if res.rows.length < 10 and @query_day_limit != null
  121. @log "Only #{res.rows.length} results, search without day limit"
  122. @query_limit = 30
  123. @query_day_limit = null
  124. @search(search, cb)
  125. return false
  126. @displayRows(res["rows"], search)
  127. delete res["rows"]
  128. @res = res
  129. @searched = search
  130. if cb then cb()
  131. # Focus on search input if key pressed an no input on focus
  132. storeNodeSearch: (node) =>
  133. document.body.onkeypress = (e) =>
  134. if e.charCode in [0, 32] # Not a normal character or space
  135. return
  136. if document.activeElement?.tagName != "INPUT"
  137. node.focus()
  138. handleSearchInput: (e) =>
  139. if @searching?.length > 3
  140. delay = 400
  141. else
  142. delay = 800
  143. # More delay for heavy clients
  144. if Page.site_list.sites.length > 300
  145. delay = delay * 3
  146. else if Page.site_list.sites.length > 100
  147. delay = delay * 2
  148. @searching = e.target.value
  149. @searching_text = @searching.replace(/[^ ]+:.*$/, "").trim()
  150. if Page.server_info.rev < 1230
  151. @feeds = []
  152. @feed_keys = {}
  153. if e.target.value == "" # No delay when returning to newsfeed
  154. delay = 1
  155. if e.keyCode == 13 # Enter
  156. delay = 1
  157. clearInterval @input_timer
  158. setTimeout =>
  159. @waiting = true
  160. # Delay calls to reduce server load
  161. @input_timer = setTimeout ( =>
  162. RateLimitCb delay, (cb_done) =>
  163. @limit = 30
  164. @query_limit = 20
  165. @query_day_limit = 3
  166. @waiting = false
  167. if @searching
  168. @search @searching, =>
  169. cb_done()
  170. else
  171. @update =>
  172. cb_done()
  173. if not @searching
  174. @searching = null
  175. @searched = null
  176. ), delay
  177. return false
  178. handleSearchKeyup: (e) =>
  179. if e.keyCode == 27 # Esc
  180. e.target.value = ""
  181. @handleSearchInput(e)
  182. if e.keyCode == 13 # Enter
  183. @handleSearchInput(e)
  184. return false
  185. handleFilterClick: (e) =>
  186. @filter = e.target.getAttribute("href").replace("#", "")
  187. if @filter == "all"
  188. @filter = null
  189. return false
  190. handleSearchInfoClick: (e) =>
  191. @show_stats = not @show_stats
  192. return false
  193. handleSearchClear: (e) =>
  194. e.target.value = ""
  195. @handleSearchInput(e)
  196. return false
  197. formatTitle: (title) ->
  198. if @searching_text and @searching_text.length > 1
  199. return Text.highlight(title, @searching_text)
  200. else
  201. if title
  202. return title
  203. else
  204. return ""
  205. formatBody: (body, type) ->
  206. body = body.replace(/[\n\r]+/, "\n") # Remove empty lines
  207. if type == "comment" or type == "mention"
  208. # Display Comment
  209. username_match = body.match(/^(([a-zA-Z0-9\.]+)@[a-zA-Z0-9\.]+|@(.*?)):/)
  210. if username_match
  211. if username_match[2]
  212. username_formatted = username_match[2] + " › "
  213. else
  214. username_formatted = username_match[3] + " › "
  215. body = body.replace(/> \[(.*?)\].*/g, "$1: ") # Replace original message quote
  216. body = body.replace(/^[ ]*>.*/gm, "") # Remove quotes
  217. body = body.replace(username_match[0], "") # Remove commenter from body
  218. else
  219. username_formatted = ""
  220. body = body.replace(/\n/g, " ")
  221. body = body.trim()
  222. # Highligh matched search parts
  223. if @searching_text and @searching_text.length > 1
  224. body = Text.highlight(body, @searching_text)
  225. if body[0].length > 60 and body.length > 1
  226. body[0] = "..."+body[0][body[0].length-50..body[0].length-1]
  227. return [h("b", Text.highlight(username_formatted, @searching_text)), body]
  228. else
  229. body = body[0..200]
  230. return [h("b", [username_formatted]), body]
  231. else
  232. # Display post
  233. body = body.replace(/\n/g, " ")
  234. # Highligh matched search parts
  235. if @searching_text and @searching_text.length > 1
  236. body = Text.highlight(body, @searching_text)
  237. if body[0].length > 60
  238. body[0] = "..."+body[0][body[0].length-50..body[0].length-1]
  239. else
  240. body = body[0..200]
  241. return body
  242. formatType: (type, title) ->
  243. if type == "comment"
  244. return "Comment on"
  245. else if type == "mention"
  246. if title
  247. return "You got mentioned in"
  248. else
  249. return "You got mentioned"
  250. else
  251. return ""
  252. enterAnimation: (elem, props) =>
  253. if @searching == null
  254. return Animation.slideDown.apply(this, arguments)
  255. else
  256. return null
  257. exitAnimation: (elem, remove_func, props) =>
  258. if @searching == null
  259. return Animation.slideUp.apply(this, arguments)
  260. else
  261. remove_func()
  262. renderFeed: (feed) =>
  263. if @filter and feed.type != @filter
  264. return null
  265. try
  266. site = Page.site_list.item_list.items_bykey[feed.site]
  267. type_formatted = @formatType(feed.type, feed.title)
  268. classes = {}
  269. if @date_feed_visit and feed.date_added > @date_feed_visit
  270. classes["new"] = true
  271. return h("div.feed."+feed.type, {key: feed.key, enterAnimation: @enterAnimation, exitAnimation: @exitAnimation, classes: classes}, [
  272. h("div.details", [
  273. h("span.dot", {title: "new"}, "\u2022"),
  274. h("a.site", {href: site.getHref()}, [site.row.content.title]),
  275. h("div.added", [Time.since(feed.date_added)])
  276. ]),
  277. h("div.circle", {style: "border-color: #{Text.toColor(feed.type+site.row.address, 60, 60)}"}),
  278. h("div.title-container", [
  279. if type_formatted then h("span.type", type_formatted),
  280. h("a.title", {href: site.getHref()+feed.url}, @formatTitle(feed.title))
  281. ])
  282. h("div.body", {key: feed.body, enterAnimation: @enterAnimation, exitAnimation: @exitAnimation}, @formatBody(feed.body, feed.type))
  283. if feed.body_more # Display comments
  284. feed.body_more.map (body_more) =>
  285. h("div.body", {key: body_more, enterAnimation: @enterAnimation, exitAnimation: @exitAnimation}, @formatBody(body_more, feed.type))
  286. if feed.more > 0 # Collapse other types
  287. h("a.more", {href: site.getHref()+feed.url}, ["+#{feed.more} more"])
  288. ])
  289. catch err
  290. @log err
  291. return h("div", key: Time.timestamp())
  292. renderWelcome: =>
  293. h("div.welcome", [
  294. h("img", {src: "img/logo.svg", height: 150, onerror: "this.src='img/logo.png'; this.onerror=null;"})
  295. h("h1", "Welcome to ZeroNet")
  296. h("h2", "Let's build a decentralized Internet together!")
  297. h("div.served", ["This site currently served by ", h("b.peers", (Page.site_info["peers"] or "n/a")), " peers, without any central server."])
  298. h("div.sites", [
  299. h("h3", "Some sites we created:"),
  300. h("a.site.site-zerotalk", {href: Text.getSiteUrl("Talk.ZeroNetwork.bit")}, [
  301. h("div.title", ["ZeroTalk"])
  302. h("div.description", ["Reddit-like, decentralized forum"])
  303. h("div.visit", ["Activate \u2501"])
  304. ]),
  305. h("a.site.site-zeroblog", {href: Text.getSiteUrl("Blog.ZeroNetwork.bit")}, [
  306. h("div.title", ["ZeroBlog"])
  307. h("div.description", ["Microblogging platform"])
  308. h("div.visit", ["Activate \u2501"])
  309. ]),
  310. h("a.site.site-zeromail", {href: Text.getSiteUrl("Mail.ZeroNetwork.bit")}, [
  311. h("div.title", ["ZeroMail"])
  312. h("div.description", ["End-to-end encrypted mailing"])
  313. h("div.visit", ["Activate \u2501"])
  314. ]),
  315. h("a.site.site-zerome", {href: Text.getSiteUrl("Me.ZeroNetwork.bit")}, [
  316. h("div.title", ["ZeroMe"])
  317. h("div.description", ["P2P social network"])
  318. h("div.visit", ["Activate \u2501"])
  319. ]),
  320. h("a.site.site-zerosites", {href: Text.getSiteUrl("Sites.ZeroNetwork.bit")}, [
  321. h("div.title", ["ZeroSites"])
  322. h("div.description", ["Discover more sites"])
  323. h("div.visit", ["Activate \u2501"])
  324. ])
  325. ])
  326. ])
  327. renderSearchStat: (stat) =>
  328. if stat.taken == 0
  329. return null
  330. total_taken = @res.taken
  331. site = Page.site_list.item_list.items_bykey[stat.site]
  332. if not site
  333. return []
  334. back = []
  335. back.push(h("tr", {key: stat.site + "_" + stat.feed_name, classes: {"slow": stat.taken > total_taken * 0.1, "extra-slow": stat.taken > total_taken * 0.3}}, [
  336. h("td.site", h("a.site", {href: site.getHref()}, [site.row.content.title])),
  337. h("td.feed_name", stat.feed_name),
  338. h("td.taken", (if stat.taken? then stat.taken + "s" else "n/a "))
  339. ]))
  340. if stat.error
  341. back.push(h("tr.error",
  342. h("td", "Error:")
  343. h("td", {colSpan: 2}, stat.error)
  344. ))
  345. return back
  346. handleNotificationHideClick: (e) =>
  347. address = e.target.getAttribute("address")
  348. Page.settings.siteblocks_ignore[address] = true
  349. Page.mute_list.update()
  350. Page.saveSettings()
  351. return false
  352. renderNotifications: =>
  353. h("div.notifications", {classes: {empty: Page.mute_list.siteblocks_serving.length == 0}}, [
  354. Page.mute_list.siteblocks_serving.map (siteblock) =>
  355. h("div.notification", {key: siteblock.address, enterAnimation: Animation.show, exitAnimation: Animation.slideUpInout}, [
  356. h("a.hide", {href: "#Hide", onclick: @handleNotificationHideClick, address: siteblock.address}, "\u00D7"),
  357. "You are serving a blocked site: ",
  358. h("a.site", {href: siteblock.site.getHref()}, siteblock.site.row.content.title or siteblock.site.row.address_short),
  359. h("span.reason", [h("a.title", {href: siteblock.include.site.getHref()}, "Reason"), ": ", siteblock.reason])
  360. ])
  361. ])
  362. getClass: =>
  363. if @searching != null
  364. return "search"
  365. else
  366. return "newsfeed.limit-#{@limit}"
  367. saveFeedVisit: (date_feed_visit) =>
  368. @log "Saving feed visit...", Page.settings.date_feed_visit, "->", date_feed_visit
  369. Page.settings.date_feed_visit = date_feed_visit
  370. Page.saveSettings()
  371. renderSearchHelp: =>
  372. h("div.search-help", [
  373. "Tip: Search in specific site using ",
  374. h("code", "anything site:SiteName")
  375. ])
  376. render: =>
  377. if @need_update
  378. RateLimitCb(5000, @update)
  379. @need_update = false
  380. if @feeds and Page.settings.date_feed_visit < @feeds[0]?.date_added
  381. @saveFeedVisit(@feeds[0].date_added)
  382. if @feeds and Page.site_list.loaded and document.body.className != "loaded" and not @updating
  383. if document.body.scrollTop > 500 # Scrolled down wait until next render
  384. setTimeout (-> document.body.classList.add("loaded")), 2000
  385. else
  386. document.body.classList.add("loaded")
  387. h("div#FeedList.FeedContainer", {classes: {faded: Page.mute_list.visible}},
  388. if Page.mute_list.updated then @renderNotifications()
  389. if @feeds == null or not Page.site_list.loaded
  390. h("div.loading")
  391. else if @feeds.length > 0 or @searching != null
  392. [
  393. h("div.feeds-filters", [
  394. h("a.feeds-filter", {href: "#all", classes: {active: @filter == null}, onclick: @handleFilterClick}, "All"),
  395. for feed_type of @feed_types
  396. h("a.feeds-filter", {key: feed_type, href: "#" + feed_type, classes: {active: @filter == feed_type}, onclick: @handleFilterClick}, feed_type)
  397. ])
  398. h("div.feeds-line"),
  399. h("div.feeds-search", {classes: {"searching": @searching, "searched": @searched, "loading": @loading or @waiting}},
  400. h("div.icon-magnifier"),
  401. if @loading
  402. h("div.loader", {enterAnimation: Animation.show, exitAnimation: Animation.hide}, h("div.arc"))
  403. if @searched and not @loading
  404. h("a.search-clear.nolink", {href: "#clear", onclick: @handleSearchClear, enterAnimation: Animation.show, exitAnimation: Animation.hide}, "\u00D7")
  405. if @res?.stats
  406. h("a.search-info.nolink",
  407. {href: "#ShowStats", enterAnimation: Animation.show, exitAnimation: Animation.hide, onclick: @handleSearchInfoClick},
  408. (if @searching then "#{@res.num} results " else "") + "from #{@res.sites} sites in #{@res.taken.toFixed(2)}s"
  409. )
  410. h("input", {type: "text", placeholder: "Search in connected sites", value: @searching, onkeyup: @handleSearchKeyup, oninput: @handleSearchInput, afterCreate: @storeNodeSearch}),
  411. if @show_stats
  412. h("div.search-info-stats", {enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, [
  413. h("table", [
  414. h("tr", h("th", "Site"), h("th", "Feed"), h("th.taken", "Taken")),
  415. @res.stats.map @renderSearchStat
  416. ])
  417. ])
  418. @renderSearchHelp()
  419. if Page.server_info.rev < 1230 and @searching
  420. h("div.search-noresult", {enterAnimation: Animation.show}, ["You need to ", h("a", {href: "#Update", onclick: Page.head.handleUpdateZeronetClick}, "update"), " your ZeroNet client to use the search feature!"])
  421. else if @feeds.length == 0 and @searched
  422. h("div.search-noresult", {enterAnimation: Animation.show}, "No results for #{@searched}")
  423. ),
  424. h("div.FeedList."+@getClass(), {classes: {loading: @loading or @waiting}}, @feeds[0..@limit].map(@renderFeed))
  425. ]
  426. else
  427. @renderWelcome()
  428. )
  429. onSiteInfo: (site_info) =>
  430. if site_info.event?[0] == "file_done" and site_info.event?[1].endsWith(".json") and not site_info.event?[1].endsWith("content.json")
  431. if not @searching
  432. @need_update = true
  433. window.FeedList = FeedList