├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── app ├── search.js └── views.js ├── assets └── base.html ├── build ├── background.png ├── background@2x.png ├── dmg-icon.icns ├── icon.icns ├── icon.ico ├── icon.svg └── setup-icon.ico ├── lib ├── catch-links.js ├── connection.js ├── get-root.js ├── get-timestamp.js ├── lookup-roots.js ├── observ-full-screen.js ├── pull-group-while.js ├── pull-resume.js ├── scroller.js ├── thread-summary.js ├── unique-roots.js └── update-tree.js ├── main-window.js ├── package-lock.json ├── package.json ├── pages ├── index.js ├── mentions.js ├── private.js ├── profile.js ├── public.js └── thread.js ├── run.js ├── sbot-plugin ├── about.js ├── index.js ├── likes.js ├── profile.js ├── public-feed.js ├── subscriptions.js └── thread.js ├── styles ├── avatar.mcss ├── base.mcss ├── error-message.mcss ├── feed-event.mcss ├── index.js ├── loading.mcss ├── main-window.mcss ├── markdown.mcss ├── message.mcss ├── profile-header.mcss └── scroller.mcss └── views ├── actions ├── index.js ├── like.js └── reply.js ├── feed-item.js ├── feed.js ├── image.js ├── likes.js ├── many.js ├── markdown.js ├── message-header.js ├── message.js ├── person.js └── timestamp.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "cSpell.words": [ 4 | "flumeview", 5 | "hashlru", 6 | "hlru", 7 | "patchtron", 8 | "resumable" 9 | ] 10 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PATCHTRON 3000™ 2 | 3 | Yet another SSB client! Probably gonna be Patchwork 4.0, but for now is missing a lot of things and under pretty serious development 4 | 5 | ![in joke](https://windowsunited.de/wp-content/uploads/2016/10/ThimbleweedPark.png) 6 | 7 | ## Install-a-reno! 8 | 9 | ```bash 10 | git clone https://github.com/mmckegg/patchtron.git 11 | cd patchtron 12 | npm install 13 | 14 | # start! 15 | # this does use scuttle-shell, but make sure you don't have any other clients open (as it uses custom plugins) 16 | npm start 17 | ``` 18 | 19 | ## License 20 | 21 | [GNU Affero General Public License v3.0](/LICENSE) 22 | -------------------------------------------------------------------------------- /app/search.js: -------------------------------------------------------------------------------- 1 | var h = require('mutant/h') 2 | var addSuggest = require('suggest-box') 3 | 4 | module.exports = function Search ({navigate, i18n}) { 5 | // var getProfileSuggestions = api.profile.async.suggest() 6 | // var getChannelSuggestions = api.channel.async.suggest() 7 | var searchBox = h('input.search', { 8 | type: 'search', 9 | placeholder: i18n('word, @key, #channel'), 10 | 'ev-suggestselect': (ev) => { 11 | navigate(ev.detail.id) 12 | searchBox.value = ev.detail.id 13 | }, 14 | 'ev-keydown': (ev) => { 15 | if (ev.key === 'Enter') { 16 | doSearch() 17 | ev.preventDefault() 18 | } 19 | } 20 | }) 21 | 22 | // setImmediate(() => { 23 | // addSuggest(searchBox, (inputText, cb) => { 24 | // if (inputText[0] === '@') { 25 | // cb(null, getProfileSuggestions(inputText.slice(1)), {idOnly: true}) 26 | // } else if (inputText[0] === '#') { 27 | // cb(null, getChannelSuggestions(inputText.slice(1))) 28 | // } else if (inputText[0] === '/') { 29 | // cb(null, getPageSuggestions(inputText)) 30 | // } 31 | // }, {cls: 'SuggestBox'}) 32 | // }) 33 | 34 | return searchBox 35 | 36 | function doSearch () { 37 | var value = searchBox.value.trim() 38 | if (value.startsWith('/') || value.startsWith('?') || value.startsWith('@') || value.startsWith('#') || value.startsWith('%') || value.startsWith('&')) { 39 | if (value.startsWith('@') && value.length < 30) { 40 | // probably not a key 41 | } else if (value.length > 2) { 42 | navigate(value) 43 | } 44 | } else if (value.trim()) { 45 | if (value.length > 2) { 46 | navigate(`?${value.trim()}`) 47 | } 48 | } 49 | } 50 | 51 | function getPageSuggestions (input) { 52 | return pages.sort().filter(p => p.startsWith(input.toLowerCase())).map(p => { 53 | return { 54 | id: p, 55 | value: p, 56 | title: p 57 | } 58 | }) 59 | } 60 | } -------------------------------------------------------------------------------- /app/views.js: -------------------------------------------------------------------------------- 1 | var { h, Value, Dict, dictToCollection, map, computed } = require('mutant') 2 | 3 | module.exports = function Views (renderPage, defaultViews) { 4 | var views = Dict({}) 5 | 6 | var forwardHistory = [] 7 | var backHistory = [] 8 | 9 | if (defaultViews) { 10 | defaultViews.forEach((view) => { 11 | renderPage(view, (err, page) => { 12 | if (err) return console.log(err) 13 | views.put(view, page) 14 | }) 15 | }) 16 | } 17 | 18 | var lastViewed = {} 19 | 20 | // delete cached view after 5 mins of last seeing 21 | setInterval(() => { 22 | views.keys().forEach((view) => { 23 | if (!defaultViews.includes(view)) { 24 | if (lastViewed[view] !== true && Date.now() - lastViewed[view] > (5 * 60e3) && view !== currentView()) { 25 | views.delete(view) 26 | } 27 | } 28 | }) 29 | }, 60e3) 30 | 31 | var canGoForward = Value(false) 32 | var canGoBack = Value(false) 33 | var currentView = Value((defaultViews && defaultViews[0]) || null) 34 | 35 | var viewCollection = dictToCollection(views) 36 | var html = h('div.main', map(viewCollection, (item) => { 37 | return h('div.view', { 38 | attributes: { 39 | 'data-href': item.key 40 | }, 41 | hidden: computed([item.key, currentView], (a, b) => a !== b) 42 | }, [ item.value ]) 43 | })) 44 | 45 | return { 46 | get: views.get, 47 | defaultViews, 48 | canGoForward, 49 | canGoBack, 50 | currentView, 51 | setView, 52 | goBack, 53 | goForward, 54 | html 55 | } 56 | 57 | // scoped 58 | 59 | function goBack () { 60 | if (backHistory.length) { 61 | canGoForward.set(true) 62 | forwardHistory.push(currentView()) 63 | 64 | var view = backHistory.pop() 65 | loadView(view) 66 | 67 | currentView.set(view) 68 | canGoBack.set(backHistory.length > 0) 69 | } 70 | } 71 | 72 | function goForward () { 73 | if (forwardHistory.length) { 74 | backHistory.push(currentView()) 75 | 76 | var view = forwardHistory.pop() 77 | loadView(view) 78 | 79 | currentView.set(view) 80 | canGoForward.set(forwardHistory.length > 0) 81 | canGoBack.set(true) 82 | } 83 | } 84 | 85 | function loadView (view) { 86 | if (!views.has(view)) { 87 | renderPage(view, (err, page) => { 88 | if (err) return console.log(err) 89 | if (page) { 90 | if (page.uniqueKey) { 91 | views.keys().forEach(k => { 92 | if (views.get(k).uniqueKey === page.uniqueKey) { 93 | views.delete(k) 94 | } 95 | }) 96 | } 97 | views.put(view, page) 98 | } 99 | }) 100 | 101 | } 102 | } 103 | 104 | function setView (view, anchor) { 105 | loadView(view) 106 | 107 | if (views.has(view)) { 108 | if (lastViewed[view] !== true) { 109 | lastViewed[view] = Date.now() 110 | } 111 | 112 | if (currentView() && lastViewed[currentView()] !== true) { 113 | lastViewed[currentView()] = Date.now() 114 | } 115 | 116 | var viewElement = views.get(view) 117 | 118 | if (viewElement && typeof viewElement.setAnchor === 'function') { 119 | viewElement.setAnchor(anchor) 120 | } 121 | 122 | if (view !== currentView()) { 123 | canGoForward.set(false) 124 | canGoBack.set(true) 125 | forwardHistory.length = 0 126 | backHistory.push(currentView()) 127 | currentView.set(view) 128 | } 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /assets/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /build/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmckegg/patchtron/9629ec1293272148772ff8f47d3251c32ee7d340/build/background.png -------------------------------------------------------------------------------- /build/background@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmckegg/patchtron/9629ec1293272148772ff8f47d3251c32ee7d340/build/background@2x.png -------------------------------------------------------------------------------- /build/dmg-icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmckegg/patchtron/9629ec1293272148772ff8f47d3251c32ee7d340/build/dmg-icon.icns -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmckegg/patchtron/9629ec1293272148772ff8f47d3251c32ee7d340/build/icon.icns -------------------------------------------------------------------------------- /build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmckegg/patchtron/9629ec1293272148772ff8f47d3251c32ee7d340/build/icon.ico -------------------------------------------------------------------------------- /build/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 24 | 46 | 50 | 54 | 57 | 58 | 60 | 63 | 79 | 80 | 83 | 87 | 91 | 92 | 95 | 99 | 103 | 104 | 107 | 111 | 115 | 116 | 119 | 123 | 127 | 128 | 131 | 135 | 139 | 140 | 143 | 147 | 151 | 152 | 155 | 171 | 172 | 182 | 192 | 202 | 211 | 220 | 229 | 232 | 236 | 237 | 240 | 244 | 245 | 248 | 252 | 253 | 256 | 260 | 261 | 264 | 268 | 269 | 272 | 276 | 277 | 286 | 295 | 304 | 313 | 322 | 331 | 340 | 349 | 358 | 367 | 376 | 385 | 394 | 403 | 412 | 421 | 430 | 439 | 448 | 457 | 466 | 475 | 484 | 493 | 502 | 511 | 520 | 529 | 538 | 547 | 556 | 565 | 574 | 583 | 592 | 601 | 610 | 619 | 628 | 637 | 646 | 655 | 664 | 673 | 682 | 691 | 700 | 709 | 718 | 727 | 736 | 745 | 754 | 763 | 772 | 781 | 790 | 799 | 808 | 811 | 817 | 818 | 822 | 827 | 833 | 838 | 843 | 849 | 850 | 854 | 859 | 865 | 870 | 875 | 881 | 882 | 886 | 891 | 897 | 902 | 907 | 913 | 914 | 915 | 917 | 918 | 920 | image/svg+xml 921 | 923 | 924 | 925 | 926 | 927 | 933 | 936 | 941 | 942 | 946 | 950 | 955 | 961 | 967 | 973 | 979 | 985 | 991 | 997 | 1003 | 1009 | 1015 | 1021 | 1022 | 1027 | 1030 | 1036 | 1042 | 1043 | 1047 | 1052 | 1057 | 1062 | 1068 | 1074 | 1079 | 1084 | 1090 | 1095 | 1100 | 1105 | 1110 | 1115 | 1120 | 1125 | 1131 | 1136 | 1141 | 1146 | 1151 | 1156 | 1161 | 1166 | 1171 | 1176 | 1181 | 1186 | 1191 | 1196 | 1197 | 1200 | 1206 | 1211 | 1216 | 1221 | 1226 | 1231 | 1236 | 1241 | 1246 | 1251 | 1256 | 1261 | 1266 | 1271 | 1272 | 1275 | 1281 | 1286 | 1291 | 1296 | 1297 | 1301 | 1307 | 1313 | 1318 | 1323 | 1329 | 1330 | 1331 | 1337 | 1344 | 1351 | 1358 | 1362 | 1368 | 1369 | 1370 | 1371 | 1444 | 1455 | 1456 | -------------------------------------------------------------------------------- /build/setup-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmckegg/patchtron/9629ec1293272148772ff8f47d3251c32ee7d340/build/setup-icon.ico -------------------------------------------------------------------------------- /lib/catch-links.js: -------------------------------------------------------------------------------- 1 | var Url = require('url') 2 | 3 | module.exports = function (root, cb) { 4 | root.addEventListener('click', (ev) => { 5 | if (ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.defaultPrevented) { 6 | return true 7 | } 8 | 9 | var anchor = null 10 | for (var n = ev.target; n.parentNode; n = n.parentNode) { 11 | if (n.nodeName === 'A') { 12 | anchor = n 13 | break 14 | } 15 | } 16 | if (!anchor) return true 17 | 18 | var href = anchor.getAttribute('href') 19 | 20 | if (href) { 21 | var url = Url.parse(href) 22 | if (url.host) { 23 | cb(href, true) 24 | } else if (href !== '#') { 25 | cb(href, false, anchor.anchor) 26 | } 27 | } 28 | 29 | ev.preventDefault() 30 | ev.stopPropagation() 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /lib/connection.js: -------------------------------------------------------------------------------- 1 | const { Value, onceTrue } = require('mutant') 2 | const loadConfig = require('ssb-config/inject') 3 | const createClient = require('ssb-client') 4 | const ssbKeys = require('ssb-keys') 5 | const Path = require('path') 6 | const extend = require('xtend') 7 | const minimist = require('minimist') 8 | const ref = require('ssb-ref') 9 | const emojis = require('emoji-named-characters') 10 | const emojiNames = Object.keys(emojis) 11 | const pullDefer = require('pull-defer') 12 | const Event = require('geval/event') 13 | const pullResume = require('./pull-resume') 14 | 15 | module.exports = function (appName, opts) { 16 | let result = Value() 17 | const config = loadConfig(appName, minimist(process.argv)) 18 | console.log(process.argv) 19 | const keyPath = Path.join(config.path, 'secret') 20 | const keys = ssbKeys.loadOrCreateSync(keyPath) 21 | 22 | var publishEvent = Event() 23 | 24 | console.log('CONFIG:', config) 25 | 26 | opts = extend({ 27 | path: config.path, 28 | remote: config.remote, 29 | host: config.host, 30 | port: config.port, 31 | key: config.key, 32 | appKey: config.caps.shs, 33 | timers: config.timers, 34 | caps: config.caps, 35 | friends: config.friends 36 | }, opts) 37 | 38 | result.publish = function (content, cb) { 39 | result.obtain(sbot => { 40 | sbot.publish(content, (err, result) => { 41 | if (err) return cb && cb(err) 42 | cb && cb(err, result) 43 | publishEvent.broadcast(result) 44 | }) 45 | }) 46 | } 47 | 48 | result.like = function (dest, like = true, cb) { 49 | let vote = like 50 | ? { link: dest, value: 1, expression: 'Like' } 51 | : { link: dest, value: 0, expression: 'Unlike' } 52 | 53 | result.publish({ type: 'vote', vote }, cb) 54 | } 55 | 56 | result.about = function (dest, key, cb) { 57 | result.obtain(sbot => { 58 | sbot['patchtron'].about.socialValue({dest, key}, cb) 59 | }) 60 | } 61 | 62 | result.get = function (id, cb) { 63 | if (typeof key === 'string') { 64 | id = {id, raw: true} 65 | } 66 | result.obtain(sbot => { 67 | sbot.get(id, cb) 68 | }) 69 | } 70 | 71 | result.pull = function (fn) { 72 | return function (opts) { 73 | var stream = pullDefer.source() 74 | result.obtain(sbot => { 75 | stream.resolve(fn(sbot, opts)) 76 | }) 77 | return stream 78 | } 79 | } 80 | 81 | result.pullResume = function (fn, baseOpts) { 82 | var Stream = result.pull(fn) 83 | return function (opts) { 84 | return pullResume.remote((opts) => { 85 | return Stream(opts) 86 | }, extend(baseOpts, opts)) 87 | } 88 | } 89 | 90 | result.obtain = function (cb) { 91 | if (result()) { 92 | cb(result()) 93 | } else { 94 | onceTrue(result, cb) 95 | } 96 | } 97 | 98 | result.blobUrl = function (link) { 99 | var prefix = config.blobsPrefix != null ? config.blobsPrefix : `http://localhost:${config.ws.port}/blobs/get` 100 | if (link && typeof link.link === 'string') { 101 | link = link.link 102 | } 103 | 104 | var parsed = ref.parseLink(link) 105 | if (parsed && ref.isBlob(parsed.link)) { 106 | return `${prefix}/${parsed.link}` 107 | } 108 | } 109 | 110 | result.onPublish = publishEvent.listen 111 | 112 | result.emojiUrl = function (emoji) { 113 | return emoji in emojiNames && result.blobUrl(emoji).replace(/\/blobs\/get/, '/img/emoji') + '.png' 114 | } 115 | 116 | result.id = keys.id 117 | 118 | createClient(keys, opts, function (err, sbot) { 119 | if (err) throw err 120 | result.set(sbot) 121 | }) 122 | return result 123 | } 124 | -------------------------------------------------------------------------------- /lib/get-root.js: -------------------------------------------------------------------------------- 1 | var ref = require('ssb-ref') 2 | 3 | module.exports = function getRoot (msg) { 4 | if (msg && msg.value && msg.value.content) { 5 | var type = msg.value.content.type 6 | var root = msg.value.content.root 7 | 8 | if (type === 'vote') { 9 | root = msg.value.content.vote && msg.value.content.vote.link 10 | } else if (type === 'about') { 11 | root = msg.value.content.about 12 | } 13 | 14 | // only abouts and likes for messages (not feeds) will be returned 15 | if (ref.isMsg(root)) return root 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/get-timestamp.js: -------------------------------------------------------------------------------- 1 | module.exports = function getTimestamp (msg) { 2 | if (!msg || !msg.value || !msg.value.timestamp) return 3 | if (msg.timestamp) { 4 | return Math.min(msg.timestamp, msg.value.timestamp) 5 | } else { 6 | return msg.value.timestamp 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/lookup-roots.js: -------------------------------------------------------------------------------- 1 | const HLRU = require('hashlru') 2 | const pull = require('pull-stream') 3 | const getRoot = require('./get-root') 4 | const extend = require('xtend') 5 | 6 | module.exports = function LookupRoots ({ssb, cache}) { 7 | cache = cache || HLRU(100) 8 | return pull.asyncMap((msg, cb) => { 9 | getRootMsg(msg, {ssb, cache}, (_, root) => { 10 | if (root && root.key !== msg.key) { 11 | cb(null, extend(msg, { 12 | rootId: msg.key, root 13 | })) 14 | } else { 15 | cb(null, extend(msg, {rootId: msg.key})) 16 | } 17 | }) 18 | }) 19 | } 20 | 21 | function getRootMsg (msg, {ssb, cache, visited}, cb) { 22 | visited = visited || new Set() 23 | visited.add(msg.key) 24 | 25 | let rootId = getRoot(msg) 26 | if (!rootId) { 27 | // we found the root! 28 | return cb(null, msg) 29 | } else { 30 | getThruCache(rootId, {ssb, cache}, (_, root) => { 31 | if (visited.has(root.key)) { 32 | // recursion detected, abort! 33 | return cb(null, msg) 34 | } else { 35 | // go deeper 36 | getRootMsg(root, {ssb, cache, visited}, cb) 37 | } 38 | }) 39 | } 40 | } 41 | 42 | function getThruCache (key, {ssb, cache}, cb) { 43 | if (cache.has(key)) { 44 | cb(null, cache.get(key)) 45 | } else { 46 | // don't do an ooo lookup 47 | ssb.get({id: key, raw: true}, (_, value) => { 48 | var msg = {key, value} 49 | if (msg.value) { 50 | cache.set(key, msg) 51 | } 52 | cb(null, msg) 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/observ-full-screen.js: -------------------------------------------------------------------------------- 1 | var electron = require('electron') 2 | var {Value} = require('mutant') 3 | 4 | module.exports = function () { 5 | var win = electron.remote.getCurrentWindow() 6 | var isFullScreen = Value(win.isFullScreen()) 7 | win.on('enter-full-screen', function () { 8 | isFullScreen.set(true) 9 | }) 10 | win.on('leave-full-screen', function () { 11 | isFullScreen.set(false) 12 | }) 13 | return isFullScreen 14 | } -------------------------------------------------------------------------------- /lib/pull-group-while.js: -------------------------------------------------------------------------------- 1 | module.exports = function PullGroupWhile (check) { 2 | var ended = false 3 | var queue = [] 4 | return function (read) { 5 | return function (end, cb) { 6 | // this means that the upstream is sending an error. 7 | if (end) { 8 | ended = end 9 | return read(ended, cb) 10 | } 11 | // this means that we read an end before. 12 | if (ended) return cb(ended) 13 | 14 | read(null, function next (end, data) { 15 | ended = ended || end 16 | 17 | if (ended) { 18 | if (!queue.length) { 19 | return cb(ended) 20 | } 21 | 22 | let _queue = queue 23 | queue = [] 24 | return cb(null, _queue) 25 | } 26 | 27 | if (check(queue, data)) { 28 | queue.push(data) 29 | read(null, next) 30 | } else { 31 | let _queue = queue 32 | queue = [data] 33 | cb(null, _queue) 34 | } 35 | }) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/pull-resume.js: -------------------------------------------------------------------------------- 1 | const pull = require('pull-stream') 2 | const pullCat = require('pull-cat') 3 | const Next = require('pull-next') 4 | const extend = require('xtend') 5 | 6 | module.exports = { 7 | source: function (stream, {getResume, limit, filterMap}) { 8 | if (limit) { 9 | let marker = {marker: true} 10 | let count = 0 11 | return pullCat([ 12 | pull( 13 | stream, 14 | pull.through(msg => { 15 | if (!msg.sync) { 16 | marker.resume = getResume(msg) 17 | } 18 | }), 19 | filterMap, 20 | pull.take(limit), 21 | pull.through(() => { 22 | count += 1 23 | }) 24 | ), 25 | 26 | pull( 27 | // send truncated marker for resuming search 28 | pull.values([marker]), 29 | 30 | // don't emit the resume if we're at the end of the stream 31 | pull.filter(() => count === limit) 32 | ) 33 | ]) 34 | } else { 35 | return pull( 36 | stream, 37 | filterMap 38 | ) 39 | } 40 | }, 41 | remote: function (getStream, opts) { 42 | var started = false 43 | var lastMessage = null 44 | 45 | return Next(function () { 46 | if (started && (!lastMessage || lastMessage.resume == null)) return 47 | started = true 48 | 49 | let subOpts = extend(opts, { 50 | resume: (lastMessage && lastMessage.resume) || undefined 51 | }) 52 | 53 | lastMessage = null 54 | 55 | return pull( 56 | getStream(subOpts), 57 | pull.through(function (msg) { 58 | lastMessage = msg 59 | }) 60 | ) 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/scroller.js: -------------------------------------------------------------------------------- 1 | var pull = require('pull-stream') 2 | var Pause = require('pull-pause') 3 | var Value = require('mutant/value') 4 | var onceIdle = require('mutant/once-idle') 5 | var computed = require('mutant/computed') 6 | 7 | module.exports = Scroller 8 | 9 | function Scroller (scroller, content, render, opts) { 10 | if (typeof opts === 'function') { 11 | opts = {onDone: opts} 12 | } else if (!opts) { 13 | opts = {} 14 | } 15 | var toRenderCount = Value(0) 16 | var toAppendCount = Value(0) 17 | var pendingVisible = new Set() 18 | 19 | var queueLength = computed([toRenderCount, toAppendCount], (a, b) => a + b) 20 | 21 | var pause = Pause(function () {}) 22 | var running = true 23 | var appendQueue = [] 24 | 25 | function appendLoop () { 26 | var distanceFromBottom = scroller.scrollHeight - (scroller.scrollTop + scroller.clientHeight) 27 | if (distanceFromBottom < scroller.clientHeight) { 28 | while (appendQueue.length) { 29 | var element = appendQueue.shift() 30 | content.appendChild(element) 31 | pendingVisible.add(element) 32 | } 33 | } 34 | 35 | toAppendCount.set(appendQueue.length) 36 | if (queueLength() < 5) { 37 | // queue running low, resume stream 38 | pause.resume() 39 | } 40 | 41 | if (running || queueLength()) { 42 | window.requestAnimationFrame(appendLoop) 43 | } 44 | } 45 | 46 | var stream = pull( 47 | pause, 48 | pull.drain(function (msg) { 49 | toRenderCount.set(toRenderCount() + 1) 50 | 51 | onceIdle(() => { 52 | try { 53 | var element = render(msg) 54 | appendQueue.push(element) 55 | toRenderCount.set(toRenderCount() - 1) 56 | } catch (ex) { 57 | setImmediate(() => { 58 | throw ex 59 | }) 60 | opts.onDone(ex) 61 | } 62 | }) 63 | 64 | if (queueLength() > 5) { 65 | pause.pause() 66 | } 67 | }, function (err) { 68 | running = false 69 | clearInterval(visibleInterval) 70 | opts.onDone ? opts.onDone(err) : console.error(err) 71 | }) 72 | ) 73 | 74 | var visibleInterval = setInterval(() => { 75 | // check for visible items every 2 seconds 76 | Array.from(pendingVisible).forEach(checkVisible) 77 | }, 2000) 78 | 79 | stream.queue = queueLength 80 | 81 | appendLoop() 82 | return stream 83 | 84 | function checkVisible (element) { 85 | var height = scroller.clientHeight 86 | var rect = element.getBoundingClientRect() 87 | if (height > 50 && rect.bottom < height) { 88 | pendingVisible.delete(element) 89 | if (opts.onItemVisible) { 90 | onceIdle(() => opts.onItemVisible(element)) 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /lib/thread-summary.js: -------------------------------------------------------------------------------- 1 | const pull = require('pull-stream') 2 | const extend = require('extend') 3 | 4 | module.exports = function (dest, {recentLimit = 3, bumpFilter, recentFilter, readThread}, cb) { 5 | var bumps = [] 6 | var totalReplies = 0 7 | var latestReplies = [] 8 | return pull( 9 | readThread({reverse: true, live: false, dest}), 10 | pull.drain(msg => { 11 | try { 12 | // bump filter can return values other than true that will be passed to view 13 | if (msg && msg.value && msg.value.content) { 14 | let type = msg.value.content.type 15 | 16 | let bump = !bumpFilter || bumpFilter(msg) 17 | if (bump) { 18 | if (latestReplies.length < recentLimit && (!recentFilter || recentFilter(msg))) { 19 | // collect the most recent bump messages 20 | latestReplies.unshift(msg) 21 | } 22 | 23 | // summarize all bumps, extend with result of bumpFilter 24 | bumps.push(extend({ 25 | id: msg.key, author: msg.value.author 26 | }, bump instanceof Object ? bump : {})) 27 | } 28 | 29 | if (type !== 'vote') { 30 | totalReplies += 1 31 | } 32 | } 33 | } catch (ex) { 34 | cb(ex) 35 | } 36 | }, (err) => { 37 | if (err) return cb(err) 38 | cb(null, { 39 | bumps, 40 | totalReplies, 41 | latestReplies 42 | }) 43 | }) 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /lib/unique-roots.js: -------------------------------------------------------------------------------- 1 | const pull = require('pull-stream') 2 | 3 | module.exports = function UniqueRoots () { 4 | var included = new Set() 5 | return pull.filter(msg => { 6 | if (!included.has(msg.rootId)) { 7 | included.add(msg.key) 8 | return true 9 | } 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /lib/update-tree.js: -------------------------------------------------------------------------------- 1 | var walk = require('mutant/lib/walk') 2 | 3 | module.exports = function (root, opts) { 4 | walk(root, (element) => { 5 | if (element && typeof element.update === 'function') { 6 | element.update(opts) 7 | } 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /main-window.js: -------------------------------------------------------------------------------- 1 | const connectToSbot = require('./lib/connection') 2 | const { h, when, computed } = require('mutant') 3 | const updateTree = require('./lib/update-tree') 4 | const ObservFullScreen = require('./lib/observ-full-screen') 5 | const catchLinks = require('./lib/catch-links') 6 | const Views = require('./app/views') 7 | const Search = require('./app/search') 8 | const renderPage = require('./pages') 9 | 10 | const connection = connectToSbot(process.env['ssb_appname'] || 'ssb') 11 | 12 | connection.onPublish(msg => { 13 | let content = msg.value.content 14 | let type = content.type 15 | if (type === 'vote' && content.vote && content.vote.link) { 16 | invalidate(msg.value.content.vote.link) 17 | } 18 | }) 19 | 20 | // add stylesheets 21 | document.head.appendChild(h('style', { 22 | innerHTML: require('./styles') 23 | })) 24 | 25 | catchLinks(document, (href, external) => { 26 | if (!external) { 27 | navigate(href) 28 | } 29 | }) 30 | 31 | var views = Views((href, cb) => { 32 | renderPage(href, {i18n, connection, navigate}, cb) 33 | }, [ 34 | '/public', '/private', connection.id, '/mentions' 35 | ]) 36 | 37 | var mainView = h(`MainWindow -${process.platform}`, { 38 | classList: [ when(ObservFullScreen(), '-fullscreen') ] 39 | }, [ 40 | h('div.top', [ 41 | h('span.history', [ 42 | h('a', { 43 | 'ev-click': views.goBack, 44 | classList: [ when(views.canGoBack, '-active') ] 45 | }), 46 | h('a', { 47 | 'ev-click': views.goForward, 48 | classList: [ when(views.canGoForward, '-active') ] 49 | }) 50 | ]), 51 | h('span.nav', [ 52 | tab(i18n('Public'), '/public'), 53 | tab(i18n('Private'), '/private'), 54 | // dropTab(i18n('More'), [ 55 | // getSubscribedChannelMenu, 56 | // [i18n('Gatherings'), '/gatherings'], 57 | // [i18n('Extended Network'), '/all'], 58 | // {separator: true}, 59 | // [i18n('Settings'), '/settings'] 60 | // ]) 61 | ]), 62 | h('span.appTitle', [ 63 | h('span.title', i18n('PATCHTRON 3000™️')), 64 | // api.app.html.progressNotifier() 65 | ]), 66 | h('span', [ 67 | Search({navigate, i18n}) 68 | ]), 69 | h('span.nav', [ 70 | tab(i18n('Profile'), connection.id), 71 | tab(i18n('Mentions'), '/mentions') 72 | ]) 73 | ]), 74 | views.html 75 | ]) 76 | 77 | document.body.appendChild(mainView) 78 | 79 | function tab (name, view) { 80 | var instance = views.get(view) 81 | return h('a', { 82 | 'ev-click': function (ev) { 83 | if (instance) { 84 | var isSelected = views.currentView() === view 85 | var needsRefresh = instance.pendingUpdates && instance.pendingUpdates() 86 | 87 | // refresh if tab is clicked when there are pending items or the page is already selected 88 | if ((needsRefresh || isSelected) && instance.reload) { 89 | instance.reload() 90 | } 91 | } 92 | }, 93 | href: view, 94 | classList: [ 95 | when(selected(view), '-selected') 96 | ] 97 | }, [ 98 | name, 99 | when(instance && instance.pendingUpdates, [ 100 | ' (', instance && instance.pendingUpdates, ')' 101 | ]) 102 | ]) 103 | } 104 | 105 | function selected (view) { 106 | return computed([views.currentView, view], (currentView, view) => { 107 | return currentView === view 108 | }) 109 | } 110 | 111 | function i18n (text, ...args) { 112 | return text.replace(/%s/, (match, offset) => { 113 | return args[offset] 114 | }) 115 | } 116 | 117 | function invalidate (id) { 118 | document.querySelectorAll(`[data-id="${id}"]`).forEach(element => { 119 | updateTree(element, {connection, i18n}) 120 | }) 121 | } 122 | 123 | function navigate (href, anchor) { 124 | views.setView(href, anchor) 125 | } 126 | 127 | i18n.plural = i18n 128 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Patchtron", 3 | "version": "4.0.0-pre1", 4 | "description": "", 5 | "main": "run.js", 6 | "scripts": { 7 | "postinstall": "npm run rebuild", 8 | "rebuild": "electron-builder install-app-deps", 9 | "test": "echo \"Error: no test specified\" && exit 1", 10 | "start": "electron run.js --friends.hops 2", 11 | "pack": "build --dir", 12 | "pack:all": "build --dir -mlw", 13 | "pack:mac": "build --dir -m", 14 | "pack:linux": "build --dir -l", 15 | "pack:windows": "build --dir -w", 16 | "release": "build", 17 | "release:all": "build -mlw", 18 | "release:mac": "build -m", 19 | "release:linux": "build -l", 20 | "release:windows": "build -w", 21 | "publish": "build -mlw --publish=always" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/mmckegg/patchtron.git" 26 | }, 27 | "author": "", 28 | "license": "AGPL-3.0-or-later", 29 | "bugs": { 30 | "url": "https://github.com/mmckegg/patchtron/issues" 31 | }, 32 | "homepage": "https://github.com/mmckegg/patchtron#readme", 33 | "dependencies": { 34 | "color-hash": "^1.0.3", 35 | "electron-default-menu": "^1.0.1", 36 | "electron-window-state": "^4.1.1", 37 | "emoji-named-characters": "^1.0.2", 38 | "geval": "^2.2.0", 39 | "hashlru": "^2.2.1", 40 | "html-escape": "^2.0.0", 41 | "human-time": "0.0.1", 42 | "micro-css": "^2.0.1", 43 | "mutant": "^3.22.1", 44 | "pull-defer": "^0.2.3", 45 | "pull-next": "^1.0.1", 46 | "pull-pause": "0.0.2", 47 | "scuttle-shell": "github:ssbc/scuttle-shell#enhancement-custom-plugins", 48 | "ssb-client": "^4.6.0", 49 | "ssb-config": "^2.2.1", 50 | "ssb-markdown": "^3.6.0", 51 | "ssb-ref": "^2.11.2", 52 | "suggest-box": "^2.2.3", 53 | "xtend": "^4.0.1" 54 | }, 55 | "devDependencies": { 56 | "electron": "^2.0.8", 57 | "electron-builder": "^20.28.4" 58 | }, 59 | "build": { 60 | "appId": "org.ssbc.patchtron", 61 | "asarUnpack": [ 62 | "node_modules/sodium-native/prebuilds" 63 | ], 64 | "npmSkipBuildFromSource": true, 65 | "npmArgs": [ 66 | "--abi=57" 67 | ], 68 | "files": [ 69 | "**/*", 70 | "!node_modules/**/deps${/*}", 71 | "!node_modules/**/_node_modules${/*}", 72 | "!node_modules/sodium-native/prebuilds${/*}", 73 | "node_modules/sodium-native/prebuilds/${os}-${arch}/electron-57.node", 74 | "node_modules/sodium-native/prebuilds/${os}-${arch}/libsodium.*" 75 | ], 76 | "mac": { 77 | "icon": "build/icon.icns", 78 | "target": "dmg", 79 | "publish": [ 80 | "github" 81 | ], 82 | "files": [ 83 | "node_modules/sodium-native/prebuilds/darwin-${arch}/electron-57.node", 84 | "node_modules/sodium-native/prebuilds/darwin-${arch}/libsodium.*" 85 | ] 86 | }, 87 | "linux": { 88 | "category": "Network" 89 | }, 90 | "win": { 91 | "icon": "build/icon.ico", 92 | "publisherName": "Secure Scuttlebutt Consortium", 93 | "files": [ 94 | "node_modules/sodium-native/prebuilds/win32-${arch}/electron-57.node", 95 | "node_modules/sodium-native/prebuilds/win32-${arch}/libsodium.*" 96 | ] 97 | }, 98 | "dmg": { 99 | "icon": "build/dmg-icon.icns", 100 | "artifactName": "PATCHTRON-${version}-mac.${ext}" 101 | }, 102 | "appImage": { 103 | "artifactName": "PATCHTRON-${version}-linux-${arch}.${ext}" 104 | }, 105 | "nsis": { 106 | "installerIcon": "build/setup-icon.ico", 107 | "artifactName": "PATCHTRON-${version}-windows.${ext}" 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | var mainPages = { 2 | '/public': require('./public'), 3 | '/private': require('./private'), 4 | '/mentions': require('./mentions') 5 | } 6 | 7 | var ThreadPage = require('./thread') 8 | var ProfilePage = require('./profile') 9 | 10 | module.exports = function renderPage (href, opts, cb) { 11 | console.log('rendering', href) 12 | if (mainPages[href]) { 13 | return cb(null, mainPages[href](href, opts)) 14 | } else if (href.startsWith('%')) { 15 | return cb(null, ThreadPage(href, opts)) 16 | } else if (href.startsWith('@')) { 17 | return cb(null, ProfilePage(href, opts)) 18 | } 19 | cb(null, false) 20 | } 21 | -------------------------------------------------------------------------------- /pages/mentions.js: -------------------------------------------------------------------------------- 1 | const {h} = require('mutant') 2 | 3 | module.exports = function () { 4 | return h('h1', 'MENTIONS!') 5 | } -------------------------------------------------------------------------------- /pages/private.js: -------------------------------------------------------------------------------- 1 | const {h} = require('mutant') 2 | 3 | module.exports = function () { 4 | return h('h1', 'PRIVATE!') 5 | } -------------------------------------------------------------------------------- /pages/profile.js: -------------------------------------------------------------------------------- 1 | const renderFeed = require('../views/feed') 2 | const extend = require('xtend') 3 | const {h} = require('mutant') 4 | const image = require('../views/image') 5 | const person = require('../views/person') 6 | const renderItem = require('../views/feed-item') 7 | 8 | module.exports = function ProfilePage (id, {i18n, connection, navigate}) { 9 | var prepend = h('header', {className: 'ProfileHeader'}, [ 10 | h('div.image', image(id, {connection, i18n})), 11 | h('div.main', [ 12 | h('div.title', [ 13 | h('h1', [person(id, {connection, i18n})]), 14 | h('div.meta', [ 15 | 16 | ]) 17 | ]), 18 | h('section -publicKey', [ 19 | h('pre', {title: i18n('Public key for this profile')}, id) 20 | ]) 21 | ]) 22 | ]) 23 | 24 | let getStream = connection.pullResume((sbot, opts) => { 25 | return sbot.patchtron.profile.roots(extend(opts, { 26 | id, filterReplyAuthors: [id] 27 | })) 28 | }, {limit: 20, reverse: true}) 29 | 30 | return renderFeed({connection, i18n, prepend, renderItem, getStream}) 31 | } 32 | -------------------------------------------------------------------------------- /pages/public.js: -------------------------------------------------------------------------------- 1 | const renderFeed = require('../views/feed') 2 | const renderItem = require('../views/feed-item') 3 | 4 | module.exports = function (href, {i18n, connection, navigate}) { 5 | let getStream = connection.pullResume((sbot, opts) => { 6 | return sbot.patchtron.publicFeed.roots(opts) 7 | }, {limit: 20, reverse: true}) 8 | 9 | return renderFeed({connection, i18n, renderItem, getStream}) 10 | } 11 | -------------------------------------------------------------------------------- /pages/thread.js: -------------------------------------------------------------------------------- 1 | const { h, Value, when, Proxy, computed } = require('mutant') 2 | const Scroller = require('../lib/scroller') 3 | const renderMessage = require('../views/message') 4 | const pull = require('pull-stream') 5 | 6 | module.exports = function renderThread (id, {connection, i18n}) { 7 | var done = Value(false) 8 | var loading = Proxy(false) 9 | var content = h('section.content') 10 | var container = h('Scroller', { 11 | style: { overflow: 'auto' } 12 | }, [ 13 | h('div.wrapper', [ 14 | content, 15 | when(loading, h('Loading -large')) 16 | ]) 17 | ]) 18 | 19 | connection.get(id, (err, value) => { 20 | if (err) return 21 | content.prepend(renderMessage({key: id, value}, {connection, i18n})) 22 | }) 23 | 24 | var scroller = Scroller(container, content, (msg) => { 25 | return renderMessage(msg, {connection, i18n}) 26 | }, { 27 | onDone: () => done.set(true), 28 | onItemVisible: (item) => {} 29 | }) 30 | 31 | loading.set(computed([done, scroller.queue], (done, queue) => { 32 | return !done && queue < 5 33 | })) 34 | 35 | var pullThread = connection.pull((sbot, opts) => { 36 | return sbot.patchtron.thread.read(opts) 37 | }) 38 | 39 | pull( 40 | pullThread({dest: id, types: ['post', 'about']}), 41 | scroller 42 | ) 43 | 44 | container.done = done 45 | 46 | return container 47 | } 48 | -------------------------------------------------------------------------------- /run.js: -------------------------------------------------------------------------------- 1 | const defaultMenu = require('electron-default-menu') 2 | const WindowState = require('electron-window-state') 3 | const electron = require('electron') 4 | const Menu = electron.Menu 5 | const Path = require('path') 6 | 7 | // keep references to open app windows 8 | let windows = {} 9 | 10 | // for macOS: track whether the app is quitting or just hiding main window 11 | let quitting = false 12 | 13 | console.log('starting scuttleshell') 14 | 15 | // spawn scuttleshell and load custom plugins 16 | 17 | electron.app.on('ready', () => { 18 | windows.background = startServer() 19 | 20 | var menu = defaultMenu(electron.app, electron.shell) 21 | var view = menu.find(x => x.label === 'View') 22 | view.submenu = [ 23 | { role: 'reload' }, 24 | { role: 'toggledevtools' }, 25 | { label: 'Background Process Inspector', 26 | click () { 27 | if (windows.background) { 28 | windows.background.webContents.openDevTools({detach: true}) 29 | } 30 | } 31 | }, 32 | { type: 'separator' }, 33 | { role: 'resetzoom' }, 34 | { role: 'zoomin' }, 35 | { role: 'zoomout' }, 36 | { type: 'separator' }, 37 | { role: 'togglefullscreen' } 38 | ] 39 | var win = menu.find(x => x.label === 'Window') 40 | win.submenu = [ 41 | { role: 'minimize' }, 42 | { role: 'zoom' }, 43 | { role: 'close', label: 'Close Window', accelerator: 'CmdOrCtrl+Shift+W' }, 44 | { type: 'separator' }, 45 | { 46 | label: 'Close Tab', 47 | accelerator: 'CmdOrCtrl+W', 48 | click () { 49 | windows.main.webContents.send('closeTab') 50 | } 51 | }, 52 | { 53 | label: 'Select Next Tab', 54 | accelerator: 'CmdOrCtrl+Shift+]', 55 | click () { 56 | windows.main.webContents.send('nextTab') 57 | } 58 | }, 59 | { 60 | label: 'Select Previous Tab', 61 | accelerator: 'CmdOrCtrl+Shift+[', 62 | click () { 63 | windows.main.webContents.send('previousTab') 64 | } 65 | }, 66 | { type: 'separator' }, 67 | { role: 'front' } 68 | ] 69 | 70 | Menu.setApplicationMenu(Menu.buildFromTemplate(menu)) 71 | 72 | electron.app.on('activate', function (e) { 73 | if (windows.main) { 74 | windows.main.show() 75 | } 76 | }) 77 | 78 | electron.app.on('before-quit', function () { 79 | quitting = true 80 | }) 81 | 82 | electron.ipcMain.once('server-started', function (ev) { 83 | openMainWindow() 84 | }) 85 | 86 | // electron.session.defaultSession.webRequest.onBeforeRequest(['ssb://*'], (details, done) => { 87 | // details.url = 'file://' + Path.join(__dirname, 'assets', 'base.html') 88 | // done({cancel: false, url: details.url}) 89 | // }) 90 | }) 91 | 92 | function openMainWindow () { 93 | if (!windows.main) { 94 | var windowState = WindowState({ 95 | defaultWidth: 1024, 96 | defaultHeight: 768 97 | }) 98 | windows.main = openWindow(Path.join(__dirname, 'main-window.js'), { 99 | minWidth: 800, 100 | x: windowState.x, 101 | y: windowState.y, 102 | width: windowState.width, 103 | height: windowState.height, 104 | titleBarStyle: 'hiddenInset', 105 | autoHideMenuBar: true, 106 | title: 'PatchTron 3000', 107 | show: true, 108 | backgroundColor: '#EEE', 109 | icon: Path.join(__dirname, 'assets/icon.png') 110 | }) 111 | windowState.manage(windows.main) 112 | windows.main.setSheetOffset(40) 113 | windows.main.on('close', function (e) { 114 | if (!quitting && process.platform === 'darwin') { 115 | e.preventDefault() 116 | windows.main.hide() 117 | } 118 | }) 119 | windows.main.on('closed', function () { 120 | windows.main = null 121 | if (process.platform !== 'darwin') electron.app.quit() 122 | }) 123 | } 124 | } 125 | 126 | function startServer () { 127 | var customConfig = { 128 | plugins: [ 129 | Path.join(__dirname, 'sbot-plugin') 130 | ] // , 131 | // friends: { 132 | // hops: 2 133 | // } 134 | } 135 | 136 | var window = new electron.BrowserWindow({ 137 | connect: false, 138 | center: true, 139 | fullscreen: false, 140 | fullscreenable: false, 141 | height: 150, 142 | maximizable: false, 143 | minimizable: false, 144 | resizable: false, 145 | show: false, 146 | skipTaskbar: true, 147 | title: 'patchtron-server', 148 | useContentSize: true, 149 | width: 150 150 | }) 151 | 152 | window.webContents.on('dom-ready', function () { 153 | window.webContents.executeJavaScript(` 154 | // copy argv from main process 155 | process.argv = ${JSON.stringify(process.argv)} 156 | 157 | //const electron = require('electron') 158 | const scuttleShell = require('scuttle-shell') 159 | 160 | // spawn scuttle-shell 161 | scuttleShell.start(${JSON.stringify(customConfig)}) 162 | electron.ipcRenderer.send('server-started') 163 | `) 164 | }) 165 | 166 | window.loadURL('file://' + Path.join(__dirname, 'assets', 'base.html')) 167 | return window 168 | } 169 | 170 | function openWindow (path, opts) { 171 | var window = new electron.BrowserWindow(opts) 172 | window.webContents.on('dom-ready', function () { 173 | window.webContents.executeJavaScript(` 174 | // copy argv from main process 175 | process.argv = ${JSON.stringify(process.argv)} 176 | 177 | var electron = require('electron') 178 | var h = require('mutant/h') 179 | electron.webFrame.setVisualZoomLevelLimits(1, 1) 180 | var title = ${JSON.stringify(opts.title || 'PatchTron 3000')} 181 | document.documentElement.querySelector('head').appendChild( 182 | h('title', title) 183 | ) 184 | require(${JSON.stringify(path)}) 185 | `) 186 | }) 187 | 188 | // window.webContents.on('will-navigate', function (e, url) { 189 | // e.preventDefault() 190 | // electron.shell.openExternal(url) 191 | // }) 192 | 193 | // window.webContents.on('new-window', function (e, url) { 194 | // e.preventDefault() 195 | // electron.shell.openExternal(url) 196 | // }) 197 | 198 | window.loadURL('file://' + Path.join(__dirname, 'assets', 'base.html')) 199 | return window 200 | } 201 | -------------------------------------------------------------------------------- /sbot-plugin/about.js: -------------------------------------------------------------------------------- 1 | var pull = require('pull-stream') 2 | 3 | module.exports = function (ssb, config) { 4 | return { 5 | read, 6 | socialValue: function ({key, dest}, cb) { 7 | socialValues({key, dest}, (err, values) => { 8 | if (err) return cb(err) 9 | if (values[ssb.id]) { 10 | // you assigned a value, use this! 11 | cb(null, values[ssb.id]) 12 | } else if (values[dest]) { 13 | // they assigned a name, use this! 14 | cb(null, values[dest]) 15 | } else { 16 | // TODO: choose a value from selection based on most common 17 | cb(null, null) 18 | } 19 | }) 20 | }, 21 | socialValues 22 | } 23 | 24 | function socialValues ({key, dest}, cb) { 25 | var values = {} 26 | pull( 27 | read({dest}), 28 | pull.drain(msg => { 29 | if (msg.value.content[key]) { 30 | values[msg.value.author] = msg.value.content[key] 31 | } 32 | }, (err) => { 33 | if (err) return cb(err) 34 | cb(null, values) 35 | }) 36 | ) 37 | } 38 | 39 | function read ({reverse = false, limit, live, old, dest}) { 40 | return pull( 41 | ssb.backlinks.read({ 42 | reverse, 43 | live, 44 | limit, 45 | query: [{$filter: { 46 | dest, 47 | value: {content: {type: 'about', about: dest}} 48 | }}] 49 | }) 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /sbot-plugin/index.js: -------------------------------------------------------------------------------- 1 | 2 | var Thread = require('./thread') 3 | var About = require('./about') 4 | var Likes = require('./likes') 5 | var Profile = require('./profile') 6 | var PublicFeed = require('./public-feed') 7 | var pull = require('pull-stream') 8 | var Subscriptions = require('./subscriptions') 9 | 10 | exports.name = 'patchtron' 11 | exports.version = require('../package.json').version 12 | exports.manifest = { 13 | profile: { 14 | roots: 'source' 15 | }, 16 | publicFeed: { 17 | roots: 'source' 18 | }, 19 | thread: { 20 | read: 'source' 21 | }, 22 | about: { 23 | read: 'source', 24 | socialValue: 'async', 25 | socialValues: 'async' 26 | }, 27 | likes: { 28 | read: 'source', 29 | current: 'async', 30 | counts: 'async' 31 | }, 32 | subscriptions: { 33 | get: 'async' 34 | } 35 | } 36 | 37 | exports.init = function (ssb, config) { 38 | var thread = Thread(ssb, config) 39 | var subscriptions = Subscriptions(ssb, config) 40 | var about = About(ssb, config) 41 | var likes = Likes(ssb, config) 42 | var profile = Profile(ssb, config) 43 | var publicFeed = PublicFeed(ssb, config) 44 | 45 | // prioritize pubs that we actually follow 46 | pull( 47 | ssb.friends.createFriendStream({hops: 1, live: false}), 48 | pull.collect((err, contacts) => { 49 | if (!err) { 50 | ssb.gossip.peers().forEach(function (peer) { 51 | if (contacts.includes(peer.key)) { 52 | ssb.gossip.add(peer, 'friends') 53 | } 54 | }) 55 | } 56 | }) 57 | ) 58 | 59 | return { 60 | publicFeed, 61 | thread, 62 | about, 63 | likes, 64 | profile, 65 | subscriptions 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /sbot-plugin/likes.js: -------------------------------------------------------------------------------- 1 | var pull = require('pull-stream') 2 | 3 | module.exports = function (ssb, config) { 4 | return { 5 | read, 6 | counts: function ({dest}, cb) { 7 | current({dest}, (err, values) => { 8 | if (err) return cb(err) 9 | var result = { 10 | total: Object.keys(values).length, 11 | expressionCounts: {} 12 | } 13 | Object.keys(values).forEach(key => { 14 | let value = values[key] 15 | result.expressionCounts[value] = (result.expressionCounts[value] || 0) + 1 16 | }) 17 | cb(null, result) 18 | }) 19 | }, 20 | current 21 | } 22 | 23 | function current ({dest}, cb) { 24 | var values = {} 25 | pull( 26 | read({dest}), 27 | pull.drain(msg => { 28 | let author = msg.value.author 29 | let vote = msg.value.content.vote 30 | if (vote) { 31 | if (vote.value > 0) { 32 | values[author] = String(vote.expression || 'Like') 33 | } else { 34 | delete values[author] 35 | } 36 | } 37 | }, (err) => { 38 | if (err) return cb(err) 39 | cb(null, values) 40 | }) 41 | ) 42 | } 43 | 44 | function read ({reverse = false, limit, live, old, dest}) { 45 | return pull( 46 | ssb.backlinks.read({ 47 | reverse, 48 | live, 49 | limit, 50 | query: [{$filter: { 51 | dest, 52 | value: {content: {type: 'vote', vote: {link: dest}}} 53 | }}] 54 | }) 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /sbot-plugin/profile.js: -------------------------------------------------------------------------------- 1 | const pull = require('pull-stream') 2 | const extend = require('xtend') 3 | const HLRU = require('hashlru') 4 | const LookupRoots = require('../lib/lookup-roots') 5 | const UniqueRoots = require('../lib/unique-roots') 6 | const getRoot = require('../lib/get-root') 7 | const threadSummary = require('../lib/thread-summary') 8 | const pullResume = require('../lib/pull-resume') 9 | 10 | module.exports = function Profile (ssb, config) { 11 | var cache = HLRU(100) 12 | 13 | return { 14 | roots: function ({id, limit, filterReplyAuthors, reverse, resume}) { 15 | // use resume option if specified 16 | var opts = {id, reverse, old: true} 17 | if (resume) { 18 | opts[reverse ? 'lt' : 'gt'] = resume 19 | } 20 | 21 | return pullResume.source(ssb.createUserStream(opts), { 22 | limit, 23 | getResume: (item) => { 24 | // WAITING FOR: https://github.com/ssbc/secure-scuttlebutt/pull/215 25 | // otherwise roots can potentially have unwanted items pinned to top of feed 26 | // if a message has a timestamp far in the future 27 | return item && item.value && item.value.sequence 28 | }, 29 | filterMap: pull( 30 | LookupRoots({ssb, cache}), 31 | 32 | // DON'T REPEAT THE SAME THREAD 33 | UniqueRoots(), 34 | 35 | // DON'T INCLUDE UN-ROOTED MESSAGES (e.g. missing conversation root) 36 | pull.filter(msg => { 37 | return !getRoot(msg.root) 38 | }), 39 | 40 | // JUST RETURN THE ROOT OF THE MESSAGE 41 | pull.map(msg => { 42 | return msg.root || msg 43 | }), 44 | 45 | // ADD THREAD SUMMARY 46 | pull.asyncMap((item, cb) => { 47 | threadSummary(item.key, { 48 | readThread: ssb.patchtron.thread.read, 49 | recentLimit: 3, 50 | bumpFilter, 51 | recentFilter 52 | }, (err, summary) => { 53 | if (err) return cb(err) 54 | cb(null, extend(item, summary)) 55 | }) 56 | }) 57 | ) 58 | }) 59 | 60 | function recentFilter (msg) { 61 | let content = msg.value.content 62 | let type = content.type 63 | return type !== 'vote' 64 | } 65 | 66 | function bumpFilter (msg) { 67 | // match summary bumps to actual bumps 68 | if (msg.value.author === ssb.id) { 69 | let content = msg.value.content 70 | let type = content.type 71 | if (type === 'vote') { 72 | let vote = content.vote 73 | if (vote) { 74 | return {type: 'reaction', reaction: vote.expression, value: vote.value} 75 | } 76 | } else if (type === 'post') { 77 | return {type: 'reply'} 78 | } else if (type === 'about') { 79 | return {type: 'update'} 80 | } 81 | } 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /sbot-plugin/public-feed.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const pull = require('pull-stream') 3 | const HLRU = require('hashlru') 4 | const extend = require('xtend') 5 | const normalizeChannel = require('ssb-ref').normalizeChannel 6 | const Defer = require('pull-defer') 7 | const pullResume = require('../lib/pull-resume') 8 | const threadSummary = require('../lib/thread-summary') 9 | const LookupRoots = require('../lib/lookup-roots') 10 | 11 | module.exports = function PublicFeed (ssb, config) { 12 | // cache mostly just to avoid reading the same roots over and over again 13 | // not really big enough for multiple refresh cycles 14 | var cache = HLRU(100) 15 | 16 | return { 17 | roots: function ({ids = [ssb.id], reverse, limit, resume}) { 18 | var seen = new Set() 19 | var included = new Set() 20 | 21 | var stream = Defer.source() 22 | 23 | getFilter({ssb, ids}, (err, filter) => { 24 | if (err) return stream.abort(err) 25 | 26 | // use resume option if specified 27 | var opts = {reverse, old: true} 28 | if (resume) { 29 | opts[reverse ? 'lt' : 'gt'] = resume 30 | } 31 | 32 | stream.resolve(pullResume.source(ssb.createFeedStream(opts), { 33 | limit, 34 | getResume: (item) => { 35 | // WAITING FOR: https://github.com/ssbc/secure-scuttlebutt/pull/215 36 | // otherwise roots can potentially have unwanted items pinned to top of feed 37 | // if a message has a timestamp far in the future 38 | return item && (item.rts || (item.value && item.value.timestamp)) 39 | }, 40 | filterMap: pull( 41 | // BUMP FILTER 42 | pull.filter(item => { 43 | if (filter && item) { 44 | var filterResult = filter(item) 45 | if (filterResult) { 46 | item.filterResult = filterResult 47 | return true 48 | } 49 | } 50 | }), 51 | 52 | // LOOKUP AND ADD ROOTS 53 | LookupRoots({ssb, cache}), 54 | 55 | // FILTER ROOTS 56 | pull.filter(item => { 57 | var root = item.root || item 58 | var isPrivate = root.value && root.value.private 59 | 60 | // skip this item if it has already been included 61 | if (!included.has(root.key) && filter && root && root.value && !isPrivate) { 62 | if (checkReplyForcesDisplay(item)) { // include this item if it has matching tags or the author is you 63 | // update filter result so that we can display the correct bump message 64 | root.filterResult = extend(item.filterResult, {forced: true}) 65 | included.add(root.key) 66 | return true 67 | } else if (!seen.has(root.key)) { 68 | seen.add(root.key) 69 | var filterResult = filter(root) 70 | if (shouldShow(filterResult)) { 71 | root.filterResult = filterResult 72 | included.add(root.key) 73 | return true 74 | } 75 | } 76 | } 77 | }), 78 | 79 | // MAP ROOT ITEMS 80 | pull.map(item => { 81 | var root = item.root || item 82 | return root 83 | }), 84 | 85 | // ADD THREAD SUMMARY 86 | pull.asyncMap((item, cb) => { 87 | threadSummary(item.key, { 88 | recentLimit: 3, 89 | readThread: ssb.patchtron.thread.read, 90 | bumpFilter 91 | }, (err, summary) => { 92 | if (err) return cb(err) 93 | cb(null, extend(item, summary, { 94 | filterResult: undefined, 95 | rootBump: bumpFromFilterResult(item, item.filterResult) 96 | })) 97 | }) 98 | }) 99 | ) 100 | })) 101 | 102 | function bumpFilter (msg) { 103 | let filterResult = filter(msg) 104 | return bumpFromFilterResult(msg, filterResult) 105 | } 106 | }) 107 | 108 | return stream 109 | } 110 | } 111 | 112 | function shouldShow (filterResult) { 113 | return !!filterResult 114 | } 115 | } 116 | 117 | function getMatchingTags (lookup, mentions) { 118 | if (Array.isArray(mentions)) { 119 | return mentions.reduce((result, mention) => { 120 | if (mention && typeof mention.link === 'string' && mention.link.startsWith('#')) { 121 | if (checkChannel(lookup, mention.link.slice(1))) { 122 | result.push(normalizeChannel(mention.link.slice(1))) 123 | } 124 | } 125 | return result 126 | }, []) 127 | } 128 | return [] 129 | } 130 | 131 | function getMentionsYou (ids, mentions) { 132 | if (Array.isArray(mentions)) { 133 | return mentions.some((mention) => { 134 | if (mention && typeof mention.link === 'string') { 135 | return ids.includes(mention.link) 136 | } 137 | }) 138 | } 139 | } 140 | 141 | function checkReplyForcesDisplay (item) { 142 | var filterResult = item.filterResult || {} 143 | var matchesTags = filterResult.matchingTags && !!filterResult.matchingTags.length 144 | return matchesTags || filterResult.isYours 145 | } 146 | 147 | function checkFollowing (lookup, ids, target) { 148 | // TODO: rewrite contacts index (for some reason the order is different) 149 | if (!lookup) return false 150 | // HACK: only lookup the first ID until a method is added to ssb-friends to 151 | // correctly identify latest info 152 | var value = ids.slice(0, 1).map(id => lookup[id] && lookup[id][target]) 153 | return value && value[0] 154 | } 155 | 156 | function checkChannel (lookup, channel) { 157 | if (!lookup) return false 158 | channel = normalizeChannel(channel) 159 | if (channel) { 160 | return lookup[channel] && lookup[channel].subscribed 161 | } 162 | } 163 | 164 | function mostRecentValue (values, timestampIndex = 0) { 165 | var mostRecent = null 166 | values.forEach(value => { 167 | if (value && (!mostRecent || mostRecent[timestampIndex] < value[timestampIndex])) { 168 | mostRecent = value 169 | } 170 | }) 171 | return mostRecent 172 | } 173 | 174 | function bumpFromFilterResult (msg, filterResult) { 175 | if (filterResult) { 176 | if (filterResult.following) { 177 | return {type: 'reply'} 178 | } else if (filterResult.matchesChannel || filterResult.matchingTags.length) { 179 | var channels = new Set() 180 | if (filterResult.matchesChannel) channels.add(msg.value.content.channel) 181 | if (Array.isArray(filterResult.matchingTags)) filterResult.matchingTags.forEach(x => channels.add(x)) 182 | return {type: 'matches-channel', channels: Array.from(channels)} 183 | } 184 | } 185 | } 186 | 187 | function getFilter ({ids, ssb}, cb) { 188 | // TODO: rewrite contacts stream 189 | ssb.friends.get((err, friends) => { 190 | if (err) return cb(err) 191 | 192 | // TODO: support sameAs multiple IDs 193 | ssb.patchtron.subscriptions.get({id: ids[0]}, (err, subscriptions) => { 194 | if (err) return cb(err) 195 | cb(null, function (msg) { 196 | var type = msg.value.content.type 197 | if (type === 'vote') return false // filter out likes 198 | var hasChannel = !!msg.value.content.channel 199 | var matchesChannel = (type !== 'channel' && checkChannel(subscriptions, msg.value.content.channel)) 200 | var matchingTags = getMatchingTags(subscriptions, msg.value.content.mentions) 201 | var isYours = ids.includes(msg.value.author) 202 | var mentionsYou = getMentionsYou(ids, msg.value.content.mentions) 203 | 204 | var following = checkFollowing(friends, ids, msg.value.author) 205 | if (isYours || matchesChannel || matchingTags.length || following || mentionsYou) { 206 | return { 207 | matchingTags, matchesChannel, isYours, following, mentionsYou, hasChannel 208 | } 209 | } 210 | }) 211 | }) 212 | }) 213 | } -------------------------------------------------------------------------------- /sbot-plugin/subscriptions.js: -------------------------------------------------------------------------------- 1 | var normalizeChannel = require('ssb-ref').normalizeChannel 2 | var pull = require('pull-stream') 3 | 4 | module.exports = function (ssb, config) { 5 | var cbs = {} 6 | var caches = {} 7 | 8 | return { 9 | get: function ({id}, cb) { 10 | if (caches[id]) { 11 | cb(null, caches[id]) 12 | } else { 13 | // cache not loaded yet, queue 14 | if (!cbs[id]) { 15 | // first request, start loading 16 | cbs[id] = [cb] 17 | loadCache(id) 18 | } else { 19 | // subsequent request, add to queue 20 | cbs[id].push(cb) 21 | } 22 | } 23 | } 24 | } 25 | 26 | function update (msg, cache) { 27 | cache[normalizeChannel(msg.value.content.channel)] = { 28 | subscribed: msg.value.content.subscribed, 29 | timestamp: msg.value.timestamp 30 | } 31 | } 32 | 33 | function loadCache (id) { 34 | var subscriptions = {} 35 | pull( 36 | ssb.query.read({ 37 | query: [{$filter: { 38 | value: { 39 | author: id, 40 | content: { 41 | type: 'channel' 42 | } 43 | } 44 | }}, {$map: true}], 45 | old: true, 46 | live: true 47 | }), 48 | pull.drain(msg => { 49 | if (msg.sync) { 50 | caches[id] = subscriptions 51 | let callbacks = cbs[id] || [] 52 | cbs[id] = null 53 | callbacks.forEach(cb => { 54 | cb(null, caches[id]) 55 | }) 56 | } else { 57 | update(msg, subscriptions) 58 | } 59 | }, (err) => { 60 | let callbacks = cbs[id] || [] 61 | cbs[id] = null 62 | callbacks.forEach(cb => { 63 | cb(err) 64 | }) 65 | }) 66 | ) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /sbot-plugin/thread.js: -------------------------------------------------------------------------------- 1 | var pull = require('pull-stream') 2 | var getRoot = require('../lib/get-root') 3 | 4 | module.exports = function (ssb, config) { 5 | return { read } 6 | 7 | function read ({reverse = false, limit, types, live, old, dest}) { 8 | // TODO: properly handle truncation 9 | return pull( 10 | ssb.backlinks.read({ 11 | reverse, 12 | live, 13 | index: 'DTA', 14 | query: [{$filter: { dest }}] 15 | }), 16 | pull.filter(msg => { 17 | if (msg.sync) return msg 18 | var type = msg.value.content.type 19 | var root = getRoot(msg) 20 | return root === dest && (!types || types.includes(type)) 21 | }), 22 | limit ? pull.take(limit) : pull.through() 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /styles/avatar.mcss: -------------------------------------------------------------------------------- 1 | Avatar { 2 | background-image: linear-gradient(172deg, rgb(247, 247, 247), rgba(0,0,0,0)) 3 | width: 45px 4 | border-radius: 3px 5 | } 6 | -------------------------------------------------------------------------------- /styles/base.mcss: -------------------------------------------------------------------------------- 1 | html, body { 2 | background: #f5f5f5 3 | margin: 0 4 | font: caption 5 | overflow: hidden 6 | height: 100% 7 | font-size: 12px 8 | -webkit-user-select: none 9 | color: #2b2b2b 10 | } 11 | 12 | body { 13 | display: flex 14 | flex-direction: column 15 | line-height: 1.2 16 | } 17 | 18 | h1 { 19 | font-size: 200% 20 | margin: 4px 0 21 | font-weight: normal 22 | flex: 1 23 | } 24 | 25 | input { 26 | [type='text'] { 27 | font-size: 80% 28 | display: inline-block 29 | padding: 2px 4px 30 | height: 18px 31 | border: 1px solid #A9A9A9 32 | background: #333 33 | color: #FFF 34 | padding-right: 12px 35 | border-radius: 0 36 | } 37 | } 38 | 39 | ::-webkit-file-upload-button { 40 | font-family: inherit 41 | } 42 | 43 | a { 44 | color: #286bc3 45 | text-decoration: none 46 | 47 | code { 48 | color: #8EC1FC 49 | } 50 | 51 | :hover { 52 | color: #5eabff 53 | } 54 | } 55 | 56 | * + h1 { 57 | margin-top: 16px 58 | } 59 | 60 | * { 61 | box-sizing:border-box 62 | } 63 | 64 | input, textarea, keygen, select, button { 65 | font-family: '.SFNSText-Regular', sans-serif 66 | } 67 | -------------------------------------------------------------------------------- /styles/error-message.mcss: -------------------------------------------------------------------------------- 1 | ErrorMessage { 2 | max-width: 800px 3 | margin: 20px; 4 | 5 | pre { 6 | overflow: hidden; 7 | border: 1px solid silver; 8 | background: #EEE; 9 | } 10 | } -------------------------------------------------------------------------------- /styles/feed-event.mcss: -------------------------------------------------------------------------------- 1 | FeedEvent { 2 | display: flex 3 | flex-direction: column 4 | background: white 5 | border: #EEE solid 1px 6 | width: 100% 7 | margin: 25px auto 8 | 9 | -group { 10 | background: #e8e8e8 11 | a.expand { 12 | display: block; 13 | padding: 10px; 14 | background: #f1f1f1; 15 | text-align: center; 16 | color: #1f1e1e; 17 | cursor: pointer; 18 | :before { content: '▼ '; font-size: 80% } 19 | :hover { 20 | color: black 21 | } 22 | } 23 | 24 | div.items { 25 | margin: -10px 15px; 26 | } 27 | 28 | -expanded { 29 | a.expand { 30 | :before { display: none } 31 | } 32 | } 33 | } 34 | 35 | :empty { 36 | margin-bottom: -25px 37 | } 38 | 39 | -new, -unread { 40 | box-shadow: 0px 0px 2px #ffc800; 41 | background: #fffdf7; 42 | } 43 | 44 | div + div { 45 | border-top: 1px solid #EEE 46 | } 47 | 48 | div { 49 | flex: 1 50 | } 51 | 52 | a.full { 53 | display: block; 54 | padding: 10px; 55 | background: #f3fafd; 56 | border-top: 1px solid #bbc9d2; 57 | border-bottom: 1px solid #bbc9d2; 58 | text-align: center; 59 | } 60 | 61 | div.replies { 62 | font-size: 100% 63 | display: flex 64 | flex-direction: column 65 | div { 66 | flex: 1 67 | margin: 0 68 | } 69 | 70 | div + div { 71 | border-top: 1px solid #EEE 72 | } 73 | } 74 | 75 | div.meta { 76 | font-size: 100% 77 | padding: 10px 20px 78 | opacity: 0.8 79 | 80 | a { 81 | font-weight: bold 82 | color: black 83 | [href^="#"] { 84 | border-bottom: 1px dotted black 85 | } 86 | } 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /styles/index.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var path = require('path') 3 | var compile = require('micro-css') 4 | var result = '' 5 | var additional = '' 6 | 7 | fs.readdirSync(__dirname).forEach(function (file) { 8 | if (/\.mcss$/i.test(file)) { 9 | result += fs.readFileSync(path.resolve(__dirname, file), 'utf8') + '\n' 10 | } 11 | 12 | if (/\.css$/i.test(file)) { 13 | additional += fs.readFileSync(path.resolve(__dirname, file), 'utf8') + '\n' 14 | } 15 | }) 16 | 17 | // const flatpickr = require.resolve('flatpickr/dist/flatpickr.css') 18 | // additional += fs.readFileSync(flatpickr) + '\n' 19 | 20 | module.exports = compile(result) + additional 21 | -------------------------------------------------------------------------------- /styles/loading.mcss: -------------------------------------------------------------------------------- 1 | Loading { 2 | height: 25% 3 | display: flex 4 | align-items: center 5 | justify-content: center 6 | whitespace: no-wrap 7 | 8 | span.info { 9 | overflow: hidden 10 | white-space: nowrap 11 | text-overflow: ellipsis 12 | } 13 | 14 | -inline { 15 | height: 16px 16 | width: 16px 17 | display: inline-block 18 | margin: -3px 3px 19 | 20 | ::before { 21 | display: block 22 | height: 16px 23 | width: 16px 24 | } 25 | } 26 | 27 | -small { 28 | ::before { 29 | height: 30px 30 | width: 30px 31 | } 32 | } 33 | 34 | -large { 35 | height: 25vh 36 | ::before { 37 | height: 100px 38 | width: 100px 39 | } 40 | ::after { 41 | color: #CCC; 42 | content: 'Loading...' 43 | font-size: 200% 44 | } 45 | } 46 | 47 | -search { 48 | height: 200px 49 | 50 | ::before { 51 | height: 100px 52 | width: 100px 53 | } 54 | ::after { 55 | color: #CCC; 56 | content: 'Searching...' 57 | font-size: 200% 58 | } 59 | } 60 | 61 | ::before { 62 | content: ' ' 63 | height: 50px 64 | width: 50px 65 | background-image: svg(waitingIcon) 66 | background-repeat: no-repeat 67 | background-position: center 68 | background-size: contain 69 | animation: spin 3s infinite linear 70 | } 71 | } 72 | 73 | @svg waitingIcon { 74 | width: 30px 75 | height: 30px 76 | content: "" 77 | 78 | circle { 79 | stroke: #CCC 80 | stroke-width: 3px 81 | fill: none 82 | } 83 | } 84 | 85 | @keyframes spin { 86 | 0% { 87 | transform: rotate(0deg); 88 | } 89 | 100% { 90 | transform: rotate(360deg); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /styles/main-window.mcss: -------------------------------------------------------------------------------- 1 | MainWindow { 2 | flex: 1 3 | display: flex 4 | flex-direction: column 5 | 6 | :not(.-fullscreen) { 7 | -darwin { 8 | div.top { 9 | padding-left: 70px 10 | } 11 | } 12 | } 13 | 14 | -darwin { 15 | div.top { 16 | span.appTitle { 17 | span.title { 18 | visibility: visible 19 | } 20 | } 21 | } 22 | } 23 | 24 | div.top { 25 | display: flex; 26 | align-items: center; 27 | background: #fff; 28 | padding: 6px; 29 | border-bottom: 2px solid #e4edff; 30 | box-shadow: 0 0 3px #7f7f7f; 31 | position: relative; 32 | z-index: 100 33 | 34 | span { 35 | input.search { 36 | padding: 4px 8px; 37 | border-radius: 3px; 38 | border: 0 none; 39 | background: #ffffff; 40 | color: #656565; 41 | font-size: 120%; 42 | width: 180px; 43 | box-shadow: inset 0 0 0px 1px rgba(0,0,0,0.1) 44 | :focus { 45 | outline: 0; 46 | box-shadow: inset 0 0 0px 1px #286bc3 47 | } 48 | } 49 | } 50 | 51 | span.history { 52 | padding-left: 6px 53 | height: 26px; 54 | display: inline-block 55 | a { 56 | cursor: pointer; 57 | text-decoration: none !important 58 | display: inline-block 59 | width: 28px 60 | height: 100% 61 | border-radius: 2px 62 | background: svg(backArrow) no-repeat center 63 | opacity: 0.4 64 | -active { 65 | opacity: 1 66 | } 67 | :hover { 68 | background-color: #E8E8E8 69 | } 70 | } 71 | 72 | a + a { 73 | transform: rotate(180deg) 74 | } 75 | 76 | @svg backArrow { 77 | width: 14px 78 | height: 14px 79 | content: '' 80 | 81 | path { 82 | stroke: #979797 83 | } 84 | 85 | -active { 86 | path { 87 | fill: #DDD 88 | } 89 | } 90 | } 91 | } 92 | 93 | span.nav { 94 | display: inline-block 95 | a { 96 | padding: 4px 10px; 97 | border-radius: 3px; 98 | background: #f3f3f3; 99 | color: #656565; 100 | font-size: 120%; 101 | font-weight: 200; 102 | cursor: pointer; 103 | margin-left: 8px; 104 | text-decoration: none !important 105 | 106 | :hover { 107 | color: black 108 | background: #E8E8E8 109 | } 110 | 111 | -selected { 112 | background: #d2d2d2 113 | color: black 114 | :hover { 115 | background: #d2d2d2 116 | } 117 | } 118 | 119 | -drop { 120 | 121 | :after { 122 | background-image: svg(dropArrow) 123 | background-repeat: no-repeat; 124 | background-position: center right; 125 | width: 10px; 126 | height: 14px; 127 | display: inline-block; 128 | content: ' '; 129 | margin-left: 6px; 130 | margin-right: -6px; 131 | border-left: 1px solid #d2d2d2; 132 | padding-left: 5px; 133 | margin-bottom: -2px; 134 | } 135 | } 136 | 137 | -add { 138 | border-color: #498849 139 | background-color: #255D24 140 | text-shadow: 1px 1px 1px #000 141 | color: white 142 | 143 | :active { 144 | background-color: #1F331F !important 145 | } 146 | 147 | :hover { 148 | background-color: #356D34 149 | border-color: #4CB54C 150 | } 151 | } 152 | } 153 | } 154 | 155 | span.appTitle { 156 | flex: 1; 157 | text-align: center; 158 | font-size: 20px; 159 | color: #757575; 160 | letter-spacing: 1px; 161 | font-weight: 200; 162 | -webkit-app-region: drag; 163 | position: relative 164 | 165 | span.title { 166 | visibility: hidden 167 | } 168 | 169 | div.info { 170 | display: block 171 | position: absolute; 172 | top: 0; 173 | bottom: 0; 174 | left: 0; 175 | right: 0; 176 | background: white; 177 | margin-top: -3px; 178 | opacity: 1; 179 | transition: opacity 0.1s; 180 | max-height: 25px; 181 | padding: 0 10px; 182 | font-size: 13px; 183 | letter-spacing: 0; 184 | [hidden] { 185 | opacity: 0 186 | } 187 | } 188 | } 189 | } 190 | 191 | div.info { 192 | a.message { 193 | display: block; 194 | padding: 10px; 195 | background: #deffde; 196 | transition: color 0.2s, background-color 0.2s; 197 | color: #217720; 198 | 199 | a.ignore { 200 | float: right 201 | border-radius: 10px 202 | padding: 2px 5px 203 | margin-top: -2px 204 | :hover { 205 | text-decoration: none 206 | background: #c0c0c0 207 | color: white 208 | } 209 | } 210 | 211 | :hover { 212 | text-decoration: none 213 | background: #c0ffae 214 | } 215 | } 216 | 217 | div.status { 218 | padding: 5px 219 | background: #7c7c7c 220 | color: white 221 | (svg) { 222 | width: 20px 223 | height: 20px 224 | } 225 | } 226 | 227 | [hidden] { 228 | display: block 229 | max-height: 0 230 | animation: none 231 | } 232 | 233 | max-height: 100px 234 | box-shadow: 0 0 3px #616161 235 | overflow: hidden 236 | transition: 0.5s max-height 237 | animation: 0.5s slide-in 238 | position: relative 239 | z-index: 1 240 | } 241 | 242 | div.main { 243 | flex: 1 244 | position: relative 245 | 246 | div.view { 247 | 248 | position: absolute 249 | top: 0 250 | bottom: 0 251 | left: 0 252 | right: 0 253 | 254 | [hidden] { 255 | visibility: hidden 256 | } 257 | 258 | display: flex 259 | flex-direction: column 260 | 261 | div { 262 | -webkit-user-select: text 263 | } 264 | } 265 | } 266 | 267 | div.bottom { 268 | position: relative 269 | box-shadow: 0 0 3px #222 270 | background: #222 271 | align-items: center 272 | display: flex 273 | padding: 5px 274 | 275 | audio { 276 | color: #EEE 277 | 278 | ::-webkit-media-controls-panel { 279 | background: transparent 280 | } 281 | 282 | ::-webkit-media-controls-current-time-display { 283 | color: inherit 284 | } 285 | 286 | width: 100% 287 | } 288 | } 289 | } 290 | 291 | @keyframes slide-in { 292 | 0% { 293 | max-height: 0 294 | } 295 | 100% { 296 | max-height: 100px 297 | } 298 | } 299 | 300 | @svg dropArrow { 301 | width: 12px 302 | height: 6px 303 | content: "" 304 | 305 | path { 306 | fill: #888 307 | } 308 | 309 | -active { 310 | path { 311 | fill: #DDD 312 | } 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /styles/markdown.mcss: -------------------------------------------------------------------------------- 1 | Markdown { 2 | word-break: break-word 3 | 4 | (a.bad) { 5 | color: inherit 6 | :hover { 7 | text-decoration: inherit 8 | } 9 | } 10 | 11 | (img) { 12 | -pending { 13 | border: 1px solid #DDD 14 | background-color: #EEE 15 | width: 120px 16 | height: 40px 17 | background-image: svg(fetching) 18 | background-position: center 19 | background-repeat: no-repeat 20 | 21 | @svg fetching { 22 | width: 100px 23 | height: 20px 24 | content: 'Fetching image...' 25 | text { 26 | font: caption 27 | font-size: 12px 28 | } 29 | } 30 | } 31 | } 32 | 33 | (video) { 34 | max-width: 100% 35 | object-fit: scale-down 36 | } 37 | 38 | (audio) { 39 | width: 100% 40 | } 41 | 42 | (table) { 43 | margin: 10px 0 44 | border-collapse: collapse 45 | (th) { 46 | text-align: left 47 | border-bottom: 1px solid #DDD 48 | padding: 3px 49 | } 50 | (td) { 51 | padding: 3px 52 | } 53 | } 54 | 55 | (blockquote) { 56 | margin: 1rem 0; 57 | padding: 5px 20px; 58 | border-left: 4px gainsboro solid; 59 | background: #fafafa; 60 | color: #7c7c7c; 61 | } 62 | (hr) { 63 | border: none; 64 | border-top: 1px solid #7e7e7e; 65 | } 66 | (pre) { 67 | overflow: auto; 68 | padding: 10px; 69 | background: #fbfbfb; 70 | border: 1px solid #EEE; 71 | max-height: 300px; 72 | } 73 | (ul) { 74 | (p) { 75 | margin: 0; 76 | } 77 | } 78 | (img.emoji) { 79 | width: 1.5em; 80 | height: 1.5em; 81 | align-content: center; 82 | margin-bottom: -0.3em; 83 | } 84 | (a) { 85 | [href^="#"], [href^="@"], [href^="%"] { 86 | border-bottom: 1px dotted #286bc3; 87 | :hover { 88 | text-decoration: none; 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /styles/message.mcss: -------------------------------------------------------------------------------- 1 | Message { 2 | display: flex 3 | flex-direction: column 4 | background: white 5 | position: relative 6 | font-size: 120% 7 | line-height: 1.4 8 | flex-shrink: 0 9 | 10 | (highlight) { 11 | background-color: rgb(216, 179, 0); 12 | color: #ffffff; 13 | border-radius: 3px; 14 | padding: 0 2px; 15 | } 16 | 17 | :focus { 18 | z-index: 1 19 | } 20 | 21 | -following { 22 | header { 23 | div.main { 24 | div.main { 25 | div.name { 26 | $following 27 | } 28 | } 29 | } 30 | } 31 | } 32 | 33 | -data { 34 | header { 35 | div.main { 36 | font-size: 80% 37 | a.avatar { 38 | img { 39 | width: 25px 40 | } 41 | } 42 | } 43 | } 44 | (pre) { 45 | overflow: auto 46 | max-height: 200px 47 | } 48 | } 49 | 50 | -mini { 51 | header { 52 | font-size: 100% 53 | margin-bottom: 15px 54 | div.main { 55 | a.avatar { 56 | img { 57 | width: 40px 58 | height: 40px 59 | } 60 | } 61 | } 62 | } 63 | } 64 | 65 | -compact { 66 | section { 67 | max-height: 300px 68 | } 69 | } 70 | 71 | -reply { 72 | header { 73 | font-size: 100% 74 | div.meta { 75 | a.channel { 76 | display: none; 77 | } 78 | span.private { 79 | (img) { 80 | width: 40px 81 | height: 40px 82 | } 83 | } 84 | } 85 | div.main { 86 | a.avatar { 87 | img { 88 | width: 40px 89 | height: 40px 90 | } 91 | } 92 | } 93 | } 94 | } 95 | 96 | -missing { 97 | header { 98 | div.main { 99 | div.main { 100 | margin-left: 0 101 | } 102 | } 103 | } 104 | section { 105 | font-size: 90% 106 | font-style: italic 107 | color: #777 108 | } 109 | } 110 | 111 | -new, -unread { 112 | box-shadow: 0 0 1px #ffc600; 113 | z-index: 1; 114 | } 115 | 116 | header { 117 | font-size: 120% 118 | margin: 15px 20px 0 119 | display: flex 120 | 121 | div.mini { 122 | flex: 1 123 | } 124 | 125 | div.main { 126 | display: flex 127 | flex: 1 128 | 129 | a.avatar { 130 | img { 131 | width: 50px 132 | height: 50px 133 | } 134 | } 135 | 136 | div.main { 137 | div.name { 138 | a { 139 | color: #444 140 | font-weight: bold 141 | } 142 | } 143 | div.meta { 144 | font-size: 90% 145 | } 146 | margin-left: 10px 147 | } 148 | } 149 | 150 | div.meta { 151 | display: flex; 152 | flex-direction: column-reverse; 153 | align-items: flex-end; 154 | justify-content: flex-end; 155 | 156 | span.flag { 157 | width: 12px 158 | height: 12px 159 | 160 | background-repeat: no-repeat 161 | background-position: center 162 | display: inline-block 163 | vertical-align: middle; 164 | margin-top: -3px; 165 | 166 | -unread { 167 | :after { 168 | content: ' unread' 169 | font-size: 75% 170 | color: #b7b7b7 171 | } 172 | } 173 | 174 | -new { 175 | :after { 176 | content: ' new' 177 | font-size: 75% 178 | color: #b7b7b7 179 | } 180 | } 181 | 182 | -new, -unread { 183 | width: auto 184 | height: auto 185 | color: #ffcf04 186 | 187 | :before { 188 | content: '✸' 189 | } 190 | 191 | :first-letter { 192 | font-size: 100% 193 | } 194 | } 195 | } 196 | 197 | em { 198 | display: inline-block 199 | padding: 4px 200 | } 201 | 202 | a.channel { 203 | font-weight: bold; 204 | } 205 | 206 | a.likes { 207 | font-size: 85%; 208 | color: #757474 209 | 210 | ::before { 211 | content: '❤ ' 212 | color: #ff2f92 213 | } 214 | :hover { 215 | color: #ff2f92 216 | } 217 | } 218 | 219 | span.private { 220 | display: inline-block; 221 | margin: -3px -3px 3px 4px; 222 | border: 4px solid #525050; 223 | background-color: #525050 224 | position: relative; 225 | 226 | a { 227 | display: inline-block 228 | 229 | img { 230 | margin: 0 231 | vertical-align: bottom 232 | border: none 233 | } 234 | } 235 | 236 | :after { 237 | content: 'private'; 238 | position: absolute; 239 | background: #525050; 240 | bottom: 0; 241 | left: -1px; 242 | font-size: 10px; 243 | padding: 2px 4px 0 2px; 244 | border-top-right-radius: 5px; 245 | color: white; 246 | font-weight: bold; 247 | pointer-events: none; 248 | white-space: nowrap 249 | } 250 | } 251 | } 252 | } 253 | 254 | section { 255 | margin: 0 256 | padding: 0 20px 257 | max-height: 1500px 258 | overflow: hidden 259 | 260 | (img) { 261 | max-width: 100% 262 | } 263 | 264 | -expanded { 265 | max-height: none 266 | } 267 | } 268 | 269 | a.backlink { 270 | display: block; 271 | border-top: 1px solid #EEE; 272 | padding: 10px 15px; 273 | background: #ffffff; 274 | color: #8f8f8f; 275 | margin-top: -1px; 276 | font-size: 9pt; 277 | 278 | :hover { 279 | text-decoration: none 280 | color: #777 281 | } 282 | } 283 | 284 | footer { 285 | margin: 5px 0 20px; 286 | padding: 0 20px 287 | 288 | div.expander { 289 | text-align: center; 290 | background-color: white; 291 | 292 | -truncated { 293 | padding-top: 50px; 294 | margin-top: -50px; 295 | -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,1) 50%, rgba(0,0,0,0)); 296 | a { 297 | :before { content: '▼ '; font-size: 80% } 298 | } 299 | } 300 | } 301 | 302 | div.actions { 303 | a.like { 304 | :before { 305 | content: "❤ " 306 | } 307 | :hover { 308 | background: #e26ba4 309 | } 310 | } 311 | a.unlike { 312 | :before { 313 | content: "" 314 | } 315 | :hover { 316 | background: #959EAB 317 | } 318 | } 319 | a { 320 | font-size: 13px 321 | background: #f3f3f3; 322 | padding: 6px 10px; 323 | border-radius: 3px; 324 | color: #656565; 325 | display: inline-block 326 | text-align: center 327 | transition: background-color 0.25s ease, color 0.25s ease 328 | 329 | :before { 330 | content: "↩ " 331 | } 332 | :hover { 333 | background: #7dbd85 334 | color: white 335 | } 336 | } 337 | a + a { 338 | margin-left: 10px; 339 | } 340 | } 341 | 342 | div.actions2 { 343 | a { 344 | opacity: 0.4 345 | transition: opacity 0.2s 346 | font-weight: bold 347 | color: #333 348 | 349 | :hover { 350 | opacity: 1 351 | text-decoration: none; 352 | } 353 | } 354 | a + a { 355 | margin-left: 25px; 356 | } 357 | } 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /styles/profile-header.mcss: -------------------------------------------------------------------------------- 1 | ProfileHeader { 2 | display: flex; 3 | width: 100%; 4 | margin: 20px auto; 5 | 6 | div.image { 7 | width: 200px; 8 | max-height: 200px; 9 | padding: 5px; 10 | background: white; 11 | box-shadow: 0 0 10px #AAA; 12 | margin-right: 20px; 13 | img { 14 | width: 100% 15 | } 16 | } 17 | 18 | div.main { 19 | min-width: 0 20 | flex: 1 21 | div.title { 22 | display: flex 23 | h1 { 24 | flex: 1 25 | } 26 | div.meta { 27 | a { 28 | margin-left: 5px 29 | display: inline-block 30 | } 31 | } 32 | } 33 | section { 34 | -description { 35 | font-size: 120% 36 | max-height: 350px 37 | overflow: auto 38 | -webkit-mask-image: linear-gradient(180deg, rgba(0,0,0,1) 90%, rgba(0,0,0,0)); 39 | } 40 | -publicKey { 41 | pre { 42 | text-overflow: ellipsis; 43 | overflow: hidden; 44 | color: #b3b3b3; 45 | margin: 2px 0; 46 | font-size: 120%; 47 | background: #ffffff; 48 | padding: 3px; 49 | } 50 | } 51 | -distanceWarning { 52 | $distanceWarning 53 | } 54 | 55 | -blockWarning { 56 | padding: 8px; 57 | border: 1px solid #ffc965; 58 | background: #ffebcc; 59 | margin: 5px 0; 60 | color: #8a6800; 61 | font-size: 110% 62 | 63 | a { 64 | color: #8a6800; 65 | :hover { 66 | text-decoration: none 67 | color: #da8701 68 | } 69 | } 70 | } 71 | 72 | -mutualFriends { 73 | padding: 8px; 74 | background: #cfe9ff; 75 | margin: 5px 0; 76 | font-size: 110%; 77 | font-weight: bold; 78 | 79 | a { 80 | color: #003a5d; 81 | :hover { 82 | text-decoration: none 83 | color: #0188da 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /styles/scroller.mcss: -------------------------------------------------------------------------------- 1 | Scroller { 2 | display: flex 3 | flex-direction: column 4 | padding: 0 20px 5 | overflow: auto 6 | width: 100% 7 | height: 100% 8 | min-height: 0px 9 | 10 | div.wrapper { 11 | flex: 1 12 | width: 100% 13 | max-width: 800px 14 | margin-left: auto 15 | margin-right: auto 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /views/actions/index.js: -------------------------------------------------------------------------------- 1 | var like = require('./like') 2 | var reply = require('./reply') 3 | 4 | module.exports = function actions (msg, {connection, i18n}) { 5 | return [ 6 | like(msg, {connection, i18n}), 7 | reply(msg, {connection, i18n}) 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /views/actions/like.js: -------------------------------------------------------------------------------- 1 | const { h } = require('mutant') 2 | 3 | module.exports = function renderLikeAction (msg, {connection, i18n}) { 4 | var result = h('a.like', { 5 | href: '#', 6 | state: { 7 | dest: msg.key, 8 | connection, 9 | i18n 10 | } 11 | }, i18n('Like')) 12 | 13 | result.addEventListener('click', handleClick) 14 | result.update = updateSelf 15 | result.update() 16 | return result 17 | } 18 | 19 | function updateSelf () { 20 | let element = this 21 | let {dest, connection, i18n} = element.state 22 | connection.obtain(sbot => { 23 | sbot['patchtron'].likes.current({dest}, (err, values) => { 24 | if (err) return 25 | if (values[connection.id]) { 26 | element.className = 'unlike' 27 | element.innerText = i18n('Unlike') 28 | } else { 29 | element.className = 'like' 30 | element.innerText = i18n('Like') 31 | } 32 | }) 33 | }) 34 | } 35 | 36 | function handleClick (ev) { 37 | let element = this 38 | let {dest, connection} = element.state 39 | 40 | let vote = element.classList.contains('like') 41 | ? { link: dest, value: 1, expression: 'Like' } 42 | : { link: dest, value: 0, expression: 'Unlike' } 43 | 44 | connection.publish({ type: 'vote', vote }) 45 | } 46 | -------------------------------------------------------------------------------- /views/actions/reply.js: -------------------------------------------------------------------------------- 1 | const { h } = require('mutant') 2 | 3 | module.exports = function renderLikeAction (msg, {connection, i18n}) { 4 | var result = h('a.reply', { 5 | state: { 6 | dest: msg.key, 7 | connection, 8 | i18n 9 | } 10 | }, i18n('Reply')) 11 | 12 | result.addEventListener('click', handleClick) 13 | result.update = updateSelf 14 | result.update() 15 | return result 16 | } 17 | 18 | function updateSelf () { 19 | // let element = this 20 | // let {dest, connection, i18n} = element.state 21 | } 22 | 23 | function handleClick (ev) { 24 | // let element = this 25 | // let {dest, connection} = element.state 26 | } 27 | -------------------------------------------------------------------------------- /views/feed-item.js: -------------------------------------------------------------------------------- 1 | const renderMessage = require('./message') 2 | const person = require('./person') 3 | const many = require('./many') 4 | const { h } = require('mutant') 5 | 6 | var bumpMessages = { 7 | 'reaction': 'liked this message', 8 | 'reply': 'replied to this message', 9 | 'updated': 'added changes', 10 | 'mention': 'mentioned you', 11 | 'channel-mention': 'mentioned this channel', 12 | 'attending': 'can attend' 13 | } 14 | 15 | module.exports = function renderItem (msg, {connection, i18n}) { 16 | let mostRecentBumpType = (msg.bumps && msg.bumps[0] && msg.bumps[0].type) || 'reply' 17 | let bumps = getBumps(msg)[mostRecentBumpType] 18 | 19 | var meta = null 20 | 21 | // explain why this message is in your feed 22 | if (mostRecentBumpType !== 'matches-channel' && msg.rootBump && msg.rootBump.type === 'matches-channel') { 23 | // the root post was in a channel that you subscribe to 24 | meta = h('div.meta', [ 25 | many(msg.rootBump.channels, {renderItem: channel, i18n}), ' ', i18n('mentioned in your network') 26 | ]) 27 | } else if (bumps && bumps.length) { 28 | let authors = getAuthors(bumps) 29 | if (mostRecentBumpType === 'matches-channel') { 30 | // a reply to this post matches a channel you subscribe to 31 | let channels = new Set() 32 | bumps.forEach(bump => bump.channels && bump.channels.forEach(c => channels.add(c))) 33 | meta = h('div.meta', [ 34 | i18n.plural('%s people from your network replied to this message on ', authors.length), 35 | many(channels, { 36 | i18n, renderItem: channel 37 | }) 38 | ]) 39 | } else { 40 | // someone you follow replied to this message 41 | var description = i18n(bumpMessages[mostRecentBumpType] || 'added changes') 42 | meta = h('div.meta', [ 43 | many(authors, { 44 | i18n, 45 | renderItem: author => person(author, {connection}) 46 | }), ' ', description 47 | ]) 48 | } 49 | } 50 | 51 | return h('FeedEvent -post', [ 52 | meta, 53 | renderMessage(msg, {connection, i18n}), 54 | msg.totalReplies > msg.latestReplies.length ? h('a.full', {href: msg.key}, ['View full thread' + ' (', msg.totalReplies, ')']) : null, 55 | h('div.replies', [ 56 | msg.latestReplies.map(msg => renderMessage(msg, {connection, i18n})) 57 | ]) 58 | ]) 59 | } 60 | 61 | function getBumps (msg) { 62 | var bumps = {} 63 | if (Array.isArray(msg.bumps)) { 64 | msg.bumps.forEach(bump => { 65 | let type = bump.type || 'reply' 66 | bumps[type] = bumps[type] || [] 67 | bumps[type].push(bump) 68 | }) 69 | } 70 | return bumps 71 | } 72 | 73 | function getAuthors (items) { 74 | var authors = {} 75 | items.forEach(item => { 76 | authors[item.author] = true 77 | }) 78 | return Object.keys(authors) 79 | } 80 | 81 | function channel (id) { 82 | return h('a.channel', {href: `#${id}`}, `#${id}`) 83 | } 84 | -------------------------------------------------------------------------------- /views/feed.js: -------------------------------------------------------------------------------- 1 | const { h, Value, when, Proxy, computed } = require('mutant') 2 | const Scroller = require('../lib/scroller') 3 | const pull = require('pull-stream') 4 | 5 | module.exports = function renderFeed ({connection, i18n, prepend, renderItem, getStream}) { 6 | var done = Value(false) 7 | var error = Value(null) 8 | var loading = Proxy(true) 9 | var seen = new Set() 10 | 11 | var content = h('section.content') 12 | var container = h('Scroller', { 13 | style: { overflow: 'auto' } 14 | }, [ 15 | h('div.wrapper', [ 16 | h('section.prepend', prepend), 17 | when(error, computed(error, renderError)), 18 | content, 19 | when(loading, h('Loading -large')) 20 | ]) 21 | ]) 22 | 23 | var scroller = Scroller(container, content, (msg) => renderItem(msg, {connection, i18n}), { 24 | onDone: (err) => { 25 | if (err) error.set(err) 26 | done.set(true) 27 | }, 28 | onItemVisible: (item) => {} 29 | }) 30 | 31 | loading.set(computed([done, scroller.queue], (done, queue) => { 32 | return !done && queue < 5 33 | })) 34 | 35 | pull( 36 | getStream(), 37 | pull.filter(msg => { 38 | // only render posts 39 | if (!msg.value || msg.value.content.type !== 'post') return 40 | 41 | // only render a post the first time we see it (duplicates because of resume) 42 | if (!seen.has(msg.key)) { 43 | seen.add(msg.key) 44 | return true 45 | } 46 | }), 47 | // GroupWhile((result, msg) => result.length < 20), 48 | // pull.flatten(), 49 | scroller 50 | ) 51 | 52 | container.done = done 53 | 54 | return container 55 | } 56 | 57 | function renderError (err) { 58 | if (err) { 59 | return h('ErrorMessage', [ 60 | h('h1', '⚠️ An error occurred'), 61 | h('pre', err.stack) 62 | ]) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /views/image.js: -------------------------------------------------------------------------------- 1 | const colorHash = new (require('color-hash'))() 2 | const fallbackImageUrl = 'data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==' 3 | const { h } = require('mutant') 4 | 5 | module.exports = function image (id, {connection}) { 6 | var result = h('img', { 7 | className: 'Avatar', 8 | style: { 'background-color': colorHash.hex(id) }, 9 | src: fallbackImageUrl 10 | }) 11 | 12 | connection.about(id, 'image', (err, link) => { 13 | if (err) return 14 | if (link) { 15 | result.src = connection.blobUrl(link) 16 | } 17 | }) 18 | 19 | return result 20 | } 21 | -------------------------------------------------------------------------------- /views/likes.js: -------------------------------------------------------------------------------- 1 | const { h } = require('mutant') 2 | 3 | module.exports = function likes (msg, {connection, i18n}) { 4 | var result = h('a.likes', { 5 | style: {display: 'none'}, 6 | dest: msg.key 7 | }) 8 | result.update = updateSelf 9 | result.update({connection, i18n}) 10 | return result 11 | } 12 | 13 | function updateSelf ({connection, i18n}) { 14 | let element = this 15 | connection.obtain(sbot => { 16 | sbot['patchtron'].likes.counts({dest: element.dest}, (err, counts) => { 17 | if (err) return 18 | element.innerText = i18n.plural(`%s likes`, counts.total) 19 | 20 | if (counts.total > 0) { 21 | element.style.display = '' 22 | } else { 23 | element.style.display = 'none' 24 | } 25 | }) 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /views/many.js: -------------------------------------------------------------------------------- 1 | module.exports = function many (ids, {renderItem, i18n}) { 2 | ids = Array.from(ids) 3 | var featuredIds = ids.slice(0, 4) 4 | 5 | if (ids.length) { 6 | if (ids.length > 4) { 7 | return [ 8 | renderItem(featuredIds[0]), ', ', 9 | renderItem(featuredIds[1]), ', ', 10 | renderItem(featuredIds[2]), i18n(' and '), 11 | ids.length - 3, i18n(' others') 12 | ] 13 | } else if (ids.length === 4) { 14 | return [ 15 | renderItem(featuredIds[0]), ', ', 16 | renderItem(featuredIds[1]), ', ', 17 | renderItem(featuredIds[2]), i18n(' and '), 18 | renderItem(featuredIds[3]) 19 | ] 20 | } else if (ids.length === 3) { 21 | return [ 22 | renderItem(featuredIds[0]), ', ', 23 | renderItem(featuredIds[1]), i18n(' and '), 24 | renderItem(featuredIds[2]) 25 | ] 26 | } else if (ids.length === 2) { 27 | return [ 28 | renderItem(featuredIds[0]), i18n(' and '), 29 | renderItem(featuredIds[1]) 30 | ] 31 | } else { 32 | return renderItem(featuredIds[0]) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /views/markdown.js: -------------------------------------------------------------------------------- 1 | const h = require('mutant/h') 2 | const ref = require('ssb-ref') 3 | const markdownRenderer = require('ssb-markdown') 4 | const htmlEscape = require('html-escape') 5 | const QueryString = require('querystring') 6 | 7 | module.exports = function markdown (content, {connection}) { 8 | if (typeof content === 'string') { content = {text: content} } 9 | var mentions = {} 10 | var typeLookup = {} 11 | var emojiMentions = {} 12 | if (Array.isArray(content.mentions)) { 13 | content.mentions.forEach(function (link) { 14 | if (link && link.link && link.type) { 15 | typeLookup[link.link] = link.type 16 | } 17 | if (link && link.name && link.link) { 18 | if (link.emoji) { 19 | // handle custom emoji 20 | emojiMentions[link.name] = link.link 21 | } else { 22 | // handle old-style patchwork v2 mentions (deprecated) 23 | mentions['@' + link.name] = link.link 24 | } 25 | } 26 | }) 27 | } 28 | 29 | return h('Markdown', { 30 | innerHTML: markdownRenderer.block(content.text, { 31 | emoji: (emoji) => { 32 | var url = emojiMentions[emoji] 33 | ? connection.blobUrl(emojiMentions[emoji]) 34 | : connection.emojiUrl(emoji) 35 | return renderEmoji(emoji, url) 36 | }, 37 | toUrl: (id) => { 38 | var link = ref.parseLink(id) 39 | if (link && ref.isBlob(link.link)) { 40 | var url = connection.blobUrl(link.link) 41 | var query = {} 42 | if (link.query && link.query.unbox) query['unbox'] = link.query.unbox 43 | if (typeLookup[link.link]) query['contentType'] = typeLookup[link.link] 44 | return url + '?' + QueryString.stringify(query) 45 | } else if (link || id.startsWith('#') || id.startsWith('?')) { 46 | return id 47 | } else if (mentions[id]) { 48 | // handle old-style patchwork v2 mentions (deprecated) 49 | return mentions[id] 50 | } 51 | return false 52 | }, 53 | imageLink: (id) => id 54 | }) 55 | }) 56 | } 57 | 58 | function renderEmoji (emoji, url) { 59 | if (!url) return ':' + emoji + ':' 60 | return ` 61 | :${htmlEscape(emoji)}: 67 | ` 68 | } 69 | -------------------------------------------------------------------------------- /views/message-header.js: -------------------------------------------------------------------------------- 1 | const image = require('./image') 2 | const person = require('./person') 3 | const timestamp = require('./timestamp') 4 | const { h } = require('mutant') 5 | 6 | module.exports = function renderMessageHeader (msg, {connection, i18n, priority, replyInfo, meta}) { 7 | var yourId = connection.id 8 | var additionalMeta = [] 9 | if (priority === 2) { 10 | additionalMeta.push(h('span.flag -new', {title: i18n('New Message')})) 11 | } else if (priority === 1) { 12 | additionalMeta.push(h('span.flag -unread', {title: i18n('Unread Message')})) 13 | } 14 | 15 | return h('header', [ 16 | h('div.main', [ 17 | h('a.avatar', {href: `${msg.value.author}`}, [ 18 | image(msg.value.author, {connection, i18n}) 19 | ]), 20 | h('div.main', [ 21 | h('div.name', [ 22 | person(msg.value.author, {connection, i18n}), 23 | msg.value.author === yourId ? [' ', h('span.you', {}, i18n('(you)'))] : null 24 | ]), 25 | h('div.meta', [ 26 | h('a', {href: msg.key}, [timestamp(msg, {i18n})]), ' ', 27 | replyInfo 28 | ]) 29 | ]) 30 | ]), 31 | h('div.meta', [ 32 | meta, additionalMeta 33 | ]) 34 | ]) 35 | } 36 | -------------------------------------------------------------------------------- /views/message.js: -------------------------------------------------------------------------------- 1 | const messageHeader = require('./message-header') 2 | const markdown = require('./markdown') 3 | const likes = require('./likes') 4 | const actions = require('./actions') 5 | const { h } = require('mutant') 6 | 7 | module.exports = function renderMessage (msg, {connection, i18n}) { 8 | return h('Message', { 9 | attributes: {'data-id': msg.key} 10 | }, [ 11 | messageHeader(msg, { 12 | connection, 13 | i18n, 14 | meta: meta(msg, {connection, i18n}) 15 | }), 16 | h('section', [ 17 | markdown(msg.value.content, {connection}) 18 | ]), 19 | h('footer', [ 20 | h('div.actions', [ 21 | actions(msg, {connection, i18n}) 22 | ]) 23 | ]) 24 | ]) 25 | } 26 | 27 | function meta (msg, {connection, i18n}) { 28 | var result = [] 29 | result.push(likes(msg, {connection, i18n})) 30 | const { channel } = msg.value.content 31 | if (channel && msg.value.content.type !== 'channel') { 32 | result.push(h('a.channel', {href: `#${channel}`}, [`#${channel}`])) 33 | } 34 | return result 35 | } 36 | -------------------------------------------------------------------------------- /views/person.js: -------------------------------------------------------------------------------- 1 | const { h } = require('mutant') 2 | module.exports = function person (id, {connection}) { 3 | var result = h('a', {href: id}, [id.slice(0, 10), '...']) 4 | connection.about(id, 'name', (err, name) => { 5 | if (err) return 6 | if (name) { 7 | result.innerText = name 8 | } 9 | }) 10 | return result 11 | } 12 | -------------------------------------------------------------------------------- /views/timestamp.js: -------------------------------------------------------------------------------- 1 | const humanTime = require('human-time') 2 | const getTimestamp = require('../lib/get-timestamp') 3 | 4 | module.exports = function timestamp (msg, {i18n}) { 5 | var timestamp = getTimestamp(msg) 6 | return humanTime(new Date(timestamp)).replace(/minute/, 'min').replace(/second/, 'sec') 7 | } 8 | --------------------------------------------------------------------------------