From 8654837f2f9c309e0b51241ab99d0862fb3cc2fb Mon Sep 17 00:00:00 2001 From: Niklas Wenzel Date: Thu, 4 Sep 2014 14:13:12 +0200 Subject: [PATCH] Initial version --- .excludes | 0 .gitignore | 1 + LICENSE.txt | 674 +++++++++++++++++++++++++++ Makefile | 21 + README.md | 29 ++ backend/ForumBackend.qml | 252 ++++++++++ backend/ForumConfigModel.qml | 79 ++++ forum-app.desktop | 7 + forum-app.json | 6 + forum-app.qml | 96 ++++ forum-app.qmlproject | 56 +++ graphics/contact.svg | 135 ++++++ graphics/spinner@30.png | Bin 0 -> 10092 bytes graphics/spinner@8.png | Bin 0 -> 1144 bytes icon.png | Bin 0 -> 34063 bytes manifest.json | 15 + md5utils.js | 290 ++++++++++++ sha1utils.js | 151 ++++++ stringutils.js | 118 +++++ ui/AboutPage.qml | 160 +++++++ ui/AddForumPage.qml | 154 ++++++ ui/ForumsListPage.qml | 187 ++++++++ ui/LoginPage.qml | 131 ++++++ ui/components/LabelVisual.qml | 38 ++ ui/components/Notification.qml | 85 ++++ ui/components/PageWithBottomEdge.qml | 398 ++++++++++++++++ ui/viewing/MessageDelegate.qml | 142 ++++++ ui/viewing/PostCreationPage.qml | 162 +++++++ ui/viewing/SubForumList.qml | 381 +++++++++++++++ ui/viewing/SubForumListItem.qml | 104 +++++ ui/viewing/SubForumPage.qml | 226 +++++++++ ui/viewing/ThreadCreationPage.qml | 168 +++++++ ui/viewing/ThreadList.qml | 163 +++++++ ui/viewing/ThreadPage.qml | 243 ++++++++++ 34 files changed, 4672 insertions(+) create mode 100644 .excludes create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/ForumBackend.qml create mode 100644 backend/ForumConfigModel.qml create mode 100644 forum-app.desktop create mode 100644 forum-app.json create mode 100644 forum-app.qml create mode 100644 forum-app.qmlproject create mode 100644 graphics/contact.svg create mode 100644 graphics/spinner@30.png create mode 100644 graphics/spinner@8.png create mode 100644 icon.png create mode 100644 manifest.json create mode 100644 md5utils.js create mode 100644 sha1utils.js create mode 100644 stringutils.js create mode 100644 ui/AboutPage.qml create mode 100644 ui/AddForumPage.qml create mode 100644 ui/ForumsListPage.qml create mode 100644 ui/LoginPage.qml create mode 100644 ui/components/LabelVisual.qml create mode 100644 ui/components/Notification.qml create mode 100644 ui/components/PageWithBottomEdge.qml create mode 100644 ui/viewing/MessageDelegate.qml create mode 100644 ui/viewing/PostCreationPage.qml create mode 100644 ui/viewing/SubForumList.qml create mode 100644 ui/viewing/SubForumListItem.qml create mode 100644 ui/viewing/SubForumPage.qml create mode 100644 ui/viewing/ThreadCreationPage.qml create mode 100644 ui/viewing/ThreadList.qml create mode 100644 ui/viewing/ThreadPage.qml diff --git a/.excludes b/.excludes new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7f881b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +forum-app.qmlproject.user diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..333e0c0 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +# More information: https://wiki.ubuntu.com/Touch/Testing +# +# Notes for autopilot tests: +# ----------------------------------------------------------- +# In order to run autopilot tests: +# sudo apt-add-repository ppa:autopilot/ppa +# sudo apt-get update +# sudo apt-get install python-autopilot autopilot-qt +############################################################# + +all: + +autopilot: + chmod +x tests/autopilot/run + tests/autopilot/run + +check: + qmltestrunner -input tests/unit + +run: + /usr/bin/qmlscene $@ forum-app.qml diff --git a/README.md b/README.md new file mode 100644 index 0000000..a033fb6 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +Forum Browser +============= + +Forum Browser for Ubuntu using the [Tapatalk API](https://tapatalk.com/api.php) + +**Currently supported features:** + +* Adding and saving forums +* Supports all forums which have the [Tapatalk plugin](https://tapatalk.com/partners.php) installed +* Viewing forums +* Forum login +* Thread creation +* Replying to threads + +Reporting bugs +============== + +Bugs can be reported either by sending an e-mail to nikwen.developer@gmail.com or by using our [bug tracker](https://github.com/nikwen/forum-app/issues). Please give us an example of a forum which is affected by the bug, so that we can reproduce it. + +Feature requests +================ + +Feature request are handled like bugs. A project member will then mark them as a feature request. + +Big thanks to +============= + +* Michael Hall for publishing the [XDA Developers app](https://code.launchpad.net/~xda-app-developers/xda-developers-app/trunk) under the GPL. This project initially started as a fork of that application. +* [Tapatalk](https://tapatalk.com) for allowing us to use their API. diff --git a/backend/ForumBackend.qml b/backend/ForumBackend.qml new file mode 100644 index 0000000..eb3eeae --- /dev/null +++ b/backend/ForumBackend.qml @@ -0,0 +1,252 @@ +/************************************************************************* +** Forum Browser +** +** Copyright (c) 2014 Niklas Wenzel +** +** $QT_BEGIN_LICENSE:GPL$ +** This program is free software; you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation; either version 2 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +** General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; see the file COPYING. If not, write to +** the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +** Boston, MA 02110-1301, USA. +** +** +** $QT_END_LICENSE$ +** +*************************************************************************/ + +import QtQuick 2.2 +import QtQuick.XmlListModel 2.0 +import Ubuntu.Components 1.1 +import Ubuntu.Components.Popups 1.0 +import U1db 1.0 as U1db +import "../md5utils.js" as Md5Utils +import "../sha1utils.js" as Sha1Utils +import "../stringutils.js" as StringUtils + +Object { + + signal loginDone(var session); + + property var sessions: [] + property var currentSession: (currentSessionIndex >= 0) ? sessions[currentSessionIndex] : undefined + property int currentSessionIndex: -1 + + property int postsPerPage: 10 + + U1db.Index { + database: db + id: by_url + expression: ["url", "user", "password"] + } + + U1db.Query { + id: loginQuery + index: by_url + query: [currentSession.forumUrl, "*", "*"] + + onResultsChanged: { //User changed login details in dialog + if (currentSession !== undefined && currentSession.configModel.hasLoaded) { + if (results[0].user !== "") { + login() + } else if (currentSession.loggedIn) { + logout(currentSession) + } + } + } + } + + Component { + id: sessionComponent + + Object { + id: session + + property string forumUrl: "" + property string apiSource: "" + property bool loginFinished: false + property bool loggedIn: false + property alias configModel: configModel + + ForumConfigModel { + id: configModel + + onHasLoadedChanged: { + if (hasLoaded && session === currentSession && !session.loggedIn) { + login() + } + } + + Component.onCompleted: { + loadConfig() + } + } + } + } + + function newSession(forumUrl, apiSource) { + var session = sessionComponent.createObject(mainView, {"forumUrl": forumUrl, "apiSource": apiSource}) + sessions.push(session) + currentSessionIndex = sessions.indexOf(session) + } + + function endSession(session) { + console.log("endSession") + var index = sessions.indexOf(session) + var saveCurrentSession + if (currentSessionIndex === index) { //end current session? + currentSessionIndex = -1 + } else { + saveCurrentSession = currentSession + } + logout(session) + sessions.splice(index, 1) //Remove session from list + if (currentSessionIndex != -1) { + currentSessionIndex = sessions.indexOf(saveCurrentSession) + } + } + + function login() { + var session = currentSession + if (loginQuery.results[0] !== undefined && loginQuery.results[0].user !== undefined && loginQuery.results[0].password !== undefined && loginQuery.results[0].user !== "" && loginQuery.results[0].password !== "") { + console.log("login") + var api = session.apiSource + session.loginFinished = false //do not set loggedIn to false => ability to change login data + var xhr = new XMLHttpRequest + xhr.open("POST", session.apiSource) + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { +// console.log(xhr.responseText) + console.log("logged in") + + if (xhr.status === 200) { + var resultIndex = xhr.responseText.indexOf("result"); + var booleanTag = xhr.responseText.indexOf("", resultIndex) + var booleanEndTag = xhr.responseText.indexOf("", resultIndex) + var result = xhr.responseText.substring(booleanTag + 9, booleanEndTag) + + var success = result === "1" + + if (success) { + session.loggedIn = true + session.loginFinished = true + loginDone(session) + if (session === currentSession) { + notification.show(qsTr(i18n.tr("Logged in as %1")).arg(loginQuery.results[0].user)) + } + } else { + var resultTextIndex = xhr.responseText.indexOf("result_text") + var resultText + if (resultTextIndex > 0) { + var base64Tag = xhr.responseText.indexOf("", resultTextIndex) + var base64EndTag = xhr.responseText.indexOf("", resultTextIndex) + resultText = StringUtils.base64_decode(xhr.responseText.substring(base64Tag + 8, base64EndTag)) + console.log(resultText) + } + var willLogOut = logout(session) + if (!willLogOut) { + session.loggedIn = false + session.loginFinished = true + loginDone(session) + } + var dialog = PopupUtils.open(errorDialog) + dialog.title = i18n.tr("Login failed") + if (resultText !== undefined) { + dialog.text = i18n.tr("Text returned by the server:\n") + resultText + } + } + } else { + if (session === currentSession) { + notification.show(i18n.tr("Connection error")) + } + } + } + } + var user = "" + if (loginQuery.results[0].user !== undefined) { + user = loginQuery.results[0].user + } + + var password = "" + if (session.configModel.get(0).support_md5) { + console.log("md5") + password = Md5Utils.md5(loginQuery.results[0].password) + } else if (session.configModel.get(0).support_sha1) { //Untested yet + console.log("sha1") + password = Sha1Utils.sha1(loginQuery.results[0].password) + } else { + console.log("no encryption") + password = loginQuery.results[0].password + } + + xhr.send('login'+StringUtils.base64_encode(user)+''+StringUtils.base64_encode(password)+''); + } else { + console.log("no login") + session.loginFinished = true + loginDone(session) + } + + } + + //Return value: If it will try to log out + function logout(session) { + if (session === undefined) { + return + } + + console.log("logout") + if (session.loggedIn) { + session.loginFinished = false; + var api = session.apiSource + var xhr = new XMLHttpRequest; + xhr.open("POST", session.apiSource); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (session.loggedIn) { //Set to false if another attempt to login has already started + console.log("logged out") + session.loginFinished = true + loginDone(session) + session.loggedIn = false + } + } + } + xhr.send('logout_user'); + return true + } else { + if (!session.loginFinished) { //Pressed back while still logging in => Logout after login finished + var otherLoginCount = 0 //A way to disconnect in case that the page was destroyed before login() has even been called + + var connectFunction = function(loginSession) { + console.log("connected function called") + if (loginSession === session) { + loginDone.disconnect(connectFunction) + logout(session) + console.log("logout in connected") + } else { + otherLoginCount++ + if (otherLoginCount >= 2) { + loginDone.disconnect(connectFunction) + } + } + } + + console.log("connect") + loginDone.connect(connectFunction) + } + + return false + } + } + + + +} diff --git a/backend/ForumConfigModel.qml b/backend/ForumConfigModel.qml new file mode 100644 index 0000000..fcf3a32 --- /dev/null +++ b/backend/ForumConfigModel.qml @@ -0,0 +1,79 @@ +/************************************************************************* +** Forum Browser +** +** Copyright (c) 2014 Niklas Wenzel +** +** $QT_BEGIN_LICENSE:GPL$ +** This program is free software; you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation; either version 2 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +** General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; see the file COPYING. If not, write to +** the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +** Boston, MA 02110-1301, USA. +** +** +** $QT_END_LICENSE$ +** +*************************************************************************/ + +import QtQuick 2.2 +import QtQuick.XmlListModel 2.0 +import Ubuntu.Components 1.1 +import "../stringutils.js" as StringUtils + +XmlListModel { + id: configModel + objectName: "configModel" + + query: "/methodResponse/params/param/value/struct" + + property bool hasLoaded: false + property bool isVBulletin: false + + XmlRole { name: "support_md5"; query: "member[name='support_md5']/value/string()" } + XmlRole { name: "support_sha1"; query: "member[name='support_sha1']/value/string()" } + XmlRole { name: "version"; query: "member[name='version']/value/string()" } + + onStatusChanged: { + if (status === XmlListModel.Ready) { + var element = get(0) + if (element.version.trim().indexOf("vb") === 0) { + isVBulletin = true + } + console.log("version: " + element.version.trim()) + + console.log("configModel has loaded") +// console.log(xml) + hasLoaded = true; + } + } + + function loadConfig() { + hasLoaded = false; + var xhr = new XMLHttpRequest; + configModel.xml=""; + xhr.open("POST", session.apiSource); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + configModel.xml = StringUtils.xmlFromResponse(xhr.responseText) + if (xhr.status !== 200 && session === currentSession) { + if (xhr.status === 404) { + notification.show(qsTr(i18n.tr("Error 404: Could not find forum"))) + } else { + notification.show(i18n.tr("Connection error")) + } + } + } + } + xhr.send('get_config'); + } + +} diff --git a/forum-app.desktop b/forum-app.desktop new file mode 100644 index 0000000..a05894b --- /dev/null +++ b/forum-app.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Name=Forum Browser +Exec=qmlscene $@ forum-app.qml +Icon=icon.png +Terminal=false +Type=Application +X-Ubuntu-Touch=true diff --git a/forum-app.json b/forum-app.json new file mode 100644 index 0000000..dfde901 --- /dev/null +++ b/forum-app.json @@ -0,0 +1,6 @@ +{ + "policy_groups": [ + "networking" + ], + "policy_version": 1.2 +} \ No newline at end of file diff --git a/forum-app.qml b/forum-app.qml new file mode 100644 index 0000000..cca4f46 --- /dev/null +++ b/forum-app.qml @@ -0,0 +1,96 @@ +/************************************************************************* +** Forum Browser +** +** Copyright (c) 2014 Niklas Wenzel +** +** $QT_BEGIN_LICENSE:GPL$ +** This program is free software; you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation; either version 2 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +** General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; see the file COPYING. If not, write to +** the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +** Boston, MA 02110-1301, USA. +** +** +** $QT_END_LICENSE$ +** +*************************************************************************/ + +import QtQuick 2.2 +import Ubuntu.Components 1.1 +import Ubuntu.Components.Popups 1.0 +import U1db 1.0 as U1db +import "ui" +import "ui/viewing" +import "ui/components" +import "backend" + +MainView { + id: mainView + + applicationName: "com.ubuntu.developer.nikwen.forum-app" + + width: units.gu(50) + height: units.gu(75) + + useDeprecatedToolbar: false + + U1db.Database { + id: db + path: "forums.u1db" + } + + U1db.Document { + id: forumsDocument + database: db + docId: 'xda-default' + create: true + defaults: { "name": "XDA Developers", "url": "forum.xda-developers.com", "user": "", "password": "" } + } + + PageStack { + id: pageStack + + Component.onCompleted: { + pageStack.push(forumsListPage); + } + } + + Notification { + id: notification + textColor: mainView.backgroundColor + } + + ForumsListPage { + id: forumsListPage + visible: false + } + + LoginPage { + id: loginPage + visible: false + } + + ForumBackend { + id: backend + } + + Component { + id: errorDialog + Dialog { + id: dialogue + Button { + text: i18n.tr("OK") + onClicked: PopupUtils.close(dialogue) + } + } + } +} diff --git a/forum-app.qmlproject b/forum-app.qmlproject new file mode 100644 index 0000000..29280e6 --- /dev/null +++ b/forum-app.qmlproject @@ -0,0 +1,56 @@ +/* File generated by Qt Creator (with Ubuntu Plugin), version 3.0.1 */ + +import QmlProject 1.1 + +Project { + mainFile: "forum-app.qml" + + /* Include .qml, .js, and image files from current directory and subdirectories */ + QmlFiles { + directory: "." + } + QmlFiles { + directory: "ui" + } + JavaScriptFiles { + directory: "." + } + ImageFiles { + directory: "." + } + Files { + filter: "*.desktop" + } + Files { + filter: "www/*.html" + } + Files { + filter: "Makefile" + } + Files { + directory: "www" + filter: "*" + } + Files { + directory: "www/img/" + filter: "*" + } + Files { + directory: "www/css/" + filter: "*" + } + Files { + directory: "www/js/" + filter: "*" + } + Files { + directory: "tests/" + filter: "*" + } + Files { + directory: "debian" + filter: "*" + } + /* List of plugin directories passed to QML runtime */ + importPaths: [ "." ,"/usr/bin","/usr/lib/x86_64-linux-gnu/qt5/qml" ] +} diff --git a/graphics/contact.svg b/graphics/contact.svg new file mode 100644 index 0000000..6e256c8 --- /dev/null +++ b/graphics/contact.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/graphics/spinner@30.png b/graphics/spinner@30.png new file mode 100644 index 0000000000000000000000000000000000000000..9a0f0b03581ba0ba1cbf18766cf0ef355403279b GIT binary patch literal 10092 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 KMNUMnLSTY^e+%{i literal 0 HcmV?d00001 diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..de28f0be61fea9586d3d716590dd43e470a08249 GIT binary patch literal 34063 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 literal 0 HcmV?d00001 diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..1db383f --- /dev/null +++ b/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "com.ubuntu.developer.nikwen.forum-app", + "description": "Forum Browser for Ubuntu", + "framework": "ubuntu-sdk-14.10-qml-dev3", + "architecture": "all", + "title": "Forum Browser", + "hooks": { + "forum-app": { + "apparmor": "forum-app.json", + "desktop": "forum-app.desktop" + } + }, + "version": "0.1.0", + "maintainer": "Niklas Wenzel " +} \ No newline at end of file diff --git a/md5utils.js b/md5utils.js new file mode 100644 index 0000000..ceaecb5 --- /dev/null +++ b/md5utils.js @@ -0,0 +1,290 @@ +.pragma library + +//Source: https://github.com/kvz/phpjs/blob/master/functions/strings/md5.js + +function md5(str) { + // discuss at: http://phpjs.org/functions/md5/ + // original by: Webtoolkit.info (http://www.webtoolkit.info/) + // improved by: Michael White (http://getsprink.com) + // improved by: Jack + // improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) + // input by: Brett Zamir (http://brett-zamir.me) + // bugfixed by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) + // depends on: utf8_encode + // example 1: md5('Kevin van Zonneveld'); + // returns 1: '6e658d4bfcb59cc13f96c14450ac40b9' + + var xl; + + var rotateLeft = function (lValue, iShiftBits) { + return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits)); + }; + + var addUnsigned = function (lX, lY) { + var lX4, lY4, lX8, lY8, lResult; + lX8 = (lX & 0x80000000); + lY8 = (lY & 0x80000000); + lX4 = (lX & 0x40000000); + lY4 = (lY & 0x40000000); + lResult = (lX & 0x3FFFFFFF) + (lY & 0x3FFFFFFF); + if (lX4 & lY4) { + return (lResult ^ 0x80000000 ^ lX8 ^ lY8); + } + if (lX4 | lY4) { + if (lResult & 0x40000000) { + return (lResult ^ 0xC0000000 ^ lX8 ^ lY8); + } else { + return (lResult ^ 0x40000000 ^ lX8 ^ lY8); + } + } else { + return (lResult ^ lX8 ^ lY8); + } + }; + + var _F = function (x, y, z) { + return (x & y) | ((~x) & z); + }; + var _G = function (x, y, z) { + return (x & z) | (y & (~z)); + }; + var _H = function (x, y, z) { + return (x ^ y ^ z); + }; + var _I = function (x, y, z) { + return (y ^ (x | (~z))); + }; + + var _FF = function (a, b, c, d, x, s, ac) { + a = addUnsigned(a, addUnsigned(addUnsigned(_F(b, c, d), x), ac)); + return addUnsigned(rotateLeft(a, s), b); + }; + + var _GG = function (a, b, c, d, x, s, ac) { + a = addUnsigned(a, addUnsigned(addUnsigned(_G(b, c, d), x), ac)); + return addUnsigned(rotateLeft(a, s), b); + }; + + var _HH = function (a, b, c, d, x, s, ac) { + a = addUnsigned(a, addUnsigned(addUnsigned(_H(b, c, d), x), ac)); + return addUnsigned(rotateLeft(a, s), b); + }; + + var _II = function (a, b, c, d, x, s, ac) { + a = addUnsigned(a, addUnsigned(addUnsigned(_I(b, c, d), x), ac)); + return addUnsigned(rotateLeft(a, s), b); + }; + + var convertToWordArray = function (str) { + var lWordCount; + var lMessageLength = str.length; + var lNumberOfWords_temp1 = lMessageLength + 8; + var lNumberOfWords_temp2 = (lNumberOfWords_temp1 - (lNumberOfWords_temp1 % 64)) / 64; + var lNumberOfWords = (lNumberOfWords_temp2 + 1) * 16; + var lWordArray = new Array(lNumberOfWords - 1); + var lBytePosition = 0; + var lByteCount = 0; + while (lByteCount < lMessageLength) { + lWordCount = (lByteCount - (lByteCount % 4)) / 4; + lBytePosition = (lByteCount % 4) * 8; + lWordArray[lWordCount] = (lWordArray[lWordCount] | (str.charCodeAt(lByteCount) << lBytePosition)); + lByteCount++; + } + lWordCount = (lByteCount - (lByteCount % 4)) / 4; + lBytePosition = (lByteCount % 4) * 8; + lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition); + lWordArray[lNumberOfWords - 2] = lMessageLength << 3; + lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29; + return lWordArray; + }; + + var wordToHex = function (lValue) { + var wordToHexValue = '', + wordToHexValue_temp = '', + lByte, lCount; + for (lCount = 0; lCount <= 3; lCount++) { + //NOTE: Changed shift operator here, otherwise it would occasionally fail on my Nexus 4 + lByte = (lValue >> (lCount * 8)) & 255; + wordToHexValue_temp = '0' + lByte.toString(16); + wordToHexValue = wordToHexValue + wordToHexValue_temp.substr(wordToHexValue_temp.length - 2, 2); + } + return wordToHexValue; + }; + + var x = [], + k, AA, BB, CC, DD, a, b, c, d, S11 = 7, + S12 = 12, + S13 = 17, + S14 = 22, + S21 = 5, + S22 = 9, + S23 = 14, + S24 = 20, + S31 = 4, + S32 = 11, + S33 = 16, + S34 = 23, + S41 = 6, + S42 = 10, + S43 = 15, + S44 = 21; + + str = utf8_encode(str); + x = convertToWordArray(str); + a = 0x67452301; + b = 0xEFCDAB89; + c = 0x98BADCFE; + d = 0x10325476; + + xl = x.length; + for (k = 0; k < xl; k += 16) { + AA = a; + BB = b; + CC = c; + DD = d; + a = _FF(a, b, c, d, x[k + 0], S11, 0xD76AA478); + d = _FF(d, a, b, c, x[k + 1], S12, 0xE8C7B756); + c = _FF(c, d, a, b, x[k + 2], S13, 0x242070DB); + b = _FF(b, c, d, a, x[k + 3], S14, 0xC1BDCEEE); + a = _FF(a, b, c, d, x[k + 4], S11, 0xF57C0FAF); + d = _FF(d, a, b, c, x[k + 5], S12, 0x4787C62A); + c = _FF(c, d, a, b, x[k + 6], S13, 0xA8304613); + b = _FF(b, c, d, a, x[k + 7], S14, 0xFD469501); + a = _FF(a, b, c, d, x[k + 8], S11, 0x698098D8); + d = _FF(d, a, b, c, x[k + 9], S12, 0x8B44F7AF); + c = _FF(c, d, a, b, x[k + 10], S13, 0xFFFF5BB1); + b = _FF(b, c, d, a, x[k + 11], S14, 0x895CD7BE); + a = _FF(a, b, c, d, x[k + 12], S11, 0x6B901122); + d = _FF(d, a, b, c, x[k + 13], S12, 0xFD987193); + c = _FF(c, d, a, b, x[k + 14], S13, 0xA679438E); + b = _FF(b, c, d, a, x[k + 15], S14, 0x49B40821); + a = _GG(a, b, c, d, x[k + 1], S21, 0xF61E2562); + d = _GG(d, a, b, c, x[k + 6], S22, 0xC040B340); + c = _GG(c, d, a, b, x[k + 11], S23, 0x265E5A51); + b = _GG(b, c, d, a, x[k + 0], S24, 0xE9B6C7AA); + a = _GG(a, b, c, d, x[k + 5], S21, 0xD62F105D); + d = _GG(d, a, b, c, x[k + 10], S22, 0x2441453); + c = _GG(c, d, a, b, x[k + 15], S23, 0xD8A1E681); + b = _GG(b, c, d, a, x[k + 4], S24, 0xE7D3FBC8); + a = _GG(a, b, c, d, x[k + 9], S21, 0x21E1CDE6); + d = _GG(d, a, b, c, x[k + 14], S22, 0xC33707D6); + c = _GG(c, d, a, b, x[k + 3], S23, 0xF4D50D87); + b = _GG(b, c, d, a, x[k + 8], S24, 0x455A14ED); + a = _GG(a, b, c, d, x[k + 13], S21, 0xA9E3E905); + d = _GG(d, a, b, c, x[k + 2], S22, 0xFCEFA3F8); + c = _GG(c, d, a, b, x[k + 7], S23, 0x676F02D9); + b = _GG(b, c, d, a, x[k + 12], S24, 0x8D2A4C8A); + a = _HH(a, b, c, d, x[k + 5], S31, 0xFFFA3942); + d = _HH(d, a, b, c, x[k + 8], S32, 0x8771F681); + c = _HH(c, d, a, b, x[k + 11], S33, 0x6D9D6122); + b = _HH(b, c, d, a, x[k + 14], S34, 0xFDE5380C); + a = _HH(a, b, c, d, x[k + 1], S31, 0xA4BEEA44); + d = _HH(d, a, b, c, x[k + 4], S32, 0x4BDECFA9); + c = _HH(c, d, a, b, x[k + 7], S33, 0xF6BB4B60); + b = _HH(b, c, d, a, x[k + 10], S34, 0xBEBFBC70); + a = _HH(a, b, c, d, x[k + 13], S31, 0x289B7EC6); + d = _HH(d, a, b, c, x[k + 0], S32, 0xEAA127FA); + c = _HH(c, d, a, b, x[k + 3], S33, 0xD4EF3085); + b = _HH(b, c, d, a, x[k + 6], S34, 0x4881D05); + a = _HH(a, b, c, d, x[k + 9], S31, 0xD9D4D039); + d = _HH(d, a, b, c, x[k + 12], S32, 0xE6DB99E5); + c = _HH(c, d, a, b, x[k + 15], S33, 0x1FA27CF8); + b = _HH(b, c, d, a, x[k + 2], S34, 0xC4AC5665); + a = _II(a, b, c, d, x[k + 0], S41, 0xF4292244); + d = _II(d, a, b, c, x[k + 7], S42, 0x432AFF97); + c = _II(c, d, a, b, x[k + 14], S43, 0xAB9423A7); + b = _II(b, c, d, a, x[k + 5], S44, 0xFC93A039); + a = _II(a, b, c, d, x[k + 12], S41, 0x655B59C3); + d = _II(d, a, b, c, x[k + 3], S42, 0x8F0CCC92); + c = _II(c, d, a, b, x[k + 10], S43, 0xFFEFF47D); + b = _II(b, c, d, a, x[k + 1], S44, 0x85845DD1); + a = _II(a, b, c, d, x[k + 8], S41, 0x6FA87E4F); + d = _II(d, a, b, c, x[k + 15], S42, 0xFE2CE6E0); + c = _II(c, d, a, b, x[k + 6], S43, 0xA3014314); + b = _II(b, c, d, a, x[k + 13], S44, 0x4E0811A1); + a = _II(a, b, c, d, x[k + 4], S41, 0xF7537E82); + d = _II(d, a, b, c, x[k + 11], S42, 0xBD3AF235); + c = _II(c, d, a, b, x[k + 2], S43, 0x2AD7D2BB); + b = _II(b, c, d, a, x[k + 9], S44, 0xEB86D391); + a = addUnsigned(a, AA); + b = addUnsigned(b, BB); + c = addUnsigned(c, CC); + d = addUnsigned(d, DD); + } + + var temp = wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d); + + return temp.toLowerCase(); +} + +//Source: https://github.com/kvz/phpjs/blob/master/functions/xml/utf8_encode.js + +function utf8_encode(argString) { + // discuss at: http://phpjs.org/functions/utf8_encode/ + // original by: Webtoolkit.info (http://www.webtoolkit.info/) + // improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) + // improved by: sowberry + // improved by: Jack + // improved by: Yves Sucaet + // improved by: kirilloid + // bugfixed by: Onno Marsman + // bugfixed by: Onno Marsman + // bugfixed by: Ulrich + // bugfixed by: Rafal Kukawski + // bugfixed by: kirilloid + // example 1: utf8_encode('Kevin van Zonneveld'); + // returns 1: 'Kevin van Zonneveld' + + if (argString === null || typeof argString === 'undefined') { + return ''; + } + + // .replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + var string = (argString + ''); + var utftext = '', + start, end, stringl = 0; + + start = end = 0; + stringl = string.length; + for (var n = 0; n < stringl; n++) { + var c1 = string.charCodeAt(n); + var enc = null; + + if (c1 < 128) { + end++; + } else if (c1 > 127 && c1 < 2048) { + enc = String.fromCharCode( + (c1 >> 6) | 192, (c1 & 63) | 128 + ); + } else if ((c1 & 0xF800) != 0xD800) { + enc = String.fromCharCode( + (c1 >> 12) | 224, ((c1 >> 6) & 63) | 128, (c1 & 63) | 128 + ); + } else { + // surrogate pairs + if ((c1 & 0xFC00) != 0xD800) { + throw new RangeError('Unmatched trail surrogate at ' + n); + } + var c2 = string.charCodeAt(++n); + if ((c2 & 0xFC00) != 0xDC00) { + throw new RangeError('Unmatched lead surrogate at ' + (n - 1)); + } + c1 = ((c1 & 0x3FF) << 10) + (c2 & 0x3FF) + 0x10000; + enc = String.fromCharCode( + (c1 >> 18) | 240, ((c1 >> 12) & 63) | 128, ((c1 >> 6) & 63) | 128, (c1 & 63) | 128 + ); + } + if (enc !== null) { + if (end > start) { + utftext += string.slice(start, end); + } + utftext += enc; + start = end = n + 1; + } + } + + if (end > start) { + utftext += string.slice(start, stringl); + } + + return utftext; +} diff --git a/sha1utils.js b/sha1utils.js new file mode 100644 index 0000000..29efd93 --- /dev/null +++ b/sha1utils.js @@ -0,0 +1,151 @@ +.pragma library + +//Source: https://github.com/kvz/phpjs/blob/master/functions/strings/sha1.js + +function sha1(str) { + // discuss at: http://phpjs.org/functions/sha1/ + // original by: Webtoolkit.info (http://www.webtoolkit.info/) + // improved by: Michael White (http://getsprink.com) + // improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) + // input by: Brett Zamir (http://brett-zamir.me) + // example 1: sha1('Kevin van Zonneveld'); + // returns 1: '54916d2e62f65b3afa6e192e6a601cdbe5cb5897' + + var rotate_left = function (n, s) { + var t4 = (n << s) | (n >>> (32 - s)); + return t4; + }; + + /*var lsb_hex = function (val) { + // Not in use; needed? + var str=""; + var i; + var vh; + var vl; + + for ( i=0; i<=6; i+=2 ) { + vh = (val>>>(i*4+4))&0x0f; + vl = (val>>>(i*4))&0x0f; + str += vh.toString(16) + vl.toString(16); + } + return str; + };*/ + + var cvt_hex = function (val) { + var str = ''; + var i; + var v; + + for (i = 7; i >= 0; i--) { + v = (val >>> (i * 4)) & 0x0f; + str += v.toString(16); + } + return str; + }; + + var blockstart; + var i, j; + var W = new Array(80); + var H0 = 0x67452301; + var H1 = 0xEFCDAB89; + var H2 = 0x98BADCFE; + var H3 = 0x10325476; + var H4 = 0xC3D2E1F0; + var A, B, C, D, E; + var temp; + + // utf8_encode + str = unescape(encodeURIComponent(str)); + var str_len = str.length; + + var word_array = []; + for (i = 0; i < str_len - 3; i += 4) { + j = str.charCodeAt(i) << 24 | str.charCodeAt(i + 1) << 16 | str.charCodeAt(i + 2) << 8 | str.charCodeAt(i + 3); + word_array.push(j); + } + + switch (str_len % 4) { + case 0: + i = 0x080000000; + break; + case 1: + i = str.charCodeAt(str_len - 1) << 24 | 0x0800000; + break; + case 2: + i = str.charCodeAt(str_len - 2) << 24 | str.charCodeAt(str_len - 1) << 16 | 0x08000; + break; + case 3: + i = str.charCodeAt(str_len - 3) << 24 | str.charCodeAt(str_len - 2) << 16 | str.charCodeAt(str_len - 1) << + 8 | 0x80; + break; + } + + word_array.push(i); + + while ((word_array.length % 16) != 14) { + word_array.push(0); + } + + word_array.push(str_len >>> 29); + word_array.push((str_len << 3) & 0x0ffffffff); + + for (blockstart = 0; blockstart < word_array.length; blockstart += 16) { + for (i = 0; i < 16; i++) { + W[i] = word_array[blockstart + i]; + } + for (i = 16; i <= 79; i++) { + W[i] = rotate_left(W[i - 3] ^ W[i - 8] ^ W[i - 14] ^ W[i - 16], 1); + } + + A = H0; + B = H1; + C = H2; + D = H3; + E = H4; + + for (i = 0; i <= 19; i++) { + temp = (rotate_left(A, 5) + ((B & C) | (~B & D)) + E + W[i] + 0x5A827999) & 0x0ffffffff; + E = D; + D = C; + C = rotate_left(B, 30); + B = A; + A = temp; + } + + for (i = 20; i <= 39; i++) { + temp = (rotate_left(A, 5) + (B ^ C ^ D) + E + W[i] + 0x6ED9EBA1) & 0x0ffffffff; + E = D; + D = C; + C = rotate_left(B, 30); + B = A; + A = temp; + } + + for (i = 40; i <= 59; i++) { + temp = (rotate_left(A, 5) + ((B & C) | (B & D) | (C & D)) + E + W[i] + 0x8F1BBCDC) & 0x0ffffffff; + E = D; + D = C; + C = rotate_left(B, 30); + B = A; + A = temp; + } + + for (i = 60; i <= 79; i++) { + temp = (rotate_left(A, 5) + (B ^ C ^ D) + E + W[i] + 0xCA62C1D6) & 0x0ffffffff; + E = D; + D = C; + C = rotate_left(B, 30); + B = A; + A = temp; + } + + H0 = (H0 + A) & 0x0ffffffff; + H1 = (H1 + B) & 0x0ffffffff; + H2 = (H2 + C) & 0x0ffffffff; + H3 = (H3 + D) & 0x0ffffffff; + H4 = (H4 + E) & 0x0ffffffff; + } + + temp = cvt_hex(H0) + cvt_hex(H1) + cvt_hex(H2) + cvt_hex(H3) + cvt_hex(H4); + return temp.toLowerCase(); +} diff --git a/stringutils.js b/stringutils.js new file mode 100644 index 0000000..4e00654 --- /dev/null +++ b/stringutils.js @@ -0,0 +1,118 @@ +.pragma library + +function xmlFromResponse(response) { + return response.substring(response.indexOf("> 16 & 0xff; + o2 = bits >> 8 & 0xff; + o3 = bits & 0xff; + + if (h3 == 64) { + tmp_arr[ac++] = String.fromCharCode(o1); + } else if (h4 == 64) { + tmp_arr[ac++] = String.fromCharCode(o1, o2); + } else { + tmp_arr[ac++] = String.fromCharCode(o1, o2, o3); + } + } while (i < data.length); + + dec = tmp_arr.join(''); + + return decodeURIComponent(escape(dec.replace(/\0+$/, ''))); +} + +//Source: https://github.com/kvz/phpjs/blob/master/functions/url/base64_encode.js + +function base64_encode(data) { + // discuss at: http://phpjs.org/functions/base64_encode/ + // original by: Tyler Akins (http://rumkin.com) + // improved by: Bayron Guevara + // improved by: Thunder.m + // improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) + // improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) + // improved by: Rafał Kukawski (http://kukawski.pl) + // bugfixed by: Pellentesque Malesuada + // example 1: base64_encode('Kevin van Zonneveld'); + // returns 1: 'S2V2aW4gdmFuIFpvbm5ldmVsZA==' + // example 2: base64_encode('a'); + // returns 2: 'YQ==' + // example 3: base64_encode('✓ à la mode'); + // returns 3: '4pyTIMOgIGxhIG1vZGU=' + + var b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + var o1, o2, o3, h1, h2, h3, h4, bits, i = 0, + ac = 0, + enc = '', + tmp_arr = []; + + if (!data) { + return data; + } + + data = unescape(encodeURIComponent(data)) + + do { + // pack three octets into four hexets + o1 = data.charCodeAt(i++); + o2 = data.charCodeAt(i++); + o3 = data.charCodeAt(i++); + + bits = o1 << 16 | o2 << 8 | o3; + + h1 = bits >> 18 & 0x3f; + h2 = bits >> 12 & 0x3f; + h3 = bits >> 6 & 0x3f; + h4 = bits & 0x3f; + + // use hexets to index into b64, and append result to encoded string + tmp_arr[ac++] = b64.charAt(h1) + b64.charAt(h2) + b64.charAt(h3) + b64.charAt(h4); + } while (i < data.length); + + enc = tmp_arr.join(''); + + var r = data.length % 3; + + return (r ? enc.slice(0, r - 3) : enc) + '==='.slice(r || 3); +} diff --git a/ui/AboutPage.qml b/ui/AboutPage.qml new file mode 100644 index 0000000..2ceb260 --- /dev/null +++ b/ui/AboutPage.qml @@ -0,0 +1,160 @@ +/************************************************************************* +** Forum Browser +** +** Copyright (c) 2014 Niklas Wenzel +** +** $QT_BEGIN_LICENSE:GPL$ +** This program is free software; you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation; either version 2 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +** General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; see the file COPYING. If not, write to +** the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +** Boston, MA 02110-1301, USA. +** +** +** $QT_END_LICENSE$ +** +*************************************************************************/ + +import QtQuick 2.0 +import Ubuntu.Components 1.1 +import Ubuntu.Components.ListItems 1.0 as ListItem + +Page { + id: aboutPage + title: i18n.tr("About Forum Browser") + visible:false + + Flickable { + id: flickable + anchors.fill: parent + clip: true + + contentHeight: aboutColumn.height + aboutColumn.marginTop + + Column { + id: aboutColumn + width: parent.width + property real marginTop: units.gu(3) + y: marginTop + + UbuntuShape { + property real maxWidth: units.gu(45) + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(parent.width, maxWidth)/2 + height: Math.min(parent.width, maxWidth)/2 + image: Image { + source: "../icon.png" + smooth: true + fillMode: Image.PreserveAspectFit + } + } + + Item { + id: spacer + width: parent.width + height: units.gu(2) + } + + ListItem.Header { + text: i18n.tr("Info:") + } + + ListItem.Standard { + text: i18n.tr("Version:") + control: Label { + text: "0.1.0" + } + } + + ListItem.Header { + text: i18n.tr("Development:") + } + + ListItem.Standard { + text: i18n.tr("License:") + control: Label { + text: "GPL v3" + } + progression: true + onClicked: Qt.openUrlExternally("http://www.gnu.org/licenses/gpl-3.0.txt") + } + + ListItem.Standard { + text: i18n.tr("Source code & bug tracker:") + control: Label { + text: i18n.tr("Github") + } + progression: true + onClicked: Qt.openUrlExternally("https://github.com/nikwen/forum-app") + } + + ListItem.Standard { + text: i18n.tr("Uses code from:") + control: Label { + text: i18n.tr("phpjs (MIT License)") + } + progression: true + onClicked: Qt.openUrlExternally("https://github.com/kvz/phpjs") + } + + ListItem.Header { + text: i18n.tr("Authors:") + } + + ListItem.Standard { + text: "Niklas Wenzel" + control: Label { + text: i18n.tr("Maintainer") + } + } + + ListItem.Standard { + text: "Michael Hall" + control: Label { + text: i18n.tr("XDA Developers app") + } + } + + ListItem.Header { + text: i18n.tr("Contact") + } + + ListItem.Standard { + text: "nikwen.developer@gmail.com" + progression: true + onClicked: Qt.openUrlExternally("mailto:nikwen.developer@gmail.com") + } + + ListItem.Standard { + text: i18n.tr("XDA Developers thread") + progression: true + onClicked: Qt.openUrlExternally("http://forum.xda-developers.com/ubuntu-touch/apps-games/app-forum-browser-0-1-0-t2867227") //TODO: Open in app + } + + ListItem.Empty { + id: poweredByTapatalkItem + width: parent.width + height: units.gu(9) + divider.visible: false + + onClicked: Qt.openUrlExternally("https://tapatalk.com") + + Label { + id: poweredLabel + font.bold: true + text: i18n.tr("Powered by Tapatalk") + anchors.centerIn: parent + } + } + } + } +} diff --git a/ui/AddForumPage.qml b/ui/AddForumPage.qml new file mode 100644 index 0000000..0645262 --- /dev/null +++ b/ui/AddForumPage.qml @@ -0,0 +1,154 @@ +/************************************************************************* +** Forum Browser +** +** Copyright (c) 2014 Niklas Wenzel +** +** $QT_BEGIN_LICENSE:GPL$ +** This program is free software; you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation; either version 2 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +** General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; see the file COPYING. If not, write to +** the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +** Boston, MA 02110-1301, USA. +** +** +** $QT_END_LICENSE$ +** +*************************************************************************/ + +import QtQuick 2.2 +import Ubuntu.Components 1.1 +import U1db 1.0 as U1db + +Page { + id: addForumPage + title: (docId !== "") ? i18n.tr("Edit Forum") : i18n.tr("Add Forum") + + property string docId: "" + onDocIdChanged: { + if (docId !== "") { + var doc = db.getDoc(docId) + nameTextField.text = doc["name"] + urlTextField.text = doc["url"] + } + } + + head.actions: [ + Action { + id: save + iconName: "ok" + text: docId !== ""?i18n.tr("Edit"):i18n.tr("Add") + onTriggered: { + if (nameTextField.text !== "" && urlTextField.text !== "") { + //Remove protocol from url (so that there are no multiple instances of one forum but with different prefixes) + var url = urlTextField.text + var pos = url.indexOf("://") + if (pos === 4 || pos === 5) { + url = url.substring(pos + 3) + } + + //Check if name or url already exist + var docs = db.listDocs() + for (var d in docs) { + if (docs[d] !== docId) { + var contents = db.getDoc(docs[d]) + if (contents["name"] === nameTextField.text) { + notification.show(i18n.tr("Error: Name already exists")) + return + } else if (contents["url"] === url) { + notification.show(i18n.tr("Error: Url already exists")) + return + } + } + } + + if (docId !== "") { + var doc = db.getDoc(docId) + doc["name"] = nameTextField.text + doc["url"] = url + db.putDoc(doc, docId) + docId = "" + } else { + db.putDoc({ name: nameTextField.text, url: url }) + } + + pageStack.pop() + } else { + if (nameTextField.text.trim() === "") { + notification.show(i18n.tr("Error: Name is empty")) + } else if (urlTextField.text.trim() === "") { + notification.show(i18n.tr("Error: Url is empty")) + } + } + } + } + ] + + head.backAction: Action { + id: dismissOption + text: i18n.tr("Dismiss") + iconName: "close" + onTriggered: pageStack.pop() + } + + Column { + id: column + spacing: units.gu(1) + anchors.fill: parent + anchors.margins: units.gu(2) + + Label { + id: nameLabel + text: i18n.tr("Forum-Name:") + anchors { + left: column.left; + right: column.right; + } + + fontSize: "medium" + } + + TextField { + id: nameTextField + anchors { + left: column.left; + right: column.right; + } + + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: urlTextField + } + + Label { + id: urlLabel + text: i18n.tr("Forum-Url:") + anchors { + left: column.left; + right: column.right; + } + + fontSize: "medium" + } + + TextField { + id: urlTextField + anchors { + left: column.left; + right: column.right; + } + + inputMethodHints: Qt.ImhUrlCharactersOnly + + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.backtab: nameTextField + } + } +} diff --git a/ui/ForumsListPage.qml b/ui/ForumsListPage.qml new file mode 100644 index 0000000..7b7035f --- /dev/null +++ b/ui/ForumsListPage.qml @@ -0,0 +1,187 @@ +/************************************************************************* +** Forum Browser +** +** Copyright (c) 2014 Niklas Wenzel +** +** $QT_BEGIN_LICENSE:GPL$ +** This program is free software; you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation; either version 2 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +** General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; see the file COPYING. If not, write to +** the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +** Boston, MA 02110-1301, USA. +** +** +** $QT_END_LICENSE$ +** +*************************************************************************/ + +import QtQuick 2.2 +import Ubuntu.Components 1.1 +import U1db 1.0 as U1db +import Ubuntu.Components.ListItems 1.0 as ListItem + +Page { + id: forumsListPage + title: i18n.tr("My Forums") + + head.actions: [ + Action { + id: addAction + text: i18n.tr("Add Forum") + iconName: "add" + onTriggered: pageStack.push(Qt.resolvedUrl("AddForumPage.qml")) + }, + Action { + id: aboutAction + text: i18n.tr("About") + iconName: "info" + onTriggered: pageStack.push(Qt.resolvedUrl("AboutPage.qml")) + } + + ] + + U1db.Index { + database: db + id: by_forum + expression: ["name", "url"] + } + U1db.Query { + id: forums + index: by_forum + query: ["*", "*"] + } + + ListView { + id: listView + model: sortedList + clip: true + + anchors { + top: parent.top + left: parent.left + right: parent.right + bottom: poweredByTapatalkItem.top + } + + + property var sortedList: sort(forums.results) + + delegate: ListItem.Standard { + text: model.modelData.name + + progression: true + removable: true + confirmRemoval: true + + onItemRemoved: { + db.deleteDoc(forums.documents[listView.mapSortedIndexToUnsorted(index)]) + } + + property Component component + + onClicked: { + var prefix = model.modelData.url.indexOf("http://") === 0 || model.modelData.url.indexOf("https://") === 0 + var apiSource = (!prefix?"http://":"") + model.modelData.url + "/mobiquo/mobiquo.php" + var currentForumUrl = model.modelData.url + backend.newSession(currentForumUrl, apiSource) + + pushPage() + } + + function pushPage() { + component = Qt.createComponent("viewing/SubForumPage.qml"); + + if (component.status === Component.Ready) { + finishCreation(); + } else { + console.log(component.errorString()) + component.statusChanged.connect(finishCreation); + } + } + + function finishCreation() { + var page = component.createObject(mainView, {"current_forum": 0, "title": text}) + if (page === null) console.log(component.errorString()) + pageStack.push(page) + page.loadingSpinnerRunning = true + } + + onPressAndHold: { + pageStack.push(Qt.resolvedUrl("AddForumPage.qml"), {"docId": forums.documents[listView.mapSortedIndexToUnsorted(index)]}) + } + } + + function mapSortedIndexToUnsorted(index) { + //name is unique so we can search for it + var searchFor = sortedList[index].name + for (var i = 0; i < forums.results.length; i++) { + if (forums.results[i].name === searchFor) { + return i + } + } + } + + function sort(list) { + //Selection sort + + for (var i = 0; i < list.length; i++) { + var minIndex = i + for (var j = i; j < list.length; j++) { + if (list[j].name.toLowerCase() < list[minIndex].name.toLowerCase()) { //Case insensitive + minIndex = j + } + } + var temp = list[i] + list[i] = list[minIndex] + list[minIndex] = temp + } + + return list + } + } + + Label { + id: addForumLabel + text: i18n.tr("Swipe up from the bottom to add a forum") + visible: listView.count === 0 + elide: Text.ElideRight + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + fontSize: "large" + anchors { + verticalCenter: listView.verticalCenter + left: parent.left + right: parent.right + margins: units.gu(2) + } + } + + ListItem.Empty { + id: poweredByTapatalkItem + anchors.bottom: parent.bottom + width: parent.width + divider.visible: false + + onClicked: Qt.openUrlExternally("https://tapatalk.com") + + ListItem.ThinDivider { + anchors.top: parent.top + width: parent.width + } + + Label { + text: i18n.tr("Powered by Tapatalk") + anchors.centerIn: parent + } + } + +} diff --git a/ui/LoginPage.qml b/ui/LoginPage.qml new file mode 100644 index 0000000..f72311f --- /dev/null +++ b/ui/LoginPage.qml @@ -0,0 +1,131 @@ +/************************************************************************* +** Forum Browser +** +** Copyright (c) 2014 Niklas Wenzel +** +** $QT_BEGIN_LICENSE:GPL$ +** This program is free software; you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation; either version 2 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +** General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; see the file COPYING. If not, write to +** the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +** Boston, MA 02110-1301, USA. +** +** +** $QT_END_LICENSE$ +** +*************************************************************************/ + +import QtQuick 2.2 +import Ubuntu.Components 1.1 +import U1db 1.0 as U1db + +Page { + id: sheet + title: qsTr("Login to %1").arg(loginQuery.results[0].name) + + head.backAction: Action { + id: cancelAction + text: i18n.tr("Cancel") + iconName: "close" + onTriggered: pageStack.pop() + } + + head.actions: [ + Action { + id: loginAction + text: i18n.tr("Login") + iconName: "ok" + + onTriggered: { + var doc = db.getDoc(loginQuery.documents[0]) + doc["user"] = nameTextField.text + doc["password"] = passwordTextField.text + db.putDoc(doc, loginQuery.documents[0]) + + pageStack.pop() + } + } + + ] + + U1db.Index { + database: db + id: by_forum + expression: ["name", "url", "user", "password"] + } + + U1db.Query { + id: loginQuery + index: by_forum + query: ["*", backend.currentSession.forumUrl, "*", "*"] + + onResultsChanged: { + if (results[0] !== undefined) { + if (results[0].user !== undefined) nameTextField.text = results[0].user + if (results[0].password !== undefined) passwordTextField.text = results[0].password + } + } + } + + Column { + id: column + spacing: units.gu(1) + anchors.fill: parent + anchors.margins: units.gu(2) + + Label { + id: nameLabel + text: i18n.tr("User-Name:") + anchors { + left: column.left; + right: column.right; + } + + fontSize: "medium" + } + + TextField { + id: nameTextField + anchors { + left: column.left; + right: column.right; + } + + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: passwordTextField + } + + Label { + id: passwordLabel + text: i18n.tr("Password:") + anchors { + left: column.left; + right: column.right; + } + + fontSize: "medium" + } + + TextField { + id: passwordTextField + anchors { + left: column.left; + right: column.right; + } + + echoMode: TextInput.Password + + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.backtab: nameTextField + } + } +} diff --git a/ui/components/LabelVisual.qml b/ui/components/LabelVisual.qml new file mode 100644 index 0000000..ba24ea5 --- /dev/null +++ b/ui/components/LabelVisual.qml @@ -0,0 +1,38 @@ +/* + * Copyright 2012 Canonical Ltd. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +import QtQuick 2.0 +import Ubuntu.Components 0.1 + +// internal helper class for text inside the list items. +Label { + id: label + property bool selected: false + property bool secondary: false + + // FIXME: very ugly hack to detect whether the list item is inside a Popover + property bool overlay: isInsideOverlay(label) + function isInsideOverlay(item) { + if (!item.parent) return false; + return item.parent.hasOwnProperty("pointerTarget") || label.isInsideOverlay(item.parent) + } + + fontSize: "medium" + elide: Text.ElideRight + color: selected ? UbuntuColors.orange : secondary ? overlay ? Theme.palette.normal.overlayText : Theme.palette.normal.backgroundText + : overlay ? Theme.palette.selected.overlayText : Theme.palette.selected.backgroundText + opacity: label.enabled ? 1.0 : 0.5 +} diff --git a/ui/components/Notification.qml b/ui/components/Notification.qml new file mode 100644 index 0000000..34bd348 --- /dev/null +++ b/ui/components/Notification.qml @@ -0,0 +1,85 @@ +import QtQuick 2.0 +import Ubuntu.Components 1.1 + +//Source: https://raw.githubusercontent.com/iBeliever/ubuntu-ui-extras/master/Notification.qml + +Rectangle { + id: notification + + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + margins: (mainView.useDeprecatedToolbar && toolbar.opened && !toolbar.locked ? toolbar.height : 0) + units.gu(2) + ((!mainView.anchorToKeyboard && Qt.inputMethod.visible) ? Qt.inputMethod.keyboardRectangle.height : 0) + } + + height: label.height + units.gu(3) + width: label.width + units.gu(4.5) + radius: height/2 + color: Qt.rgba(0,0,0,0.7) + + opacity: showing ? 1 : 0 + + Behavior on opacity { + UbuntuNumberAnimation {} + } + + property bool showing: false + property string text + property MainView mainView + property var queue: [] + property alias textColor: label.color + + Component.onCompleted: mainView = findMainView() //This cannot be done as a property binding because the method will later return the QQuickRootItem. + + function show(text) { + queue.push(text) + if (!showing) { + update() + } + } + + function update() { + notification.text = queue.pop() + notification.showing = true + } + + onShowingChanged: { + if (showing) { + timer.restart() + } else { + if (queue.length > 0) { + timer.interval = 800 + timer.restart() + } + } + } + + Label { + id: label + anchors.centerIn: parent + text: notification.text + fontSize: "medium" + } + + Timer { + id: timer + interval: 2000 + onTriggered: { + if (interval === 2000) { + showing = false + } else { + interval = 2000 + update() + } + } + } + + function findMainView() { + var up = parent + while (up.parent !== null) { + up = up.parent + } + return up + } +} + diff --git a/ui/components/PageWithBottomEdge.qml b/ui/components/PageWithBottomEdge.qml new file mode 100644 index 0000000..14be079 --- /dev/null +++ b/ui/components/PageWithBottomEdge.qml @@ -0,0 +1,398 @@ +/* + * Copyright (C) 2014 Canonical, Ltd. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +//Source: http://bazaar.launchpad.net/~ubuntu-clock-dev/ubuntu-clock-app/utopic-3.0/view/head:/app/upstreamcomponents/PageWithBottomEdge.qml + +/* + Example: + + MainView { + objectName: "mainView" + + applicationName: "com.ubuntu.developer.boiko.bottomedge" + + width: units.gu(100) + height: units.gu(75) + + Component { + id: pageComponent + + PageWithBottomEdge { + id: mainPage + title: i18n.tr("Main Page") + + Rectangle { + anchors.fill: parent + color: "white" + } + + bottomEdgePageComponent: Page { + title: "Contents" + anchors.fill: parent + //anchors.topMargin: contentsPage.flickable.contentY + + ListView { + anchors.fill: parent + model: 50 + delegate: ListItems.Standard { + text: "One Content Item: " + index + } + } + } + bottomEdgeTitle: i18n.tr("Bottom edge action") + } + } + + PageStack { + id: stack + Component.onCompleted: stack.push(pageComponent) + } + } + +*/ + +import QtQuick 2.2 +import Ubuntu.Components 1.1 + +Page { + id: page + + property alias bottomEdgePageComponent: edgeLoader.sourceComponent + property alias bottomEdgePageSource: edgeLoader.source + property alias bottomEdgeTitle: tipLabel.text + property alias bottomEdgeEnabled: bottomEdge.visible + property int bottomEdgeExpandThreshold: page.height * 0.2 + property int bottomEdgeExposedArea: bottomEdge.state !== "expanded" ? (page.height - bottomEdge.y - bottomEdge.tipHeight) : _areaWhenExpanded + property bool reloadBottomEdgePage: true + + readonly property alias bottomEdgePage: edgeLoader.item + readonly property bool isReady: ((bottomEdge.y === 0) && bottomEdgePageLoaded && edgeLoader.item.active) + readonly property bool isCollapsed: (bottomEdge.y === page.height) + readonly property bool bottomEdgePageLoaded: (edgeLoader.status == Loader.Ready) + + property bool _showEdgePageWhenReady: false + property int _areaWhenExpanded: 0 + + signal bottomEdgeReleased() + signal bottomEdgeDismissed() + + + function showBottomEdgePage(source, properties) + { + edgeLoader.setSource(source, properties) + _showEdgePageWhenReady = true + } + + function setBottomEdgePage(source, properties) + { + edgeLoader.setSource(source, properties) + } + + function _pushPage() + { + if (edgeLoader.status === Loader.Ready) { + edgeLoader.item.active = true + page.pageStack.push(edgeLoader.item) + if (edgeLoader.item.flickable) { + edgeLoader.item.flickable.contentY = -page.header.height + edgeLoader.item.flickable.returnToBounds() + } + if (edgeLoader.item.ready) + edgeLoader.item.ready() + } + } + + + Component.onCompleted: { + // avoid a binding on the expanded height value + var expandedHeight = height; + _areaWhenExpanded = expandedHeight; + } + + onActiveChanged: { + if (active) { + bottomEdge.state = "collapsed" + } + } + + onBottomEdgePageLoadedChanged: { + if (_showEdgePageWhenReady && bottomEdgePageLoaded) { + bottomEdge.state = "expanded" + _showEdgePageWhenReady = false + } + } + + Rectangle { + id: bgVisual + + color: "black" + anchors.fill: page + opacity: 0.7 * ((page.height - bottomEdge.y) / page.height) + z: 1 + } + + Timer { + id: hideIndicator + + interval: 3000 + running: true + repeat: false + onTriggered: tip.hiden = true + } + + Rectangle { + id: bottomEdge + objectName: "bottomEdge" + + readonly property int tipHeight: units.gu(3) + readonly property int pageStartY: 0 + + z: 1 + color: Theme.palette.normal.background + parent: page + anchors { + left: parent.left + right: parent.right + } + height: page.height + y: height + + UbuntuShape { + id: tip + objectName: "bottomEdgeTip" + + property bool hiden: false + + readonly property double visiblePosition: (page.height - bottomEdge.y) < units.gu(1) ? -bottomEdge.tipHeight + (page.height - bottomEdge.y) : 0 + readonly property double invisiblePosition: (page.height - bottomEdge.y) < units.gu(1) ? -units.gu(1) : 0 + + z: -1 + anchors.horizontalCenter: parent.horizontalCenter + y: hiden ? invisiblePosition : visiblePosition + + width: tipLabel.paintedWidth + units.gu(6) + height: bottomEdge.tipHeight + units.gu(1) + color: Theme.palette.normal.overlay + Label { + id: tipLabel + + anchors { + top: parent.top + left: parent.left + right: parent.right + } + height: bottomEdge.tipHeight + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter + opacity: tip.hiden ? 0.0 : 1.0 + Behavior on opacity { + UbuntuNumberAnimation { + duration: UbuntuAnimation.SnapDuration + } + } + } + Behavior on y { + UbuntuNumberAnimation { + duration: UbuntuAnimation.SnapDuration + } + } + } + + Rectangle { + id: shadow + + anchors { + left: parent.left + right: parent.right + } + height: units.gu(1) + y: -height + z: -2 + opacity: 0.0 + gradient: Gradient { + GradientStop { position: 0.0; color: "transparent" } + GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.2) } + } + } + + MouseArea { + id: mouseArea + + preventStealing: true + drag { + axis: Drag.YAxis + target: bottomEdge + minimumY: bottomEdge.pageStartY + maximumY: page.height + threshold: 100 + } + + anchors { + left: parent.left + right: parent.right + } + height: bottomEdge.tipHeight + y: -height + + onReleased: { + page.bottomEdgeReleased() + if (bottomEdge.y < (page.height - bottomEdgeExpandThreshold - bottomEdge.tipHeight)) { + bottomEdge.state = "expanded" + } else { + bottomEdge.state = "collapsed" + bottomEdge.y = bottomEdge.height + } + } + + onClicked: { + tip.hiden = false + hideIndicator.restart() + } + } + + state: "collapsed" + states: [ + State { + name: "collapsed" + PropertyChanges { + target: bottomEdge + y: bottomEdge.height + } + PropertyChanges { + target: tip + opacity: 1.0 + } + PropertyChanges { + target: hideIndicator + running: true + } + }, + State { + name: "expanded" + PropertyChanges { + target: bottomEdge + y: bottomEdge.pageStartY + } + PropertyChanges { + target: hideIndicator + running: false + } + }, + State { + name: "floating" + when: mouseArea.drag.active + PropertyChanges { + target: shadow + opacity: 1.0 + } + PropertyChanges { + target: hideIndicator + running: false + } + PropertyChanges { + target: tip + hiden: false + } + } + ] + + transitions: [ + Transition { + to: "expanded" + SequentialAnimation { + UbuntuNumberAnimation { + target: bottomEdge + property: "y" + duration: UbuntuAnimation.SlowDuration + } + ScriptAction { + script: page._pushPage() + } + } + }, + Transition { + from: "expanded" + to: "collapsed" + SequentialAnimation { + ScriptAction { + script: { + Qt.inputMethod.hide() + edgeLoader.item.parent = edgeLoader + edgeLoader.item.anchors.fill = edgeLoader + edgeLoader.item.active = false + } + } + UbuntuNumberAnimation { + target: bottomEdge + property: "y" + duration: UbuntuAnimation.SlowDuration + } + ScriptAction { + script: { + // destroy current bottom page + if (page.reloadBottomEdgePage) { + edgeLoader.active = false + } + + // notify + page.bottomEdgeDismissed() + + edgeLoader.active = true + tip.hiden = false + hideIndicator.restart() + } + } + } + }, + Transition { + from: "floating" + to: "collapsed" + UbuntuNumberAnimation { + target: bottomEdge + property: "opacity" + } + } + ] + + Item { + anchors.fill: parent + clip: true + + Loader { + id: edgeLoader + + z: 1 + active: true + asynchronous: true + anchors.fill: parent + + //WORKAROUND: The SDK move the page contents down to allocate space for the header we need to avoid that during the page dragging + Binding { + target: edgeLoader.status === Loader.Ready ? edgeLoader : null + property: "anchors.topMargin" + value: edgeLoader.item && edgeLoader.item.flickable ? edgeLoader.item.flickable.contentY : 0 + when: !page.isReady + } + + onLoaded: { + if (page.isReady && edgeLoader.item.active !== true) { + page._pushPage() + } + } + } + } + } +} diff --git a/ui/viewing/MessageDelegate.qml b/ui/viewing/MessageDelegate.qml new file mode 100644 index 0000000..89d9565 --- /dev/null +++ b/ui/viewing/MessageDelegate.qml @@ -0,0 +1,142 @@ +/************************************************************************* +** Forum Browser +** +** Copyright (c) 2014 Niklas Wenzel +** Copyright (c) 2013 - 2014 Michael Hall +** +** $QT_BEGIN_LICENSE:GPL$ +** This program is free software; you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation; either version 2 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +** General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; see the file COPYING. If not, write to +** the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +** Boston, MA 02110-1301, USA. +** +** +** $QT_END_LICENSE$ +** +*************************************************************************/ + +import QtQuick 2.2 +import Ubuntu.Components 1.1 +import Ubuntu.Components.ListItems 1.0 + +UbuntuShape { + property string titleText + property string content + property string avatar + property string authorText + + width: parent.width + height: contentRect.height + anchors { + horizontalCenter: parent.horizontalCenter + } + color: "white" + + Rectangle { + id: contentRect + width: parent.width + height: childrenRect.height + units.gu(1) + color: "transparent" + + Rectangle { + id: rect + width: units.gu(7) + height: units.gu(7) + color: "transparent" + + anchors { + top: parent.top + left: parent.left + } + + UbuntuShape { + width: units.gu(5) + height: width + anchors.centerIn: parent + image: Image { + id: avatarp + source: if(avatar === "") { "../../graphics/contact.svg" } else { avatar } + anchors.fill: parent + onStatusChanged: { if(avatarp.status === Image.Ready || avatar === "") { load_image.running=false; } } + } + } + ActivityIndicator { + id: load_image + z: 100 + anchors.centerIn: parent + running: true + } + } + + Label { + id: author + text: authorText + anchors { + top: parent.top + left: rect.right + topMargin: units.gu(1) + leftMargin: units.gu(1) + } + color: "black" + font.bold: true + } + + Label { + id: title + text: titleText + wrapMode: Text.Wrap + color: "#808080" + font.italic: true + visible: titleText !== undefined && titleText !== "" + anchors { + top: author.bottom + left: rect.right + right: parent.right + leftMargin: units.gu(1) + rightMargin: units.gu(1) + } + } + + Label { + text: parseBBCode(content) + wrapMode: Text.Wrap + color: "#808080" + anchors { + top: title.visible?title.bottom:author.bottom + left: rect.right + right: parent.right + topMargin: units.gu(1) + leftMargin: units.gu(1) + rightMargin: units.gu(1) + } + onLinkActivated: Qt.openUrlExternally(link) + } + } + + function parseBBCode(text) { + var bb = []; + bb[0] = /\[url\](.*?)\[\/url\]/gi; + bb[1] = /\[url\="?(.*?)"?\](.*?)\[\/url\]/gi; + + var html =[]; + html[0] = "$1"; + html[1] = "$2"; + + for (var i = 0; i < bb.length; i++) { + text = text.replace(bb[i], html[i]); + } + + return text; + } + +} diff --git a/ui/viewing/PostCreationPage.qml b/ui/viewing/PostCreationPage.qml new file mode 100644 index 0000000..3feb892 --- /dev/null +++ b/ui/viewing/PostCreationPage.qml @@ -0,0 +1,162 @@ +/************************************************************************* +** Forum Browser +** +** Copyright (c) 2014 Niklas Wenzel +** +** $QT_BEGIN_LICENSE:GPL$ +** This program is free software; you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation; either version 2 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +** General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; see the file COPYING. If not, write to +** the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +** Boston, MA 02110-1301, USA. +** +** +** $QT_END_LICENSE$ +** +*************************************************************************/ + +import QtQuick 2.2 +import Ubuntu.Components 1.1 +import Ubuntu.Components.Popups 1.0 +import "../../stringutils.js" as StringUtils + +Page { + id: postCreationPage + anchors.fill: parent + + readonly property string signature: i18n.tr("Sent from my awesome Ubuntu Touch device using the Forum Browser app") + + signal posted(); + + property int forum_id: -1 + property int topic_id: -1 + + title: i18n.tr("New Post") + + Flickable { + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + right: parent.right + margins: units.gu(1) + } + + contentHeight: column.height + + Column { + id: column + height: childrenRect.height + width: parent.width + spacing: units.gu(1) + + Label { + text: i18n.tr("Subject:") + } + + TextField { + id: subjectTextField + width: parent.width + + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: messageTextField + } + + Label { + text: i18n.tr("Message:") + } + + TextArea { + id: messageTextField + width: parent.width + + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.backtab: subjectTextField + } + + Row { + id: signatureRow + width: parent.width + spacing: units.gu(1) + + CheckBox { + id: appendSignatureCheckBox + checked: true + anchors.verticalCenter: parent.verticalCenter + } + + Label { + id: signatureLabel + text: signature + wrapMode: Text.Wrap + + anchors.verticalCenter: parent.verticalCenter + width: parent.width - signatureRow.spacing - appendSignatureCheckBox.width + } + } + + Button { + id: submitButton + text: i18n.tr("Submit") + width: parent.width + + onClicked: { + var message = messageTextField.text + + if (appendSignatureCheckBox.checked) { + message += "\n\n" + signature + } + + var xhr = new XMLHttpRequest; + xhr.open("POST", backend.currentSession.apiSource); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { +// console.log(xhr.responseText) + if (xhr.status === 200) { + var resultIndex = xhr.responseText.indexOf("result"); + var booleanTag = xhr.responseText.indexOf("", resultIndex) + var booleanEndTag = xhr.responseText.indexOf("", resultIndex) + var result = xhr.responseText.substring(booleanTag + 9, booleanEndTag) + + var success = result === "1"; + + if (success) { + pageStack.pop() + posted() + } else { + var resultTextIndex = xhr.responseText.indexOf("result_text") + var resultText + if (resultTextIndex > 0) { + var base64Tag = xhr.responseText.indexOf("", resultTextIndex) + var base64EndTag = xhr.responseText.indexOf("", resultTextIndex) + resultText = StringUtils.base64_decode(xhr.responseText.substring(base64Tag + 8, base64EndTag)) + console.log(resultText) + } + var dialog = PopupUtils.open(errorDialog) + dialog.title = i18n.tr("Action failed") + if (resultText !== undefined) { + dialog.text = i18n.tr("Text returned by the server:\n") + resultText + } + } + } else { + notification.show(i18n.tr("Connection error")) + } + } + } + xhr.send('reply_post' + forum_id + '' + topic_id + '' + StringUtils.base64_encode(subjectTextField.text) + '' + StringUtils.base64_encode(message) + ''); + } + } + + } + + } +} diff --git a/ui/viewing/SubForumList.qml b/ui/viewing/SubForumList.qml new file mode 100644 index 0000000..b413eac --- /dev/null +++ b/ui/viewing/SubForumList.qml @@ -0,0 +1,381 @@ +/************************************************************************* +** Forum Browser +** +** Copyright (c) 2014 Niklas Wenzel +** Copyright (c) 2013 - 2014 Michael Hall +** +** $QT_BEGIN_LICENSE:GPL$ +** This program is free software; you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation; either version 2 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +** General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; see the file COPYING. If not, write to +** the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +** Boston, MA 02110-1301, USA. +** +** +** $QT_END_LICENSE$ +** +*************************************************************************/ + +import QtQuick 2.2 +import QtQuick.XmlListModel 2.0 +import Ubuntu.Components 1.1 +import Ubuntu.Components.ListItems 1.0 +import "../../stringutils.js" as StringUtils + + +ListView { + id: forumsList + + property alias current_forum: categoryModel.parentForumId + property int current_topic: -1 + property int selected_forum: -1 + property string selected_title: "" + property bool canPost: false + property bool hasTopics: false + property string mode: "" + property bool moreLoading: false + + readonly property bool modelLoading: (categoryModel.status === XmlListModel.Loading) || (topicModel.status === XmlListModel.Loading) + readonly property bool modelsHaveLoadedCompletely: categoryModel.hasLoadedCompletely && topicModel.hasLoadedCompletely + + clip: true + + onModeChanged: reload() + + delegate: SubForumListItem { + text: StringUtils.base64_decode(model.name) + subText: StringUtils.base64_decode(model.description) + replies: model.topic ? (parseInt(model.posts) + 1) : 0 //+1 to include OP + author: model.topic ? StringUtils.base64_decode(model.author) : "" + has_new: model.has_new === '1' ? true : false + progression: true + + onTriggered: { + selected_title = text + if (model.topic) { + current_topic = -1 + current_topic = model.id + } else { + selected_forum = -1 + selected_forum = model.id + } + } + + Component.onCompleted: { + if (modelsHaveLoadedCompletely && hasTopics && mode === "" && index === forumListModel.count - 3 && forumListModel.count % 20 === categoryModel.count) { + console.log("load more, index: " + index) + loadMore(20) + } + } + } + + footer: Standard { //ListItem.Standard + visible: moreLoading + width: parent.width + height: visible ? units.gu(6) : 0 + divider.visible: false + + ActivityIndicator { + id: loadMoreIndicator + running: visible + anchors.centerIn: parent + } + } + + model: ListModel { + id: forumListModel + + Component.onCompleted: { + backend.loginDone.connect(clearSetLoadingOnLoginDone) + } + + Component.onDestruction: { + backend.loginDone.disconnect(clearSetLoadingOnLoginDone) + } + + function clearSetLoadingOnLoginDone(session) { + if (session === backend.currentSession) { + clearSetLoading() + } + } + + function clearSetLoading() { + clear() + loadingSpinnerRunning = true + } + } + + function topicCount() { + var count = 0; + for (var i = 0; i < forumListModel.count; i++) { + if (forumListModel.get(i).topic) { + count++; + } + } + return count; + } + + function loadMore(count) { + moreLoading = true + var tCount = topicCount(); + topicModel.__loadTopics(tCount, tCount + count - 1); + } + + function reload() { + forumListModel.clear() + loadingSpinner.running = true + if (mode === "") { + categoryModel.__loadForums() + } + topicModel.__loadTopics() + } + + XmlListModel { + id: categoryModel + objectName: "categoryModel" + + property bool hasLoadedCompletely: true + + property int parentForumId: -1 + query: "/methodResponse/params/param/value/array/data/value/struct" + + XmlRole { name: "id"; query: "member[name='forum_id']/value/string()" } + XmlRole { name: "name"; query: "member[name='forum_name']/value/base64/string()" } + XmlRole { name: "description"; query: "member[name='description']/value/base64/string()" } + XmlRole { name: "logo"; query: "member[name='logo_url']/value/string()" } + + property bool checkingForChildren: false + + onStatusChanged: { + if (status === 1) { + if (!checkingForChildren) { + console.debug("categoryModel has: " + count + " items"); + + if (count !== 1 || parentForumId !== parseInt(get(0).id)) { + insertResults() + } else { //Header with a child attribute + if (!topicModel.hasLoadedCompletely) { + topicModel.onHasLoadedCompletelyChanged.connect(loadChildren) + } else { + loadChildren() + } + } + } else { + checkingForChildren = false + switch (count) { + case 0: //Check for id + console.log("no subs") + loadingFinished() + break + default: + console.log("Subs") + insertResults() + } + } + + + } + } + + function loadChildren() { //Reloading should overall be faster than loading the children attribute for every item + topicModel.onHasLoadedCompletelyChanged.disconnect(loadChildren) + + checkingForChildren = true + + query = "/methodResponse/params/param/value/array/data/value/struct/member[name='child']/value/array/data/value/struct" + __loadForums() + } + + function insertResults() { //If changed, adjust above as well + for (var i = 0; i < count; i++) { + var element = get(i) + //We need to declare even unused properties here + //Needed when there are both topics and categories in a subforum + forumListModel.insert(i, {"topic": false, "id": element.id.trim(), "name": element.name.trim(), "description": element.description.trim(), "logo": element.logo.trim(), "author": "", "posts": "-1", "has_new": "0"}); + } + + loadingFinished() + } + + function loadingFinished() { + hasLoadedCompletely = true + loadingSpinner.running = !(topicModel.hasLoadedCompletely || current_forum === 0) + } + + Component.onCompleted: { + backend.loginDone.connect(loadOnLoginDone) + } + + Component.onDestruction: { + backend.loginDone.disconnect(loadOnLoginDone) + } + + onParentForumIdChanged: if (backend.currentSession.loginFinished) __loadForums() + + function loadOnLoginDone(session) { + if (session === backend.currentSession) { + __loadForums() + } + } + + function __loadForums() { + hasLoadedCompletely = false + + var xhr = new XMLHttpRequest; + categoryModel.xml=""; + xhr.open("POST", backend.currentSession.apiSource); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { +// console.log(xhr.responseText) + categoryModel.xml = StringUtils.xmlFromResponse(xhr.responseText) + } else { + notification.show(i18n.tr("Connection error")) + + loadingFinished() + } + } + } + xhr.send('get_forumtrue'+parentForumId+''); + } + } + + XmlListModel { + id: topicModel + objectName: "topicModel" + + property bool hasLoadedCompletely: true + + property int forumId: current_forum + query: "/methodResponse/params/param/value/struct/member/value/array/data/value/struct" + + XmlRole { name: "id"; query: "member[name='topic_id']/value/string()" } + XmlRole { name: "title"; query: "member[name='topic_title']/value/base64/string()" } + XmlRole { name: "description"; query: "member[name='short_content']/value/base64/string()" } + XmlRole { name: "author"; query: "member[name='topic_author_name']/value/base64/string()" } + XmlRole { name: "posts"; query: "member[name='reply_number']/value/int/string()" } + XmlRole { name: "has_new"; query: "member[name='new_post']/value/boolean/string()" } + + onStatusChanged: { + if (status === 1) { + if (count > 0) { + hasTopics = true //no else needed (and it may interfere with moreLoading) + + //TODO: Check if if is needed or if it won't be added twice even without the if + if (count === 1 && forumListModel.count > 0 && get(0).id.trim() === forumListModel.get(forumListModel.count - 1).id && forumListModel.get(forumListModel.count - 1).topic === true) { + //Do not add the element as it is a duplicate of the last one which was added + //Happens if a forum contains n * 20 topics (with n = 2, 3, 4, ...) and loadMore() is called (sadly, that's how the API handles the request) + + console.log("Don't add duplicate topic (n * 20 posts)") + + showNoMoreNotification() + } else { + //Add to forumListModel + + console.debug("topicModel has: " + count + " items"); + + for (var i = 0; i < count; i++) { + var element = get(i); + //We need to declare even unused properties here + //Needed when there are both topics and categories in a subforum + forumListModel.append({"topic": true, "id": element.id.trim(), "name": element.title.trim(), "description": element.description.trim(), "logo": "", "author": element.author.trim(), "posts": element.posts.trim(), "has_new": element.has_new.trim()}); + } + } + } + +// console.log(xml) + + //Check if the user is allowed to create a new topic + + var canPostStringPosition = xml.indexOf("can_post"); + if (canPostStringPosition < 0) { + canPost = true + } else { + var openBoolTagPosition = xml.indexOf("", canPostStringPosition); + var closeBoolTagPosition = xml.indexOf("", openBoolTagPosition); + var canPostSubstring = xml.substring(openBoolTagPosition + 9, closeBoolTagPosition); //equals + "".length + + canPost = canPostSubstring.trim() === "1" + } + + loadingFinished() + } + } + + function showNoMoreNotification() { + notification.show(i18n.tr("No more threads to load")) + } + + function loadingFinished() { + if (count === 0 && moreLoading) { + showNoMoreNotification() + } + + moreLoading = false + hasLoadedCompletely = true + loadingSpinner.running = !categoryModel.hasLoadedCompletely + } + + Component.onCompleted: { + backend.loginDone.connect(loadOnLoginDone) + } + + Component.onDestruction: { + backend.loginDone.disconnect(loadOnLoginDone) + } + + function loadOnLoginDone(session) { + if (session === backend.currentSession) { + __loadTopics() + } + } + + onForumIdChanged: if (backend.currentSession.loginFinished) __loadTopics() + + function __loadTopics(startNum, endNum) { + hasLoadedCompletely = false + + if (forumId <= 0) { + return; + } + + var xhr = new XMLHttpRequest; + topicModel.xml=""; + xhr.open("POST", backend.currentSession.apiSource); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { + topicModel.xml = StringUtils.xmlFromResponse(xhr.responseText) + } else { + notification.show(i18n.tr("Connection error")) + + loadingFinished() + } + } + } + var startEndParams = ""; + if (startNum !== undefined && endNum !== undefined) { + console.log("load topics: " + startNum + " - " + endNum) + startEndParams += ''+startNum+'' + startEndParams += ''+endNum+'' + } else { + startEndParams += '0' + startEndParams += '19' + } + + xhr.send('get_topic'+forumId+''+startEndParams+''+mode+''); + } + } + + + +} diff --git a/ui/viewing/SubForumListItem.qml b/ui/viewing/SubForumListItem.qml new file mode 100644 index 0000000..319f079 --- /dev/null +++ b/ui/viewing/SubForumListItem.qml @@ -0,0 +1,104 @@ +/************************************************************************* +** Forum Browser +** +** Copyright (c) 2014 Niklas Wenzel +** +** $QT_BEGIN_LICENSE:GPL$ +** This program is free software; you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation; either version 2 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +** General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; see the file COPYING. If not, write to +** the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +** Boston, MA 02110-1301, USA. +** +** +** $QT_END_LICENSE$ +** +*************************************************************************/ + +import QtQuick 2.2 +import Ubuntu.Components 1.1 +import Ubuntu.Components.ListItems 1.0 +import '../components' + +Base { + id: subtitledListItem + property alias replies: counter.text + property string author: '' + property bool has_new: false + + onAuthorChanged: { + subLabel.text = 'Thread by: '+author + } + + __height: Math.max(middleVisuals.height, units.gu(6)) + + property alias text: label.text + property alias subText: subLabel.text + + UbuntuShape { + id: countContainer + height: units.gu(4) + width: units.gu(4) + color: "green" +// color: Qt.darker(mainView.backgroundColor, 1.1) + + visible: model.topic + + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + } + + Label { + id: counter + anchors.centerIn: parent + fontSize: 'medium' + text: '0' + color: "white" +// color: '#5c3001' + } + } + + Item { + id: middleVisuals + anchors { + left: model.topic?countContainer.right:parent.left + right: parent.right + verticalCenter: parent.verticalCenter + leftMargin: model.topic?units.gu(2):0 + } + height: childrenRect.height + label.anchors.topMargin + subLabel.anchors.bottomMargin + + LabelVisual { + id: label + selected: subtitledListItem.selected + anchors { + top: parent.top + left: parent.left + right: parent.right + } + } + LabelVisual { + id: subLabel + selected: subtitledListItem.selected + secondary: !model.topic //No grey color when browsing through topics + anchors { + left: parent.left + right: parent.right + top: label.bottom + } + fontSize: "small" + wrapMode: Text.Wrap + maximumLineCount: 5 + } + } +} diff --git a/ui/viewing/SubForumPage.qml b/ui/viewing/SubForumPage.qml new file mode 100644 index 0000000..32adf05 --- /dev/null +++ b/ui/viewing/SubForumPage.qml @@ -0,0 +1,226 @@ +/************************************************************************* +** Forum Browser +** +** Copyright (c) 2014 Niklas Wenzel +** Copyright (c) 2013 - 2014 Michael Hall +** +** $QT_BEGIN_LICENSE:GPL$ +** This program is free software; you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation; either version 2 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +** General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; see the file COPYING. If not, write to +** the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +** Boston, MA 02110-1301, USA. +** +** +** $QT_END_LICENSE$ +** +*************************************************************************/ + +import QtQuick 2.2 +import Ubuntu.Components 1.1 +import Ubuntu.Components.Popups 1.0 +import '../components' + +Page { + id: forumsPage + title: i18n.tr("Forums") + + property alias current_forum: forumsList.current_forum + property bool isForumOverview: current_forum === 0 + + property alias selectedTitle: forumsList.selected_title + + property alias loadingSpinnerRunning: loadingSpinner.running + property bool showSections: false + + Action { + id: reloadAction + text: i18n.tr("Reload") + iconName: "reload" + onTriggered: { + if (!backend.currentSession.configModel.hasLoaded) { //e.g. if there was no internet connection, when the forum was opened + backend.currentSession.configModel.loadConfig() + } else { + forumsList.reload() + } + } + } + + Action { + id: loginAction + text: i18n.tr("Login") + iconName: "contact" + onTriggered: { + pageStack.push(loginPage) + } + } + + Action { + id: newTopicAction + text: i18n.tr("New Topic") + iconName: "compose" + visible: backend.currentSession.loggedIn && !isForumOverview && forumsList.canPost && forumsList.mode === "" && forumsList.hasTopics //hasTopics as a workaround for disabling posting in category-only subforums + onTriggered: { + component = Qt.createComponent("ThreadCreationPage.qml") + + if (component.status === Component.Ready) { + finishNewTopicPageCreation() + } else { + component.statusChanged.connect(finishNewTopicPageCreation) + } + } + + function finishNewTopicPageCreation() { + var page = component.createObject(mainView, {"forum_id": current_forum}) + page.posted.connect(onNewTopicCreated) + pageStack.push(page) + } + } + + function onNewTopicCreated(subject, topicId) { + selectedTitle = subject + forumsList.current_topic = -1 + forumsList.current_topic = topicId //Show topic + + forumsList.reload() + } + + Action { + id: mBackAction + text: i18n.tr("Back") + iconName: "back" + onTriggered: { + console.log("destroyPage") + + //Logout if this is the top level forums list + if (isForumOverview) { + backend.endSession(backend.currentSession) + } + + pageStack.pop() + + forumsPage.destroy(500) + } + } + + readonly property var headerActions: [ + reloadAction, + newTopicAction, + loginAction + ] + + Connections { + target: forumsList + onHasTopicsChanged: { + if (forumsList.hasTopics && forumsList.mode === "") { + showSections = true + } + } + } + + state: showSections ? "topics" : "no_topics" //e.g. show message "no stickies" + onStateChanged: console.log("state: " + state) + + states: [ + PageHeadState { + id: noTopicsState + name: "no_topics" + head: forumsPage.head + actions: headerActions + backAction: mBackAction + }, + PageHeadState { + id: topicsState + name: "topics" + head: forumsPage.head + + PropertyChanges { + target: forumsPage.head + sections.enabled: forumsList.modelsHaveLoadedCompletely + sections.model: [i18n.tr("Standard"), i18n.tr("Stickies"), i18n.tr("Announcements")] + sections.selectedIndex: 0 + actions: headerActions + backAction: mBackAction + } + } + ] + + ActivityIndicator { + id: loadingSpinner + anchors.centerIn: forumsList + } + + SubForumList { + id: forumsList + anchors.fill: parent + + mode: (forumsPage.head.sections.selectedIndex === 1) ? "TOP" : ((forumsPage.head.sections.selectedIndex === 2) ? "ANN" : "") + + onSelected_forumChanged: { + if (selected_forum > 0) { + component = Qt.createComponent("SubForumPage.qml"); + + if (component.status === Component.Ready) { + finishSubForumPageCreation(); + } else { + component.statusChanged.connect(finishSubForumPageCreation); + } + } + } + + function finishSubForumPageCreation() { + var page = component.createObject(mainView, {"title": selectedTitle, "current_forum": selected_forum, "loadingSpinnerRunning": true}) + pageStack.push(page) + } + + onCurrent_topicChanged: { + if (current_topic > 0) { + component = Qt.createComponent("ThreadPage.qml") + + if (component.status === Component.Ready) { + finishThreadPageCreation(); + } else { + component.statusChanged.connect(finishThreadPageCreation); + } + } + } + + function finishThreadPageCreation() { + var vBulletinAnnouncement = backend.currentSession.configModel.isVBulletin && forumsList.mode === "ANN" + var page = component.createObject(mainView, {"title": selectedTitle, "loadingSpinnerRunning": true, "forum_id": current_forum, "vBulletinAnnouncement": vBulletinAnnouncement}) + page.current_topic = current_topic //Need to set vBulletinAnnouncement before current_topic!!! Therefore, this is executed after the creation of the Page. + pageStack.push(page) + } + } + + Label { + id: emptyView + text: (forumsList.mode === "") ? i18n.tr("No topics available here") : ((forumsList.mode === "TOP") ? i18n.tr("No stickies available here") : i18n.tr("No announcements available here")) + visible: forumsList.model.count === 0 && !loadingSpinnerRunning + elide: Text.ElideRight + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + fontSize: "large" + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + right: parent.right + margins: units.gu(2) + } + } + + Scrollbar { + flickableItem: forumsList + align: Qt.AlignTrailing + } + +} diff --git a/ui/viewing/ThreadCreationPage.qml b/ui/viewing/ThreadCreationPage.qml new file mode 100644 index 0000000..7a6c1f5 --- /dev/null +++ b/ui/viewing/ThreadCreationPage.qml @@ -0,0 +1,168 @@ +/************************************************************************* +** Forum Browser +** +** Copyright (c) 2014 Niklas Wenzel +** +** $QT_BEGIN_LICENSE:GPL$ +** This program is free software; you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation; either version 2 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +** General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; see the file COPYING. If not, write to +** the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +** Boston, MA 02110-1301, USA. +** +** +** $QT_END_LICENSE$ +** +*************************************************************************/ + +import QtQuick 2.2 +import Ubuntu.Components 1.1 +import Ubuntu.Components.Popups 1.0 +import "../../stringutils.js" as StringUtils + +Page { + id: threadCreationPage + anchors.fill: parent + + readonly property string signature: i18n.tr("Sent from my awesome Ubuntu Touch device using the Forum Browser app") + + signal posted(string subject, int topicId); + + property int forum_id: -1 + property int topic_id: -1 + + title: i18n.tr("New Topic") + + Flickable { + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + right: parent.right + margins: units.gu(1) + } + + contentHeight: column.height + + Column { + id: column + height: childrenRect.height + width: parent.width + spacing: units.gu(1) + + Label { + text: i18n.tr("Subject:") + } + + TextField { + id: subjectTextField + width: parent.width + + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: messageTextField + } + + Label { + text: i18n.tr("Message:") + } + + TextArea { + id: messageTextField + width: parent.width + + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.backtab: subjectTextField + } + + Row { + id: signatureRow + width: parent.width + spacing: units.gu(1) + + CheckBox { + id: appendSignatureCheckBox + checked: true + anchors.verticalCenter: parent.verticalCenter + } + + Label { + id: signatureLabel + text: signature + wrapMode: Text.Wrap + + anchors.verticalCenter: parent.verticalCenter + width: parent.width - signatureRow.spacing - appendSignatureCheckBox.width + } + } + + Button { + id: submitButton + text: i18n.tr("Submit") + width: parent.width + + onClicked: { + var message = messageTextField.text + + if (appendSignatureCheckBox.checked) { + message += "\n\n" + signature + } + + var xhr = new XMLHttpRequest; + xhr.open("POST", backend.currentSession.apiSource); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { +// console.log(xhr.responseText) + if (xhr.status === 200) { + var resultIndex = xhr.responseText.indexOf("result"); + var booleanTag = xhr.responseText.indexOf("", resultIndex) + var booleanEndTag = xhr.responseText.indexOf("", resultIndex) + var result = xhr.responseText.substring(booleanTag + 9, booleanEndTag) + + var success = result === "1"; + + if (success) { + //Get the id of the topic + var idIndex = xhr.responseText.indexOf("topic_id"); + var stringTag = xhr.responseText.indexOf("", idIndex) + var stringEndTag = xhr.responseText.indexOf("", idIndex) + var id = parseInt(xhr.responseText.substring(stringTag + 8, stringEndTag)) + + pageStack.pop() + posted(subjectTextField.text, id) + } else { + var resultTextIndex = xhr.responseText.indexOf("result_text") + var resultText + if (resultTextIndex > 0) { + var base64Tag = xhr.responseText.indexOf("", resultTextIndex) + var base64EndTag = xhr.responseText.indexOf("", resultTextIndex) + resultText = StringUtils.base64_decode(xhr.responseText.substring(base64Tag + 8, base64EndTag)) + console.log(resultText) + } + var dialog = PopupUtils.open(errorDialog) + dialog.title = i18n.tr("Action failed") + if (resultText !== undefined) { + dialog.text = i18n.tr("Text returned by the server:\n") + resultText + } + } + } else { + notification.show(i18n.tr("Connection error")) + } + } + } + xhr.send('new_topic' + forum_id + '' + StringUtils.base64_encode(subjectTextField.text) + '' + StringUtils.base64_encode(message) + ''); + } + } + + } + + } +} diff --git a/ui/viewing/ThreadList.qml b/ui/viewing/ThreadList.qml new file mode 100644 index 0000000..63d38c5 --- /dev/null +++ b/ui/viewing/ThreadList.qml @@ -0,0 +1,163 @@ +/************************************************************************* +** Forum Browser +** +** Copyright (c) 2014 Niklas Wenzel +** Copyright (c) 2013 - 2014 Michael Hall +** +** $QT_BEGIN_LICENSE:GPL$ +** This program is free software; you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation; either version 2 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +** General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; see the file COPYING. If not, write to +** the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +** Boston, MA 02110-1301, USA. +** +** +** $QT_END_LICENSE$ +** +*************************************************************************/ + +import QtQuick 2.2 +import QtQuick.XmlListModel 2.0 +import Ubuntu.Components 1.1 +import Ubuntu.Components.ListItems 1.0 +import "../../stringutils.js" as StringUtils + + +ListView { + + property alias current_topic: threadModel.topic_id + property alias firstDisplayedPost: threadModel.firstDisplayedPost + property alias lastDisplayedPost: threadModel.lastDisplayedPost + property int totalPostCount: -1 + property bool canReply: false + property bool isClosed: false + + property bool vBulletinAnnouncement: false + + anchors { + topMargin: units.gu(1) + bottomMargin: units.gu(1) + leftMargin: units.gu(1) + rightMargin: units.gu(1) + } + + spacing: units.gu(1) + clip: true + + delegate: MessageDelegate { + titleText: StringUtils.base64_decode(model.title) + content: StringUtils.base64_decode(model.content) + authorText: StringUtils.base64_decode(model.author) + avatar: model.avatar + } + + function loadPosts(startNum, count) { + totalPostCount = -1 + loadingSpinner.running = true; + threadModel.__loadPosts(startNum, count); + } + + function reload() { + loadPosts(firstDisplayedPost, backend.postsPerPage) //lastDisplayedPost might not be the latest any more + } + + model: XmlListModel { + id: threadModel + objectName: "threadModel" + + property int firstDisplayedPost: -1 + property int lastDisplayedPost: -1 + + property int topic_id: -1 + query: "/methodResponse/params/param/value/struct/member[name='posts']/value/array/data/value/struct" + + XmlRole { name: "id"; query: "member[name='post_id']/value/string()" } + XmlRole { name: "title"; query: "member[name='post_title']/value/base64/string()" } + XmlRole { name: "content"; query: "member[name='post_content']/value/base64/string()" } + XmlRole { name: "author"; query: "member[name='post_author_name']/value/base64/string()" } + XmlRole { name: "avatar"; query: "member[name='icon_url']/value/string/string()" } + + onStatusChanged: { + if (status === 1) { + //Extract the total number of posts from XML + //Excerpt from the returned XML: total_post_num3 + + var totalPostNumStringPosition = xml.indexOf("total_post_num"); + var openIntTagPosition = xml.indexOf("", totalPostNumStringPosition); + var closeIntTagPosition = xml.indexOf("", openIntTagPosition); + var numSubstring = xml.substring(openIntTagPosition + 5, closeIntTagPosition); //equals + "".length + +// console.log(xml) + + totalPostCount = numSubstring; + + lastDisplayedPost = Math.min(lastDisplayedPost, totalPostCount - 1); + + //Check if the user is allowed to reply + + var canReplyStringPosition = xml.indexOf("can_reply"); + if (canReplyStringPosition < 0) { + canReply = true + } else { + var openBoolTagPosition = xml.indexOf("", canReplyStringPosition); + var closeBoolTagPosition = xml.indexOf("", openBoolTagPosition); + var canPostSubstring = xml.substring(openBoolTagPosition + 9, closeBoolTagPosition); //equals + "".length + + canReply = canPostSubstring.trim() === "1" + } + + //Check if the topic has been closed + + var isClosedStringPosition = xml.indexOf("is_closed"); + if (isClosedStringPosition < 0) { + isClosed = false + } else { + var openBoolTagPosition = xml.indexOf("", isClosedStringPosition); + var closeBoolTagPosition = xml.indexOf("", openBoolTagPosition); + var isClosedSubstring = xml.substring(openBoolTagPosition + 9, closeBoolTagPosition); //equals + "".length + + isClosed = isClosedSubstring.trim() === "1" + } + } + } + + onTopic_idChanged: __loadPosts(0, backend.postsPerPage) + function __loadPosts(startNum, count) { + firstDisplayedPost = startNum; + lastDisplayedPost = startNum + count - 1; + + var xhr = new XMLHttpRequest; + threadModel.xml=""; + xhr.open("POST", backend.currentSession.apiSource); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + loadingSpinner.running=false; + if (xhr.status === 200) { + threadModel.xml = StringUtils.xmlFromResponse(xhr.responseText) + } else { + notification.show(i18n.tr("Connection error")) + } + } + } + if (!vBulletinAnnouncement) { + xhr.send('get_thread'+topic_id+'' + firstDisplayedPost + '' + lastDisplayedPost + 'true'); + } else { //TODO: BBCode parsing for announcements + console.log("vb announcement") + xhr.send('get_announcement'+topic_id+''); + } + } + + + } + + +} diff --git a/ui/viewing/ThreadPage.qml b/ui/viewing/ThreadPage.qml new file mode 100644 index 0000000..e4459c3 --- /dev/null +++ b/ui/viewing/ThreadPage.qml @@ -0,0 +1,243 @@ +/************************************************************************* +** Forum Browser +** +** Copyright (c) 2014 Niklas Wenzel +** Copyright (c) 2013 - 2014 Michael Hall +** +** $QT_BEGIN_LICENSE:GPL$ +** This program is free software; you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation; either version 2 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +** General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; see the file COPYING. If not, write to +** the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +** Boston, MA 02110-1301, USA. +** +** +** $QT_END_LICENSE$ +** +*************************************************************************/ + +import QtQuick 2.2 +import Ubuntu.Components 1.1 +import Ubuntu.Components.ListItems 1.0 +import Ubuntu.Components.Popups 1.0 +import "../components" + +PageWithBottomEdge { + id: threadPage + flickable: null + + property alias current_topic: threadList.current_topic + property int forum_id: -1 + property alias vBulletinAnnouncement: threadList.vBulletinAnnouncement + + property alias loadingSpinnerRunning: loadingSpinner.running + + property int pageCount: Math.floor(threadList.totalPostCount/backend.postsPerPage + (threadList.totalPostCount % backend.postsPerPage === 0 ? 0 : 1)) + + bottomEdgePageSource: "PostCreationPage.qml" + bottomEdgeTitle: i18n.tr("New Post") + bottomEdgeEnabled: backend.currentSession.loggedIn && threadList.canReply && !threadList.isClosed && !vBulletinAnnouncement + + onBottomEdgeReleased: { + if (bottomEdgePage !== null) { + bottomEdgePage.forum_id = forum_id + bottomEdgePage.topic_id = current_topic + bottomEdgePage.posted.connect(threadList.reload) + } + } + + Component.onCompleted: { + header.show() //Workaround to show the header when it was previously hidden in SubForumPage + } + + head.actions: [ + Action { + id: loginAction + text: i18n.tr("Login") + iconName: "contact" + visible: !backend.currentSession.loggedIn + onTriggered: { + pageStack.push(loginPage) + } + }, + Action { + id: gotoAction + text: i18n.tr("Go To Page") + iconName: "view-list-symbolic" + visible: !vBulletinAnnouncement && threadList.totalPostCount > backend.postsPerPage + onTriggered: { + var popup = PopupUtils.open(pageSelectionDialog, pageLabel) + var selected = threadList.firstDisplayedPost / backend.postsPerPage + popup.itemSelector.selectedIndex = selected +// popup.itemSelector.positionViewAtIndex(selected, ListView.Center) //TODO: Add to UI Toolkit? + } + }, + Action { + id: reloadAction + text: i18n.tr("Reload") + iconName: "reload" + onTriggered: { + threadList.reload() + } + } + ] + + head.backAction: Action { + text: i18n.tr("Back") + iconName: "back" + onTriggered: { + pageStack.pop() + threadPage.destroy(500) + } + } + + ActivityIndicator { + id: loadingSpinner + anchors.centerIn: threadList + } + + Row { + id: buttonsRow + + anchors { + top: parent.top + topMargin: units.gu(1) + bottomMargin: units.gu(1) + horizontalCenter: parent.horizontalCenter + } + + spacing: units.gu(2) + + Icon { + name: "media-skip-backward" + width: units.gu(4) + height: units.gu(4) + + MouseArea { + anchors.fill: parent + + onClicked: { + if (threadList.firstDisplayedPost !== 0) { + threadList.loadPosts(0, backend.postsPerPage); + } + } + } + } + + Icon { + name: "media-playback-start-rtl" + width: units.gu(4) + height: units.gu(4) + + MouseArea { + anchors.fill: parent + + onClicked: { + if (threadList.firstDisplayedPost > 0) { + threadList.loadPosts(Math.max(threadList.firstDisplayedPost - backend.postsPerPage, 0), backend.postsPerPage); + } + } + } + } + + Label { + id: pageLabel + anchors.verticalCenter: parent.verticalCenter + fontSize: "large" + + text: threadList.totalPostCount !== -1 ? (Math.floor(threadList.firstDisplayedPost/backend.postsPerPage + 1) + " / " + pageCount) : "" + } + + Icon { + name: "media-playback-start" + width: units.gu(4) + height: units.gu(4) + + MouseArea { + anchors.fill: parent + + onClicked: { + if (threadList.lastDisplayedPost < threadList.totalPostCount - 1) { + threadList.loadPosts(threadList.lastDisplayedPost + 1, backend.postsPerPage); + } + } + } + } + + Icon { + name: "media-skip-forward" + width: units.gu(4) + height: units.gu(4) + + MouseArea { + anchors.fill: parent + + onClicked: { + var postsOnLastPage = ((threadList.totalPostCount) % backend.postsPerPage); + var beginningLastPage = threadList.totalPostCount - (postsOnLastPage === 0 ? backend.postsPerPage : postsOnLastPage); + if (beginningLastPage !== threadList.firstDisplayedPost) { + threadList.loadPosts(beginningLastPage, backend.postsPerPage); + } + } + } + } + } + + ThreadList { + id: threadList + anchors { + top: buttonsRow.bottom + left: parent.left + right: parent.right + bottom: parent.bottom + } + } + + Component { + id: pageSelectionDialog + + Dialog { + id: dialog + title: i18n.tr("Go to") + + property alias itemSelector: selector + + ItemSelector { + id: selector + expanded: true + + containerHeight: itemHeight * Math.min(model.count, 8) + + model: ListModel { + Component.onCompleted: { + for (var i = 0; i < pageCount; i++) { + append({pageText: qsTr(i18n.tr("Page %1 (Post %2 - %3)")).arg(i + 1).arg(i * 10 + 1).arg(Math.min((i + 1) * 10, threadList.totalPostCount))}) + } + } + } + + delegate: OptionSelectorDelegate { + text: pageText + + onTriggered: { + var firstPost = index * backend.postsPerPage + if (firstPost !== threadList.firstDisplayedPost) { + threadList.loadPosts(firstPost, backend.postsPerPage) + } + PopupUtils.close(dialog) + } + } + } + } + } + +}