├── .coveragerc ├── .gitignore ├── .travis.yml ├── COPYING ├── ChangeLog ├── ChangeLog.md ├── Makefile.am ├── NEWS ├── README ├── README.md ├── app.nix ├── autogen.sh ├── configure.ac ├── data ├── Makefile.am ├── app-menu.ui ├── empty-view.ui ├── icons │ ├── reddit-detach-symbolic.svg │ ├── reddit-detach-symbolic.symbolic.png │ ├── reddit-downvote-symbolic.svg │ ├── reddit-downvote-symbolic.symbolic.png │ ├── reddit-mail-reply-all-symbolic.svg │ ├── reddit-mail-reply-all-symbolic.symbolic.png │ ├── reddit-mail-reply-sender-symbolic.svg │ ├── reddit-mail-reply-sender-symbolic.symbolic.png │ ├── reddit-novote-symbolic.svg │ ├── reddit-novote-symbolic.symbolic.png │ ├── reddit-upvote-symbolic.svg │ ├── reddit-upvote-symbolic.symbolic.png │ ├── today.sam.reddit-is-gtk-symbolic.svg │ └── today.sam.reddit-is-gtk.svg ├── mascot.svg ├── post-top-bar.ui ├── reddit-is-gtk.appdata.xml.in ├── reddit-is-gtk.desktop.in ├── reddit-is-gtk.gresource.xml ├── row-comment.ui ├── row-link.ui ├── settings-window.ui ├── shortcuts-window.ui ├── style.css ├── submit-window.ui ├── subreddit-about.ui ├── today.sam.something-for-reddit.gschema.xml ├── user-about.ui └── webview-toolbar.ui ├── debian ├── changelog ├── compat ├── control ├── copyright ├── rules └── source │ └── format ├── default.nix ├── dev-shell.nix ├── fedora └── fedora.spec ├── po ├── .intltool-merge-cache ├── POTFILES ├── POTFILES.in └── POTFILES.skip ├── publish-flatpak.md ├── reddit-is-gtk.in ├── redditisgtk ├── Makefile.am ├── __init__.py ├── aboutrow.py ├── api.py ├── buttons.py ├── comments.py ├── conftest.py ├── emptyview.py ├── gtktestutil.py ├── gtkutil.py ├── identity.py ├── identitybutton.py ├── main.py ├── mediapreview.py ├── newmarkdown.py ├── palettebutton.py ├── posttopbar.py ├── readcontroller.py ├── settings.py ├── subentry.py ├── sublist.py ├── sublistrows.py ├── submit.py ├── test_aboutrow.py ├── test_api.py ├── test_comments.py ├── test_emptyview.py ├── test_gtkutil.py ├── test_identity.py ├── test_identitybutton.py ├── test_newmarkdown.py ├── test_posttopbar.py ├── test_readcontroller.py ├── test_subentry.py ├── test_sublist.py ├── test_sublistrows.py ├── test_submit.py ├── tests-data │ ├── api__load-more.json │ ├── comments--thread.json │ ├── identity--load.json │ ├── posttopbar--comment.json │ ├── posttopbar--post.json │ ├── snapshots │ │ ├── newmarkdown--code.json │ │ ├── newmarkdown--error-handler.json │ │ ├── newmarkdown--escaping-amp.json │ │ ├── newmarkdown--escaping-code.json │ │ ├── newmarkdown--escaping.json │ │ ├── newmarkdown--heading.json │ │ ├── newmarkdown--hello.json │ │ ├── newmarkdown--hr.json │ │ ├── newmarkdown--inline.json │ │ ├── newmarkdown--link--r.json │ │ ├── newmarkdown--list.json │ │ ├── newmarkdown--quote.json │ │ ├── newmarkdown--super-long.json │ │ └── newmarkdown--super.json │ ├── sublistrows--pm.json │ ├── sublistrows--thumb-from-previews.json │ ├── submit--good-response.json │ └── submit--ratelimit-response.json └── webviews.py ├── screenshots ├── 0.2.1-askreddit.png ├── 0.2.1-dankmemes.png └── 0.2.1-dark.png └── today.sam.reddit-is-gtk.json /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [report] 3 | # Regexes for lines to exclude from consideration 4 | exclude_lines = 5 | # Have to re-enable the standard pragma 6 | pragma: no cover 7 | 8 | # Don't complain about missing debug-only code: 9 | def __repr__ 10 | if self\.debug 11 | if DEBUG 12 | 13 | # Don't worry about unrunnable code: 14 | if __name__ == '__main__': 15 | 16 | # Don't complain if tests don't hit defensive assertion code: 17 | raise AssertionError 18 | raise NotImplementedError 19 | 20 | # Regexs for files to exclude, namely the test files themselves 21 | omit = 22 | **/test_*.py 23 | **/conftest.py 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | /*.bak 3 | /*.lo 4 | /*.o 5 | /*.orig 6 | /*.rej 7 | /*.tab.c 8 | /*~ 9 | .*.sw[nop] 10 | /.deps 11 | /.dirstamp 12 | /.libs 13 | /AUTHORS 14 | /GPATH 15 | /GRTAGS 16 | /GSYMS 17 | /GTAGS 18 | /ID 19 | /INSTALL 20 | /Makefile 21 | /Makefile.in 22 | /TAGS 23 | /_libs 24 | /aclocal.m4 25 | /autom4te.cache 26 | /autoscan.log 27 | /compile 28 | /config.cache 29 | /config.guess 30 | /config.h 31 | /config.h.in 32 | /config.log 33 | /config.lt 34 | /config.status 35 | /config.status.lineno 36 | /config.sub 37 | /configure 38 | /configure.lineno 39 | /configure.scan 40 | /depcomp 41 | /install-sh 42 | /intltool-extract.in 43 | /intltool-merge.in 44 | /intltool-update.in 45 | /libtool 46 | /ltmain.sh 47 | /m4 48 | /missing 49 | /mkinstalldirs 50 | /so_locations 51 | /stamp-h1 52 | /tags 53 | /py-compile 54 | *.tar.xz 55 | *.desktop 56 | *.gresource 57 | *.compiled 58 | *.patch 59 | *.pyc 60 | Makefile 61 | Makefile.in 62 | Makefile.in.in 63 | /po/stamp-it 64 | reddit-is-gtk 65 | *.valid 66 | Makefile.in 67 | /data/reddit-is-gtk.appdata.xml 68 | /data/.sass-cache 69 | /data/style.css.out 70 | /data/style.dark.css.out 71 | /data/style.css.out.map 72 | /debian/autoreconf.* 73 | /debian/files 74 | /debian/reddit-is-gtk.debhelper.log 75 | /debian/reddit-is-gtk.substvars 76 | /__build_prefix 77 | /.pytest_cache/ 78 | .coverage 79 | .flatpak-builder 80 | build-dir 81 | flatpak-repo 82 | htmlcov 83 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | env: 2 | global: 3 | - CC_TEST_REPORTER_ID=ad7d02c4a09f9f9845a84380473e215749292d230080f259395093b25a2b04a4 4 | language: nix 5 | before_script: 6 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 7 | - chmod +x ./cc-test-reporter 8 | - ./cc-test-reporter before-build 9 | script: 10 | - nix-shell dev-shell.nix --command 'echo Downloaded deps' 11 | - xvfb-run nix-shell dev-shell.nix --command './autogen.sh --prefix=$PREFIX && make && make install && pytest --cov=redditisgtk --cov-report xml' 12 | after_script: 13 | - sudo apt-get update 14 | - sudo apt-get install -y python3-dev gcc python3-pip 15 | - sudo pip3 install coverage 16 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT -t coverage.py 17 | -------------------------------------------------------------------------------- /ChangeLog: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/071733a9e6050d0d171e6620c189b7860af56bab/ChangeLog -------------------------------------------------------------------------------- /ChangeLog.md: -------------------------------------------------------------------------------- 1 | # 0.2.2 - “The Bugfix Release ⓇⒺⒹⓊⓍ” 2 | 3 | 26/Nov/2018 4 | 5 | - Fixes sign in issues on newer versions of webkit (#61, #58) 6 | 7 | # 0.2.1 - “The Bugfix Release” 8 | 9 | 25/Nov/2018 10 | 11 | It has been great to come back and revitalize this app after 2 years of 12 | neglect. I'm excited about how far this app can be improved, and think this 13 | release makes a good stepping stone towards future features and improvements. 14 | 15 | ## Added 16 | 17 | - The frontpage is now visible. You can see it by going to `/` in the url bar. 18 | (#56) 19 | - The app now supports Flatpak & NixOS installation 20 | - Performance improvements in the comments view 21 | - The codebase is now tested! I'm pretty sure this is one of the only GTK+ 22 | apps which has proper UI tests - so this should stop more regressions in the 23 | future! 24 | 25 | ## Changed 26 | 27 | - Replaced the markdown engine, should now support more features (#66) 28 | - The alignment in subreddit listings should now be more uniform. (#64) 29 | - Many size related bugs have been fixed (#6, #47, #72) 30 | - The url bar is now less lenient on formatting. Previously, going to `/linux` 31 | would take you to `/r/linux`. Due to the frontpage changes, this no longer 32 | happens 33 | - [PACKAGING] Moved icons from pixmaps to the hicolor directory 34 | - [PACKAGING] Change SCSS compiler from ruby sass to sassc 35 | -------------------------------------------------------------------------------- /Makefile.am: -------------------------------------------------------------------------------- 1 | ACLOCAL_AMFLAGS = -I m4 2 | NULL = 3 | 4 | bin_SCRIPTS = reddit-is-gtk 5 | 6 | SUBDIRS = redditisgtk data po 7 | 8 | EXTRA_DIST = \ 9 | reddit-is-gtk.in \ 10 | $(NULL) 11 | 12 | CLEANFILES = \ 13 | $(bin_SCRIPTS) 14 | $(NULL) 15 | 16 | MAINTAINERCLEANFILES = \ 17 | $(srcdir)/INSTALL \ 18 | $(srcdir)/aclocal.m4 \ 19 | $(srcdir)/autoscan.log \ 20 | $(srcdir)/compile \ 21 | $(srcdir)/config.guess \ 22 | $(srcdir)/config.h.in \ 23 | $(srcdir)/config.sub \ 24 | $(srcdir)/configure.scan \ 25 | $(srcdir)/depcomp \ 26 | $(srcdir)/install-sh \ 27 | $(srcdir)/ltmain.sh \ 28 | $(srcdir)/missing \ 29 | $(srcdir)/mkinstalldirs \ 30 | $(NULL) 31 | 32 | GITIGNOREFILES = \ 33 | m4 \ 34 | $(NULL) 35 | 36 | reddit-is-gtk: reddit-is-gtk.in Makefile 37 | $(AM_V_GEN)sed \ 38 | -e s!\@srcdir\@!$(abs_top_srcdir)! \ 39 | -e s!\@prefix\@!$(prefix)! \ 40 | -e s!\@datadir\@!$(datadir)! \ 41 | -e s!\@pkgdatadir\@!$(pkgdatadir)! \ 42 | -e s!\@libexecdir\@!$(libexecdir)! \ 43 | -e s!\@libdir\@!$(libdir)! \ 44 | -e s!\@pkglibdir\@!$(pkglibdir)! \ 45 | -e s!\@localedir\@!$(localedir)! \ 46 | -e s!\@pythondir\@!$(pythondir)! \ 47 | -e s!\@pyexecdir\@!$(pyexecdir)! \ 48 | -e s!\@PACKAGE\@!$(PACKAGE)! \ 49 | -e s!\@VERSION\@!$(VERSION)! \ 50 | < $< > $@ 51 | chmod a+x $@ 52 | 53 | all-local: reddit-is-gtk 54 | 55 | -include $(top_srcdir)/git.mk 56 | 57 | -------------------------------------------------------------------------------- /NEWS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/071733a9e6050d0d171e6620c189b7860af56bab/NEWS -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/071733a9e6050d0d171e6620c189b7860af56bab/README -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Something For Reddit 2 | 3 | [![Build Status](https://travis-ci.org/samdroid-apps/something-for-reddit.svg?branch=master)](https://travis-ci.org/samdroid-apps/something-for-reddit)[![Maintainability](https://api.codeclimate.com/v1/badges/99d7155d2d7ad46df42e/maintainability)](https://codeclimate.com/github/samdroid-apps/something-for-reddit/maintainability)[![Test Coverage](https://api.codeclimate.com/v1/badges/99d7155d2d7ad46df42e/test_coverage)](https://codeclimate.com/github/samdroid-apps/something-for-reddit/test_coverage) 4 | 5 | A simple Reddit client for GNOME, built for touch, mouse and VIM keyboards. 6 | 7 | ![Screenshot of AskReddit](https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/master/screenshots/0.2.1-askreddit.png) 8 | 9 | ![Screenshot of the content view](https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/master/screenshots/0.2.1-dankmemes.png) 10 | 11 | ![Screenshot of the dark view](https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/master/screenshots/0.2.1-dark.png) 12 | 13 | # Features 14 | 15 | * Touchscreen tested interface 16 | * VIM style keybindings 17 | * View subreddits, comments and user pages 18 | * Vote on comments and links, write replies 19 | * Integrated WebKit2 browser for viewing links 20 | * Multi-account support 21 | 22 | # Packages 23 | 24 | Up to date: 25 | 26 | | Distro | Command | Info | 27 | |--------|---------|------| 28 | | Flatpak | `flatpak install https://flatpak.dl.sam.today/today.sam.reddit-is-gtk.flatpakref` | | 29 | | NixOS | Run `nix-shell --command reddit-is-gtk` inside the git repo | see `app.nix` for package | 30 | | openSUSE | | https://software.opensuse.org/package/something-for-reddit | 31 | 32 | Being updated (feel free to contribute): 33 | 34 | | Distro | Command | Info | 35 | |--------|---------|------| 36 | | Fedora | `dnf copr enable samtoday/something-for-reddit; dnf install something-for-reddit` | https://copr.fedorainfracloud.org/coprs/samtoday/something-for-reddit/ | 37 | | Archlinux | `yaourt -S something-for-reddit-git` | https://aur.archlinux.org/packages/something-for-reddit-git/ | 38 | 39 | # Installing 40 | 41 | I did this ages ago, so I don't really remember. 42 | 43 | 1. Install `gnome-common` (and autotools, etc.) 44 | 2. Install the `python3-arrow` and `python3-markdown` 45 | 3. Install the `sassc` (from your package manager) 46 | 47 | Then you can just install it like any usual program. 48 | 49 | 1. Download the source code (eg. `git clone https://github.com/samdroid-apps/something-for-reddit; cd something-for-reddit`) 50 | 2. `./autogen.sh` 51 | 3. `make` 52 | 4. `sudo make install` 53 | 54 | There is a .desktop file, but it is also `reddit-is-gtk` command 55 | 56 | Please report the bugs or deficiencies that you find via Github Issues. 57 | 58 | # Development 59 | 60 | A development shell using Nix is provided in `dev-shell.nix`. You can run it 61 | with the following command: 62 | 63 | ```sh 64 | nix-shell dev-shell.nix 65 | ``` 66 | 67 | This will include instructions to run the app. 68 | 69 | # Flatpak 70 | 71 | You can build the flatpak with the following commands: 72 | 73 | ```sh 74 | flatpak-builder build-dir today.sam.reddit-is-gtk.json --force-clean --install 75 | ``` 76 | 77 | To build the from the local copy of your source, use change the sources option 78 | in the flatpak to as follows: 79 | 80 | ```json 81 | "sources": [ 82 | { 83 | "type": "dir", 84 | "path": ".", 85 | "skip": [ 86 | "__build_prefix", 87 | ".flatpak-builder", 88 | "flatpak-repo" 89 | ] 90 | } 91 | ] 92 | ``` 93 | 94 | # Outdated Roadmap 95 | 96 | Feel free to contribute and do whatever you want. 97 | 98 | Please try and use Flake8! 99 | 100 | * **Any app icon suggestions/designs are appreciated** 101 | - The current one isn't great at all 102 | * Replace the media previews, integrate them with the comments view 103 | * Use gettext 104 | - **If you are interested in translateing this app, please email me!** 105 | * Search all the subreddits on reddit 106 | * Manage subreddits dialog 107 | * Better handle private messages 108 | * Multireddits in the urlbar list 109 | * Mutlireddit add/remove subreddits 110 | 111 | Long Term 112 | 113 | * Optimise the comments view performance 114 | * Separate the reddit json parsing from the view components 115 | * Support other sites (eg. hackernews) 116 | -------------------------------------------------------------------------------- /app.nix: -------------------------------------------------------------------------------- 1 | { stdenv 2 | , intltool 3 | , gdk_pixbuf 4 | , python3 5 | , pkgconfig 6 | , gtk3 7 | , glib 8 | , hicolor_icon_theme 9 | , makeWrapper 10 | , itstool 11 | , gnome3 12 | , autoconf 13 | , pango 14 | , atk 15 | , sassc }: 16 | 17 | let 18 | py = python3; 19 | # VERSION: 20 | version = "0.2.2"; 21 | in 22 | stdenv.mkDerivation rec { 23 | name = "something-for-reddit-${version}"; 24 | 25 | src = ./.; 26 | 27 | propagatedUserEnvPkgs = [ gnome3.gnome_themes_standard ]; 28 | nativeBuildInputs = [ 29 | pkgconfig 30 | autoconf 31 | gnome3.gnome-common 32 | intltool 33 | itstool 34 | sassc 35 | py.pkgs.pytest 36 | py.pkgs.pytestcov 37 | ]; 38 | 39 | buildInputs = [ 40 | gtk3 41 | glib 42 | gdk_pixbuf 43 | pango 44 | py 45 | hicolor_icon_theme 46 | gnome3.gsettings_desktop_schemas 47 | makeWrapper 48 | gnome3.webkitgtk 49 | gnome3.libsoup 50 | ] ++ (with py.pkgs; [ 51 | pygobject3 52 | markdown 53 | arrow 54 | ]); 55 | 56 | preConfigure = '' 57 | ./autogen.sh 58 | ''; 59 | 60 | extraLibs = [ 61 | gnome3.webkitgtk 62 | gnome3.libsoup 63 | gtk3 64 | glib 65 | pango 66 | # full output for the gi typelib files: 67 | pango.out 68 | atk 69 | gdk_pixbuf 70 | ]; 71 | extraTypelibPath = let 72 | paths = map (lib: "${lib}/lib/girepository-1.0/") extraLibs; 73 | in 74 | builtins.concatStringsSep ":" paths; 75 | extraLibPath = stdenv.lib.makeLibraryPath extraLibs; 76 | 77 | preFixup = '' 78 | wrapProgram "$out/bin/reddit-is-gtk" \ 79 | --prefix XDG_DATA_DIRS : "$out/share:${gnome3.gnome_themes_standard}/share:$XDG_ICON_DIRS:$GSETTINGS_SCHEMAS_PATH" \ 80 | --prefix GI_TYPELIB_PATH : "${extraTypelibPath}:$GI_TYPELIB_PATH" \ 81 | --prefix LD_LIBRARY_PATH : "${extraLibPath}" \ 82 | --prefix PYTHONPATH : "$PYTHONPATH" 83 | ''; 84 | 85 | meta = with stdenv.lib; { 86 | homepage = https://github.com/samdroid-apps/something-for-reddit; 87 | description = "A Reddit Client For GNOME (with Gtk+ and Python)"; 88 | maintainers = with maintainers; [ samdroid-apps ]; 89 | license = licenses.gpl3; 90 | platforms = platforms.linux; 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /autogen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Run this to generate all the initial makefiles, etc. 3 | 4 | srcdir=`dirname $0` 5 | test -z "$srcdir" && srcdir=. 6 | 7 | PKG_NAME="reddit-is-gtk" 8 | 9 | test -f $srcdir/configure.ac || { 10 | echo -n "**Error**: Directory "\`$srcdir\'" does not look like the" 11 | echo " top-level reddit-is-gtk directory" 12 | exit 1 13 | } 14 | 15 | which gnome-autogen.sh || { 16 | echo "You need to install gnome-common from GNOME Git (or from" 17 | echo "your OS vendor's package manager)." 18 | exit 1 19 | } 20 | 21 | (cd "$srcdir" ; 22 | test -d m4 || mkdir m4/ ; 23 | git submodule update --init --recursive ; 24 | ) 25 | touch AUTHORS 26 | . gnome-autogen.sh 27 | -------------------------------------------------------------------------------- /configure.ac: -------------------------------------------------------------------------------- 1 | AC_PREREQ(2.63) 2 | # VERSION: 3 | AC_INIT([something-for-reddit], 4 | [0.2.2], 5 | [https://github.com/samdroid-apps/something-for-reddit/issues], 6 | [something-for-reddit], 7 | [https://github.com/samdroid-apps/something-for-reddit]) 8 | AC_CONFIG_MACRO_DIR([m4]) 9 | AC_CONFIG_SRCDIR([Makefile.am]) 10 | AC_CONFIG_HEADERS(config.h) 11 | AM_INIT_AUTOMAKE([1.11 tar-ustar dist-xz no-dist-gzip -Wno-portability subdir-objects]) 12 | AM_MAINTAINER_MODE([enable]) 13 | m4_ifdef([AM_SILENT_RULES],[AM_SILENT_RULES([yes])]) 14 | 15 | AM_PATH_PYTHON([3.3]) 16 | 17 | AC_PROG_CC 18 | AM_PROG_CC_C_O 19 | # LT_INIT([disable-static]) 20 | 21 | PKG_PROG_PKG_CONFIG([0.22]) 22 | 23 | GETTEXT_PACKAGE=reddit-is-gtk 24 | AC_SUBST(GETTEXT_PACKAGE) 25 | AC_DEFINE_UNQUOTED(GETTEXT_PACKAGE, "$GETTEXT_PACKAGE", 26 | [The prefix for our gettext translation domains.]) 27 | IT_PROG_INTLTOOL(0.26) 28 | 29 | # I don't know how to get this working on Nix 30 | # TODO: uncomment the following once build works 31 | # AX_PYTHON_MODULE([arrow], [For date humanization]) 32 | # AX_PYTHON_MODULE([markdown], [For reddit markdown]) 33 | 34 | GLIB_GSETTINGS 35 | # TODO: get this working on Nix 36 | # GOBJECT_INTROSPECTION_REQUIRE([1.35.9]) 37 | PKG_CHECK_MODULES([GTK], [gtk+-3.0 >= 3.13.2]) 38 | PKG_CHECK_MODULES([LIBSOUP], [libsoup-2.4 >= 2.4]) 39 | # TODO: get this working on Nix 40 | # AX_PATH_PROG([sass], [For compiling style sheets]) 41 | 42 | GLIB_COMPILE_RESOURCES=`$PKG_CONFIG --variable glib_compile_resources gio-2.0` 43 | AC_SUBST(GLIB_COMPILE_RESOURCES) 44 | 45 | AC_CONFIG_FILES([ 46 | Makefile 47 | data/Makefile 48 | redditisgtk/Makefile 49 | po/Makefile.in 50 | ]) 51 | AC_OUTPUT 52 | -------------------------------------------------------------------------------- /data/Makefile.am: -------------------------------------------------------------------------------- 1 | style.dark.css.out: style.css 2 | $(AM_V_GEN) echo '$$dark: true;' | cat - style.css | sassc -s style.dark.css.out 3 | 4 | style.css.out: style.css 5 | $(AM_V_GEN) echo '$$dark: false;' | cat - style.css | sassc -s style.css.out 6 | 7 | resource_files = $(shell $(GLIB_COMPILE_RESOURCES) --sourcedir=$(srcdir) --sourcedir=$(builddir) --generate-dependencies $(builddir)/reddit-is-gtk.gresource.xml) 8 | reddit-is-gtk.gresource: reddit-is-gtk.gresource.xml style.css.out style.dark.css.out $(resource_files) 9 | $(AM_V_GEN) $(GLIB_COMPILE_RESOURCES) --target=$@ --sourcedir=$(srcdir) --sourcedir=$(builddir) $< 10 | 11 | resourcedir = $(pkgdatadir) 12 | resource_DATA = reddit-is-gtk.gresource 13 | 14 | appsdir = $(datadir)/applications 15 | apps_DATA = reddit-is-gtk.desktop 16 | 17 | @INTLTOOL_DESKTOP_RULE@ 18 | 19 | appdatadir = $(datadir)/appdata 20 | appdata_DATA = $(appdata_in_files:.xml.in=.xml) 21 | appdata_in_files = reddit-is-gtk.appdata.xml.in 22 | 23 | metainfodir = $(datadir)/metainfo 24 | metainfo_DATA = reddit-is-gtk.appdata.xml 25 | 26 | @INTLTOOL_XML_RULE@ 27 | 28 | hicolor_app_iconsdir = $(datadir)/icons/hicolor/scalable/apps 29 | hicolor_app_icons_DATA = icons/today.sam.reddit-is-gtk.svg 30 | hicolor_app_icons_symbolicdir = $(datadir)/icons/hicolor/symbolic/apps 31 | hicolor_app_icons_symbolic_DATA = icons/today.sam.reddit-is-gtk-symbolic.svg 32 | hicolor_icons_symbolicdir = $(datadir)/icons/hicolor/scalable/actions 33 | hicolor_icons_symbolic_DATA = \ 34 | icons/reddit-mail-reply-all-symbolic.svg \ 35 | icons/reddit-mail-reply-sender-symbolic.svg \ 36 | icons/reddit-detach-symbolic.svg \ 37 | icons/reddit-upvote-symbolic.svg \ 38 | icons/reddit-novote-symbolic.svg \ 39 | icons/reddit-downvote-symbolic.svg 40 | # FIXME: flatpak doesn't seem to support SVG icons at this point, god knows why 41 | hicolor_icons_24dir = $(datadir)/icons/hicolor/24x24/actions 42 | hicolor_icons_24_DATA = \ 43 | icons/reddit-mail-reply-all-symbolic.symbolic.png \ 44 | icons/reddit-mail-reply-sender-symbolic.symbolic.png \ 45 | icons/reddit-detach-symbolic.symbolic.png \ 46 | icons/reddit-upvote-symbolic.symbolic.png \ 47 | icons/reddit-novote-symbolic.symbolic.png \ 48 | icons/reddit-downvote-symbolic.symbolic.png 49 | hicolor_icon_files = \ 50 | $(hicolor_app_icons_DATA) \ 51 | $(hicolor_app_icons_symbolic_DATA) \ 52 | $(hicolor_icons_symbolic_DATA) \ 53 | $(hicolor_icons_24_DATA) 54 | 55 | install-data-hook: update-icon-cache 56 | uninstall-hook: update-icon-cache 57 | update-icon-cache: 58 | @-if test -z "$(DESTDIR)"; then \ 59 | echo "Updating Gtk hicolor icon cache."; \ 60 | $(gtk_update_hicolor_icon_cache); \ 61 | else \ 62 | echo "*** Icon cache not updated. After (un)install, run this:"; \ 63 | echo "*** $(gtk_update_hicolor_icon_cache)"; \ 64 | fi 65 | 66 | 67 | gsettings_SCHEMAS = today.sam.something-for-reddit.gschema.xml 68 | 69 | # For uninstalled use 70 | gschemas.compiled: $(gsettings_SCHEMAS) Makefile 71 | $(AM_V_GEN) $(GLIB_COMPILE_SCHEMAS) $(builddir) 72 | 73 | @GSETTINGS_RULES@ 74 | 75 | EXTRA_DIST = \ 76 | $(resource_files) \ 77 | $(hicolor_icon_files) \ 78 | reddit-is-gtk.desktop.in \ 79 | reddit-is-gtk.appdata.xml.in \ 80 | reddit-is-gtk.gresource.xml \ 81 | today.sam.something-for-reddit.gschema.xml \ 82 | style.css \ 83 | $(NULL) 84 | 85 | CLEANFILES = \ 86 | reddit-is-gtk.gresource \ 87 | reddit-is-gtk.appdata.xml \ 88 | $(apps_DATA) \ 89 | *.valid \ 90 | gschemas.compiled \ 91 | style.css.out \ 92 | style.css.out.map \ 93 | $(NULL) 94 | -------------------------------------------------------------------------------- /data/app-menu.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | app.settings 7 | Settings 8 | 9 |
10 |
11 | 12 | app.shortcuts 13 | Keyboard Shortcuts 14 | 15 | 16 | app.about 17 | About 18 | 19 | 20 | app.issues 21 | GitHub Issues 22 | 23 | 24 | app.quit 25 | Quit 26 | <Primary>q 27 | 28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /data/empty-view.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | True 6 | False 7 | center 8 | center 9 | vertical 10 | 6 11 | 12 | 13 | True 14 | False 15 | 128 16 | face-plain-symbolic 17 | 18 | 19 | False 20 | True 21 | 0 22 | 23 | 24 | 25 | 26 | True 27 | False 28 | This Is Empty 29 | 30 | 31 | 32 | 33 | 34 | 35 | False 36 | True 37 | 1 38 | 39 | 40 | 41 | 42 | Just Do It 43 | True 44 | True 45 | center 46 | False 47 | 50 | 51 | 52 | False 53 | True 54 | 2 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /data/icons/reddit-detach-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 20 | 40 | Gnome Symbolic Icon Theme 42 | 44 | 46 | 47 | 49 | image/svg+xml 50 | 52 | Gnome Symbolic Icon Theme 53 | 54 | 55 | 56 | 60 | 64 | 68 | 72 | 76 | 84 | 92 | 100 | 107 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /data/icons/reddit-detach-symbolic.symbolic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/071733a9e6050d0d171e6620c189b7860af56bab/data/icons/reddit-detach-symbolic.symbolic.png -------------------------------------------------------------------------------- /data/icons/reddit-downvote-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 22 | 24 | image/svg+xml 25 | 27 | Gnome Symbolic Icon Theme 28 | 29 | 30 | 31 | 65 | 74 | 75 | Gnome Symbolic Icon Theme 77 | 79 | 85 | 90 | 95 | 100 | 104 | 109 | 110 | 115 | 120 | 126 | 132 | 133 | -------------------------------------------------------------------------------- /data/icons/reddit-downvote-symbolic.symbolic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/071733a9e6050d0d171e6620c189b7860af56bab/data/icons/reddit-downvote-symbolic.symbolic.png -------------------------------------------------------------------------------- /data/icons/reddit-mail-reply-all-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | image/svg+xml 55 | -------------------------------------------------------------------------------- /data/icons/reddit-mail-reply-all-symbolic.symbolic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/071733a9e6050d0d171e6620c189b7860af56bab/data/icons/reddit-mail-reply-all-symbolic.symbolic.png -------------------------------------------------------------------------------- /data/icons/reddit-mail-reply-sender-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /data/icons/reddit-mail-reply-sender-symbolic.symbolic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/071733a9e6050d0d171e6620c189b7860af56bab/data/icons/reddit-mail-reply-sender-symbolic.symbolic.png -------------------------------------------------------------------------------- /data/icons/reddit-novote-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 25 | 27 | image/svg+xml 28 | 30 | Gnome Symbolic Icon Theme 31 | 32 | 33 | 34 | 68 | 77 | 78 | Gnome Symbolic Icon Theme 80 | 82 | 88 | 93 | 98 | 103 | 107 | 112 | 113 | 118 | 123 | 129 | 135 | 136 | -------------------------------------------------------------------------------- /data/icons/reddit-novote-symbolic.symbolic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/071733a9e6050d0d171e6620c189b7860af56bab/data/icons/reddit-novote-symbolic.symbolic.png -------------------------------------------------------------------------------- /data/icons/reddit-upvote-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 25 | 27 | image/svg+xml 28 | 30 | Gnome Symbolic Icon Theme 31 | 32 | 33 | 34 | 69 | 78 | 79 | Gnome Symbolic Icon Theme 81 | 83 | 89 | 94 | 99 | 104 | 108 | 114 | 115 | 120 | 125 | 131 | 137 | 138 | -------------------------------------------------------------------------------- /data/icons/reddit-upvote-symbolic.symbolic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/071733a9e6050d0d171e6620c189b7860af56bab/data/icons/reddit-upvote-symbolic.symbolic.png -------------------------------------------------------------------------------- /data/post-top-bar.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | True 8 | False 9 | 10 | 11 | True 12 | True 13 | True 14 | 15 | 16 | 17 | True 18 | False 19 | pan-down-symbolic 20 | 21 | 22 | 25 | 26 | 27 | False 28 | True 29 | 0 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | [name] 41 | True 42 | True 43 | True 44 | 45 | 46 | False 47 | True 48 | 3 49 | 50 | 51 | 52 | 53 | [score] 54 | True 55 | True 56 | True 57 | 58 | 59 | False 60 | True 61 | 4 62 | 63 | 64 | 65 | 66 | True 67 | True 68 | True 69 | 70 | 71 | 72 | True 73 | False 74 | emblem-favorite-symbolic 75 | 76 | 77 | 78 | 79 | False 80 | True 81 | 5 82 | 83 | 84 | 85 | 86 | [time] 87 | True 88 | True 89 | True 90 | 91 | 92 | False 93 | True 94 | 6 95 | 96 | 97 | 98 | 99 | [subreddit] 100 | True 101 | True 102 | True 103 | 104 | 105 | False 106 | True 107 | 7 108 | 109 | 110 | 111 | 112 | True 113 | True 114 | True 115 | 116 | 117 | True 118 | False 119 | reddit-mail-reply-all-symbolic 120 | 121 | 122 | 123 | 124 | False 125 | True 126 | 8 127 | 128 | 129 | 130 | 131 | True 132 | True 133 | True 134 | 135 | 136 | 137 | True 138 | False 139 | view-refresh-symbolic 140 | 141 | 142 | 143 | 144 | False 145 | True 146 | 9 147 | 148 | 149 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /data/reddit-is-gtk.appdata.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | reddit-is-gtk.desktop 4 | CC0-1.0 5 | GPL-3.0+ 6 | <_name>Something For Reddit 7 | <_summary>Waste time with a beautiful GUI 8 | 9 | <_p> 10 | Something for Reddit is a Reddit client for GNOME, built from 11 | the ground up using the Gtk+ 3 framework. It designed with a 12 | usability first focus - supporting both touchscreen and VIM style 13 | keybindings. With this interface, you can view subreddits, 14 | comments, your inbox and user pages. You can sign-into Reddit 15 | accounts with the easy account switcher, allowing you to vote, 16 | write replies and save favorites. 17 | 18 | <_p> 19 | If you hate reading text, here is the same information as above in 20 | dot point format: 21 | 22 |
    23 | <_li>Touchscreen tested interface 24 | <_li>VIM style keybindings 25 | <_li>View subreddits, comments and user pages 26 | <_li>Vote on comments and links, write replies 27 | <_li>Integrated WebKit2 browser for vieweing links 28 | <_li>Multi-account support 29 |
30 |
31 | 32 | 33 | https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/master/screenshots/0.2.1-askreddit.png 34 | 35 | 36 | https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/master/screenshots/0.2.1-dankmemes.png 37 | 38 | 39 | https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/master/screenshots/0.2.1-dark.png 40 | 41 | 42 | https://github.com/samdroid-apps/something-for-reddit 43 | https://github.com/samdroid-apps/something-for-reddit/issues 44 | sam@sam.today 45 | 46 | 47 |
48 | -------------------------------------------------------------------------------- /data/reddit-is-gtk.desktop.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | _Name=Something For Reddit 3 | _GenericName=Reddit Browser 4 | _Comment=Waste time with a nice GUI 5 | Exec=reddit-is-gtk 6 | Icon=today.sam.reddit-is-gtk 7 | Terminal=false 8 | Type=Application 9 | Categories=GNOME;GTK;Network;News; 10 | _Keywords=Reddit;GTK; 11 | StartupNotify=true 12 | StartupWMClass=reddit-is-gtk 13 | -------------------------------------------------------------------------------- /data/reddit-is-gtk.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | style.css.out 5 | style.dark.css.out 6 | app-menu.ui 7 | row-link.ui 8 | user-about.ui 9 | empty-view.ui 10 | row-comment.ui 11 | post-top-bar.ui 12 | submit-window.ui 13 | subreddit-about.ui 14 | webview-toolbar.ui 15 | shortcuts-window.ui 16 | settings-window.ui 17 | 18 | 19 | -------------------------------------------------------------------------------- /data/row-comment.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | True 7 | False 8 | True 9 | 10 | 11 | 16 12 | True 13 | False 14 | vertical 15 | 16 | 17 | True 18 | False 19 | face-devilish 20 | 21 | 22 | False 23 | True 24 | 0 25 | 26 | 27 | 28 | 29 | True 30 | False 31 | starred-symbolic 32 | 33 | 34 | False 35 | True 36 | 1 37 | 38 | 39 | 40 | 41 | True 42 | False 43 | mail-unread-symbolic 44 | 45 | 46 | False 47 | True 48 | 2 49 | 50 | 51 | 52 | 53 | False 54 | True 55 | 0 56 | 57 | 58 | 59 | 60 | True 61 | False 62 | True 63 | 64 | 65 | 5 hours ago 66 | True 67 | True 68 | True 69 | 0 70 | 73 | 74 | 75 | 0 76 | 0 77 | 78 | 79 | 80 | 81 | Author 82 | True 83 | True 84 | True 85 | 88 | 89 | 90 | 1 91 | 0 92 | 93 | 94 | 95 | 96 | /r/gaming 97 | True 98 | True 99 | True 100 | 1 101 | 104 | 105 | 106 | 2 107 | 0 108 | 109 | 110 | 111 | 112 | True 113 | False 114 | True 115 | 116 | 117 | True 118 | False 119 | 0 120 | reddit-mail-reply-sender-symbolic 121 | 3 122 | 123 | 124 | False 125 | True 126 | 0 127 | 128 | 129 | 130 | 131 | True 132 | False 133 | 0 134 | mail-unread-symbolic 135 | 3 136 | 137 | 138 | False 139 | True 140 | 1 141 | 142 | 143 | 144 | 145 | True 146 | False 147 | Post title 148 | True 149 | word-char 150 | 0 151 | 152 | 153 | 154 | 155 | 156 | False 157 | True 158 | 2 159 | 160 | 161 | 164 | 165 | 166 | 0 167 | 1 168 | 3 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | False 177 | True 178 | 1 179 | 180 | 181 | 184 | 185 | 186 | -------------------------------------------------------------------------------- /data/settings-window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | False 7 | Settings 8 | 9 | 10 | True 11 | False 12 | 6 13 | 6 14 | 6 15 | 6 16 | vertical 17 | 6 18 | 19 | 20 | True 21 | False 22 | 23 | 24 | True 25 | False 26 | end 27 | center 28 | 10 29 | 10 30 | Theme 31 | 1 32 | 33 | 34 | False 35 | True 36 | 0 37 | 38 | 39 | 40 | 41 | True 42 | False 43 | True 44 | 45 | Default 46 | Dark 47 | Light 48 | 49 | 50 | 51 | False 52 | True 53 | 1 54 | 55 | 56 | 57 | 58 | False 59 | True 60 | 0 61 | 62 | 63 | 64 | 65 | False 66 | center 67 | 10 68 | 10 69 | 10 70 | 71 | 72 | True 73 | False 74 | dialog-warning-symbolic 75 | 6 76 | 77 | 78 | False 79 | True 80 | 0 81 | 82 | 83 | 84 | 85 | True 86 | False 87 | Sam is a <s>horrible programmer</s> doesn't know how 88 | to implement this, so changing the theme will 89 | require a restart 90 | True 91 | 92 | 93 | False 94 | True 95 | 1 96 | 97 | 98 | 99 | 100 | False 101 | True 102 | 1 103 | 104 | 105 | 106 | 107 | True 108 | False 109 | 110 | 111 | True 112 | False 113 | end 114 | center 115 | 10 116 | Default Subreddiit 117 | 118 | 119 | False 120 | True 121 | 0 122 | 123 | 124 | 125 | 126 | True 127 | True 128 | True 129 | /r/defaultsubreddit 130 | 131 | 132 | False 133 | True 134 | 1 135 | 136 | 137 | 138 | 139 | False 140 | True 141 | 2 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | -------------------------------------------------------------------------------- /data/style.css: -------------------------------------------------------------------------------- 1 | /* define $dark, based on theme darkness */ 2 | 3 | $gnome-gray: if($dark, rgb(51, 57, 59), rgb(232, 232, 231)); 4 | $popover-bg: if($dark, rgb(57, 63, 63), rgb(232, 232, 231)); 5 | $separator: if($dark, rgb(34, 34, 31), rgb(165, 165, 161)); 6 | $blue: if($dark, #215D9C, #4a90d9); 7 | $white: if($dark, black, white); 8 | $orange: if($dark, #ce5c00, #f57900); 9 | $purple: if($dark, #5c3566, #75507b); 10 | $green: if($dark, #4e9a06, #73d216); 11 | $red: if($dark, #a40000, #cc0000); 12 | 13 | /* Some more Tango colors, used for the comments */ 14 | $lightskyblue: if($dark, #0a3050, #daeeff); 15 | $lightplum: if($dark, #371740, #fce0ff); 16 | $lightchameleon: if($dark, #2a5703, #e4ffc7); 17 | $lightchocolate: if($dark, #503000, #faf0d7); 18 | $lightorange: if($dark, #8c3700, #fff0d7); 19 | 20 | @mixin button-bg($base-color) { 21 | &, &:backdrop { 22 | color: white; 23 | text-shadow: none; 24 | } 25 | background: linear-gradient($base-color, shade($base-color, 0.8)); 26 | 27 | &.flat, &:backdrop { 28 | background: $base-color; 29 | } 30 | &:hover { 31 | background: linear-gradient(lighter($base-color), $base-color); 32 | } 33 | &:active, &:checked { 34 | background: linear-gradient(darker($base-color), $base-color); 35 | } 36 | &:disabled { 37 | background: linear-gradient(darker($base-color), shade($base-color, 0.3)); 38 | color: white; 39 | } 40 | } 41 | 42 | .root-comments-bar, .root-comments-label { 43 | margin-left: 12px; 44 | } 45 | 46 | @keyframes angry-comment { 47 | from { box-shadow: inset 0 0 20px $red; } 48 | } 49 | 50 | .comments-view { 51 | background: transparent; 52 | 53 | &.first > row { 54 | padding-left: 12px; 55 | } 56 | 57 | &:not(.first) > row { 58 | margin-left: 12px; 59 | } 60 | 61 | row { 62 | padding: 0; 63 | } 64 | 65 | revealer list { 66 | margin: 0; 67 | } 68 | 69 | row > box > label { 70 | padding-top: 3px; 71 | padding-bottom: 3px; 72 | padding-left: 5px; 73 | border-bottom: none; 74 | } 75 | 76 | row.angry { 77 | animation: 0.5s angry-comment ease-out; 78 | } 79 | 80 | @mixin row($bg) { 81 | & > row { 82 | background: $bg; 83 | transition: background 0.25s linear; 84 | } 85 | 86 | & > row:focus { 87 | @if $dark { 88 | background: darker($bg); 89 | } @else { 90 | background: white; 91 | } 92 | } 93 | 94 | & > row > box > widget > .post-top-bar.linked > button, 95 | & > row > box > button { 96 | @if $dark { 97 | @include button-bg(lighter($bg)); 98 | } @else { 99 | @include button-bg(darker($bg)); 100 | } 101 | } 102 | } 103 | 104 | &.depth-0 { 105 | @include row($lightskyblue); 106 | } 107 | 108 | &.depth-1 { 109 | @include row($lightplum); 110 | } 111 | 112 | &.depth-2 { 113 | @include row($lightchameleon); 114 | } 115 | 116 | &.depth-3 { 117 | @include row($lightchocolate); 118 | } 119 | 120 | &.depth-4 { 121 | @include row($lightorange); 122 | } 123 | } 124 | 125 | /* 126 | * (Left) list styling 127 | */ 128 | 129 | @keyframes pulse { 130 | from { box-shadow: inset 0 0 3px $blue; } 131 | 50% { box-shadow: inset 0 0 6px $blue; } 132 | to { box-shadow: inset 0 0 3px $blue; } 133 | } 134 | 135 | @keyframes angry { 136 | from { box-shadow: inset 0 0 6px $red; } 137 | } 138 | 139 | 140 | .link-row { 141 | border-bottom: 1px solid $separator; 142 | 143 | &.read { 144 | background: $gnome-gray; 145 | 146 | .reply-to-what { 147 | background: $white; 148 | } 149 | } 150 | 151 | &.sticky { 152 | @if $dark { 153 | background: darker(darker($green)); 154 | } @else { 155 | background: lighter(lighter($green)); 156 | } 157 | } 158 | 159 | &:selected { 160 | background: $blue; 161 | 162 | button.flat:hover, button.flat:checked, button.flat:active { 163 | label { 164 | color: black; 165 | } 166 | } 167 | 168 | .reply-to-what { 169 | background: darker($blue); 170 | } 171 | } 172 | 173 | .reply-to-what { 174 | background: $gnome-gray; 175 | border-radius: 3px; 176 | margin-bottom: 5px; 177 | } 178 | 179 | &:focus { 180 | box-shadow: inset 0 0 6px $blue; 181 | } 182 | 183 | &:selected:focus { 184 | box-shadow: inset 0 0 6px $white; 185 | } 186 | 187 | &.angry { 188 | animation: 0.5s angry ease-out; 189 | } 190 | } 191 | 192 | .about-row { 193 | background: $gnome-gray; 194 | padding: 0 5px 10px 5px; 195 | } 196 | 197 | @mixin label-badge($color) { 198 | box-shadow: 0 0 3px $color, 199 | inset 0 0 3px $color; 200 | background: lighter($color); 201 | color: white; 202 | text-shadow: none; 203 | } 204 | 205 | button { 206 | &.fullscreen { 207 | -gtk-icon-source: -gtk-icontheme('list-add-symbolic'); 208 | } 209 | 210 | &.upvoted label { 211 | color: $orange; 212 | } 213 | 214 | &.downvoted label { 215 | color: $purple; 216 | } 217 | 218 | label.gilded { 219 | @include label-badge($orange); 220 | } 221 | 222 | /* 223 | * author distinguished colors, 224 | * made more specific than the comments-view styles 225 | */ 226 | label { 227 | border-radius: 3px; 228 | padding-left: 3px; 229 | padding-right: 3px; 230 | } 231 | 232 | label.op { 233 | @include label-badge($blue); 234 | } 235 | 236 | label.moderator { 237 | @include label-badge(darker($green)); 238 | } 239 | 240 | label.admin { 241 | @include label-badge(lighter($red)); 242 | } 243 | 244 | label.special { 245 | @include label-badge($purple); 246 | } 247 | 248 | label.flair { 249 | border: 1px solid grey; 250 | color: grey; 251 | } 252 | 253 | &.expand image { 254 | transition: 0.2s all linear; 255 | } 256 | &.expand:checked image { 257 | -gtk-icon-transform: rotate(180deg); 258 | } 259 | } 260 | 261 | /* Radio tool buttons are treated separately */ 262 | .upvote button:checked { 263 | color: $orange; 264 | } 265 | .downvote button:checked { 266 | color: $purple; 267 | } 268 | 269 | /* 270 | * Misc 271 | */ 272 | popover textview { 273 | margin-bottom: 5px; 274 | } 275 | 276 | .error-label { 277 | color: red; 278 | } 279 | 280 | /* 281 | * SubEntry 282 | */ 283 | 284 | popover.subentry-palette { 285 | padding-left: 0; 286 | padding-right: 0; 287 | } 288 | 289 | popover.subentry-palette label.header { 290 | padding-left: 6px; 291 | } 292 | 293 | popover button.full-width { 294 | background: $popover-bg; 295 | 296 | margin: 0; 297 | border: 0; 298 | border-radius: 0; 299 | box-shadow: none; 300 | 301 | image { 302 | transition: 0.2s all linear; 303 | } 304 | 305 | &:checked { 306 | background: darker($popover-bg); 307 | border-top: 1px solid black; 308 | color: white; 309 | 310 | image { 311 | -gtk-icon-transform: rotate(-180deg); 312 | } 313 | } 314 | &:hover, &:focus { 315 | background: lighter($popover-bg); 316 | outline: none; 317 | 318 | @if $dark == false { 319 | color: black; 320 | text-shadow: none; 321 | } 322 | } 323 | &:active { 324 | background: $blue; 325 | color: white; 326 | text-shadow: none; 327 | } 328 | } 329 | 330 | popover revealer { 331 | button.full-width { 332 | background: darker($popover-bg); 333 | } 334 | 335 | box { 336 | border-bottom: 1px solid black; 337 | } 338 | } 339 | 340 | 341 | /* MARKDOWN STYLES */ 342 | .mdx-block-blockquote { 343 | border-left: 3px solid grey; 344 | padding-left: 6px; 345 | margin-left: 3px; 346 | } 347 | .mdx-block-code { 348 | font-family: monospace; 349 | color: green; 350 | } 351 | .mdx-block > * { 352 | /* Paragraph spacing */ 353 | margin-top: 6px; 354 | } 355 | .mdx-block > *:first-child { 356 | margin-top: 0; 357 | } 358 | .mdx-block-ol > *, 359 | .mdx-block-ul > * { 360 | margin-top: 2px; 361 | } 362 | 363 | .mdx-heading { 364 | font-weight: bolder; 365 | } 366 | .mdx-heading-h1 { 367 | font-size: 2em; 368 | margin-top: .67em; 369 | margin-bottom: .67em; 370 | } 371 | .mdx-heading-h2 { 372 | font-size: 1.5em; 373 | margin-top: .75em; 374 | margin-bottom: .75em; 375 | } 376 | .mdx-heading-h3 { 377 | font-size: 1.17em; 378 | margin-top: .83em; 379 | margin-bottom: .83em; 380 | } 381 | .mdx-heading-h5 { 382 | font-size: .83em; 383 | margin-top: 1.5em; 384 | margin-bottom: 1.5em; 385 | } 386 | .mdx-heading-h6 { 387 | font-size: .75em; 388 | margin-top: 1.67em; 389 | margin-bottom: 1.67em; 390 | } 391 | -------------------------------------------------------------------------------- /data/subreddit-about.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | True 7 | False 8 | document-new-symbolic 9 | 10 | 11 | True 12 | False 13 | vertical 14 | 15 | 16 | True 17 | True 18 | center 19 | 20 | 21 | 22 | 23 | 24 | True 25 | False 26 | AskReddit 27 | center 28 | end 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | False 37 | True 38 | 0 39 | 40 | 41 | 42 | 43 | Submit 44 | True 45 | True 46 | True 47 | center 48 | image1 49 | True 50 | 51 | 52 | False 53 | True 54 | 1 55 | 56 | 57 | 58 | 59 | togglebutton 60 | True 61 | True 62 | True 63 | center 64 | 65 | 66 | False 67 | True 68 | 2 69 | 70 | 71 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /data/today.sam.something-for-reddit.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | '/' 11 | Default subreddit 12 | What to open when you open the app 13 | 14 | 15 | 'default' 16 | Theme to use for app 17 | Theme to use, default just uses the system theme 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /data/user-about.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | True 6 | False 7 | vertical 8 | 9 | 10 | True 11 | False 12 | Person 13 | center 14 | end 15 | 16 | 17 | 18 | 19 | 20 | False 21 | True 22 | 0 23 | 24 | 25 | 26 | 27 | True 28 | False 29 | Loading Karma... 30 | 31 | 32 | False 33 | True 34 | 1 35 | 36 | 37 | 38 | 39 | TODO: PM 40 | True 41 | False 42 | True 43 | True 44 | center 45 | 46 | 47 | False 48 | True 49 | 2 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /data/webview-toolbar.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | True 6 | False 7 | 8 | 9 | True 10 | True 11 | True 12 | 13 | 14 | True 15 | False 16 | go-previous-symbolic 17 | 18 | 19 | 20 | 21 | 22 | False 23 | True 24 | 0 25 | 26 | 27 | 28 | 29 | True 30 | True 31 | True 32 | Open with External Browser 33 | 34 | 35 | True 36 | False 37 | reddit-detach-symbolic 38 | 39 | 40 | 41 | 42 | 43 | False 44 | True 45 | 1 46 | 47 | 48 | 49 | 50 | True 51 | True 52 | True 53 | 54 | 55 | True 56 | False 57 | go-next-symbolic 58 | 59 | 60 | 61 | 62 | 63 | False 64 | True 65 | 2 66 | 67 | 68 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | reddit-is-gtk (0.1.0-1) UNRELEASED; urgency=low 2 | 3 | * Initial Debian version 4 | 5 | -- Tobias Graven Tue, 05 Apr 2016 20:27:16 +0800 6 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 9 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: reddit-is-gtk 2 | Maintainer: samdroid-apps 3 | Section: web 4 | Priority: optional 5 | Standards-Version: 3.9.5 6 | Build-Depends: debhelper (>= 9), dh-autoreconf, intltool (>= 0.28), gettext, libgtk-3-dev (>= 3.10), libglib2.0-dev (>= 2.28), libsoup2.4-dev, python3-arrow, python3-markdown, ruby-sass 7 | 8 | Package: reddit-is-gtk 9 | Architecture: any 10 | Depends: ${shlibs:Depends}, ${misc:Depends}, libc6, gir1.2-webkit2-3.0, libgtk-3-0 (>= 3.10), libglib2.0-0 (>= 2.28), libsoup2.4-1, python3-arrow, python3-markdown 11 | Description: Reddit is GTK 12 | A simple Reddit client for GNOME, built for touch and mouse. 13 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | reddit-is-gtk - A Reddit client for GNOME 2 | Copyright (C) 2016 Sam "samdroid-apps" 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see , or 16 | /usr/share/common-licenses if you are using a Debian system. 17 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | %: 3 | dh $@ --with autoreconf 4 | 5 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (quilt) 2 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | with import {}; 2 | 3 | let 4 | py = pkgs.python3; 5 | app = callPackage ./app.nix {}; 6 | in 7 | stdenv.mkDerivation rec { 8 | name = "env"; 9 | 10 | buildInputs = [ 11 | app 12 | ]; 13 | 14 | shellHook = '' 15 | export FISH_P1="(sfr) " 16 | export FISH_P1_COLOR="green" 17 | ''; 18 | } 19 | -------------------------------------------------------------------------------- /dev-shell.nix: -------------------------------------------------------------------------------- 1 | with import {}; 2 | 3 | let 4 | py = pkgs.python3; 5 | app = callPackage ./app.nix {}; 6 | 7 | helpMessage = '' 8 | To setup the build environment, you will need to run autogen: 9 | 10 | ./autogen.sh --prefix=$PREFIX 11 | 12 | To build, you can use the following commands: 13 | 14 | make; and make install; and reddit-is-gtk 15 | 16 | The environment is configured so that the builds will be installed 17 | into the `__build_prefix` directory. Root is not required. 18 | ''; 19 | in 20 | stdenv.mkDerivation rec { 21 | name = "env"; 22 | 23 | buildInputs = app.nativeBuildInputs ++ app.buildInputs; 24 | 25 | shellHook = '' 26 | export FISH_P1="(sfr-dev) " 27 | export FISH_P1_COLOR="magenta" 28 | 29 | export PREFIX=$(pwd)/__build_prefix 30 | 31 | export XDG_DATA_DIRS=$PREFIX/share:${gnome3.gnome_themes_standard}/share:$XDG_DATA_DIRS:$XDG_ICON_DIRS:$GSETTINGS_SCHEMAS_PATH 32 | export GI_TYPELIB_PATH=${app.extraTypelibPath}:$GI_TYPELIB_PATH 33 | export LD_LIBRARY_PATH=${app.extraLibPath}:$LD_LIBRARY_PATH 34 | export PYTHONPATH=.:$PREFIX/lib/python3.6/site-packages:$PYTHONPATH 35 | export PATH=$PREFIX/bin:$PATH 36 | 37 | echo '${helpMessage}' 38 | ''; 39 | } 40 | -------------------------------------------------------------------------------- /fedora/fedora.spec: -------------------------------------------------------------------------------- 1 | %global gobject_introspection_version 1.35.9 2 | %global gtk3_version 3.13.2 3 | %global soup_version 2.4 4 | 5 | Name: something-for-reddit 6 | Summary: Browse Reddit from GNOME 7 | Version: 0.2 8 | Release: 1%{?dist} 9 | BuildArch: noarch 10 | 11 | License: GPLv3+ 12 | URL: https://github.com/samdroid-apps/something-for-reddit 13 | Source0: https://download.gnome.org/sources/%{name}/3.20/%{name}-%{version}.tar.xz 14 | 15 | BuildRequires: /usr/bin/appstream-util 16 | BuildRequires: desktop-file-utils 17 | BuildRequires: intltool 18 | BuildRequires: itstool 19 | BuildRequires: pkgconfig(gio-2.0) 20 | BuildRequires: pkgconfig(gobject-introspection-1.0) >= %{gobject_introspection_version} 21 | BuildRequires: pkgconfig(gtk+-3.0) >= %{gtk3_version} 22 | BuildRequires: pkgconfig(libsoup-2.4) >= %{soup_version} 23 | BuildRequires: python3-rpm-macros 24 | BuildRequires: python3-arrow 25 | BuildRequires: python3-markdown 26 | BuildRequires: sassc 27 | BuildRequires: gnome-common 28 | 29 | Requires: libsoup 30 | Requires: gobject-introspection >= %{gobject_introspection_version} 31 | Requires: gtk3 >= %{gtk3_version} 32 | Requires: pango 33 | Requires: python3-gobject 34 | Requires: python3-arrow 35 | Requires: python3-markdown 36 | 37 | %description 38 | This is a Reddit client, built with Gtk+ and optimized for GNOME. 39 | 40 | 41 | %prep 42 | %autosetup -p1 43 | 44 | 45 | %build 46 | %configure --disable-silent-rules 47 | libtoolize 48 | intltoolize --force 49 | aclocal 50 | #. gnome-autogen.sh 51 | make %{?_smp_mflags} 52 | 53 | 54 | %install 55 | %make_install 56 | 57 | 58 | %check 59 | appstream-util validate-relax --nonet %{buildroot}/%{_datadir}/appdata/reddit-is-gtk.appdata.xml 60 | desktop-file-validate %{buildroot}/%{_datadir}/applications/reddit-is-gtk.desktop 61 | 62 | 63 | 64 | %files 65 | %doc NEWS 66 | %license COPYING 67 | %{_bindir}/reddit-is-gtk 68 | %{_datadir}/%{name} 69 | %{_datadir}/icons 70 | %{_datadir}/something-for-reddit 71 | %{_datadir}/appdata/reddit-is-gtk.appdata.xml 72 | %{_datadir}/glib-2.0/schemas/today.sam.something-for-reddit.gschema.xml 73 | %{_datadir}/applications/reddit-is-gtk.desktop 74 | %{python3_sitelib}/redditisgtk 75 | -------------------------------------------------------------------------------- /po/.intltool-merge-cache: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/071733a9e6050d0d171e6620c189b7860af56bab/po/.intltool-merge-cache -------------------------------------------------------------------------------- /po/POTFILES: -------------------------------------------------------------------------------- 1 | ../data/app-menu.ui \ 2 | ../data/post-top-bar.ui \ 3 | ../data/reddit-is-gtk.appdata.xml.in \ 4 | ../data/reddit-is-gtk.desktop.in \ 5 | ../data/row-link.ui \ 6 | ../data/submit-window.ui \ 7 | ../data/subreddit-about.ui \ 8 | ../data/user-about.ui \ 9 | ../data/webview-toolbar.ui 10 | -------------------------------------------------------------------------------- /po/POTFILES.in: -------------------------------------------------------------------------------- 1 | data/app-menu.ui 2 | data/post-top-bar.ui 3 | data/reddit-is-gtk.appdata.xml.in 4 | data/reddit-is-gtk.desktop.in 5 | data/row-link.ui 6 | data/submit-window.ui 7 | data/subreddit-about.ui 8 | data/user-about.ui 9 | data/webview-toolbar.ui 10 | 11 | -------------------------------------------------------------------------------- /po/POTFILES.skip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/071733a9e6050d0d171e6620c189b7860af56bab/po/POTFILES.skip -------------------------------------------------------------------------------- /publish-flatpak.md: -------------------------------------------------------------------------------- 1 | Run this command to build the app (remembering to change the subject): 2 | 3 | ```sh 4 | flatpak-builder build-dir \ 5 | today.sam.reddit-is-gtk.json \ 6 | --force-clean \ 7 | --gpg-sign=34E268B2FA2F8B13 \ 8 | --repo=/home/sam/sam.today-flatpak-repo \ 9 | --default-branch=stable \ 10 | --subject="Testing build of Something for Reddit" 11 | ``` 12 | 13 | Then inside the repo directory, run this: 14 | 15 | ```sh 16 | flatpak build-update-repo \ 17 | --prune \ 18 | --prune-depth=20 \ 19 | --gpg-sign=34E268B2FA2F8B13 \ 20 | . 21 | rsync -rapPhz --delete . root@nix1.sam.today:/srv/flatpak.dl.sam.today/ 22 | ``` 23 | 24 | 25 | -------------------------------------------------------------------------------- /reddit-is-gtk.in: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | 4 | # Make sure that where we installed it to, python for look 5 | # important for eg. /usr/local 6 | sys.path.insert(1, '@pythondir@') 7 | 8 | from gi.repository import Gio 9 | r = Gio.resource_load('@pkgdatadir@/reddit-is-gtk.gresource') 10 | Gio.Resource._register(r) 11 | 12 | import redditisgtk.main 13 | redditisgtk.main.run() 14 | -------------------------------------------------------------------------------- /redditisgtk/Makefile.am: -------------------------------------------------------------------------------- 1 | appdir = $(pythondir)/redditisgtk 2 | 3 | app_PYTHON = \ 4 | Makefile \ 5 | Makefile.am \ 6 | Makefile.in \ 7 | __init__.py \ 8 | aboutrow.py \ 9 | api.py \ 10 | buttons.py \ 11 | comments.py \ 12 | emptyview.py \ 13 | gtkutil.py \ 14 | identity.py \ 15 | identitybutton.py \ 16 | main.py \ 17 | mediapreview.py \ 18 | newmarkdown.py \ 19 | palettebutton.py \ 20 | posttopbar.py \ 21 | readcontroller.py \ 22 | settings.py \ 23 | subentry.py \ 24 | sublist.py \ 25 | sublistrows.py \ 26 | submit.py \ 27 | webviews.py 28 | -------------------------------------------------------------------------------- /redditisgtk/__init__.py: -------------------------------------------------------------------------------- 1 | import gi 2 | 3 | gi.require_version('Gtk', '3.0') 4 | gi.require_version('Soup', '2.4') 5 | gi.require_version('WebKit2', '4.0') 6 | -------------------------------------------------------------------------------- /redditisgtk/aboutrow.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Sam Parkinson 2 | # 3 | # This file is part of Something for Reddit. 4 | # 5 | # Something for Reddit is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Something for Reddit is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with Something for Reddit. If not, see . 17 | 18 | from gi.repository import Gtk 19 | 20 | from redditisgtk import newmarkdown 21 | from redditisgtk.buttons import SubscribeButtonBehaviour 22 | from redditisgtk import submit 23 | from redditisgtk.api import RedditAPI 24 | 25 | 26 | class AboutRow(Gtk.ListBoxRow): 27 | ''' 28 | Abstract base class for AboutRows 29 | ''' 30 | 31 | def __init__(self, **kwargs): 32 | Gtk.ListBoxRow.__init__(self, **kwargs) 33 | 34 | 35 | class _SubredditAboutRow(AboutRow): 36 | 37 | def __init__(self, api: RedditAPI, subreddit_name: str): 38 | super().__init__(selectable=False) 39 | 40 | self._subreddit_name = subreddit_name 41 | self._api = api 42 | self._loaded = False 43 | 44 | self._builder = Gtk.Builder.new_from_resource( 45 | '/today/sam/reddit-is-gtk/subreddit-about.ui') 46 | self._g = self._builder.get_object 47 | 48 | self.add(self._g('box')) 49 | self._g('subreddit').props.label = self._subreddit_name 50 | self._sbb = SubscribeButtonBehaviour( 51 | self._api, self._g('subscribe'), self._subreddit_name) 52 | self._g('submit').connect('clicked', self.__submit_clicked_cb) 53 | self._g('expander').connect( 54 | 'notify::expanded', self.__notify_expanded_cb) 55 | 56 | def __submit_clicked_cb(self, button): 57 | w = submit.SubmitWindow(self._api, sub=self._subreddit_name) 58 | w.show() 59 | 60 | def __notify_expanded_cb(self, expander, pspec): 61 | if not self._loaded: 62 | self._api.get_subreddit_info( 63 | self._subreddit_name, self.__got_info_cb) 64 | self._loaded = True 65 | 66 | def __got_info_cb(self, data): 67 | expander = self._g('expander') 68 | child = expander.get_child() 69 | if child is not None: 70 | expander.remove(ch) 71 | ch.destroy() 72 | 73 | markdown = data['data']['description'] 74 | expander.add(newmarkdown.make_markdown_widget(markdown)) 75 | 76 | 77 | class _UserAboutRow(AboutRow): 78 | 79 | def __init__(self, api: RedditAPI, name: str): 80 | super().__init__(selectable=False) 81 | 82 | self._name = name 83 | 84 | self._builder = Gtk.Builder.new_from_resource( 85 | '/today/sam/reddit-is-gtk/user-about.ui') 86 | self._g = self._builder.get_object 87 | 88 | self.add(self._g('box')) 89 | self._g('name').props.label = self._name 90 | 91 | api.get_user_info( 92 | self._name, self.__got_info_cb) 93 | 94 | def __got_info_cb(self, data): 95 | data = data['data'] 96 | self._g('karma').props.label = \ 97 | '{link_karma}l / {comment_karma}c'.format(**data) 98 | 99 | 100 | def get_about_row(api: RedditAPI, sub: str): 101 | # Disregard leading slash 102 | url_parts = sub.strip('/').split('/') 103 | 104 | # Show if it is like /r/sub 105 | if len(url_parts) >= 2 and url_parts[0] == 'r' and url_parts[1] != 'all': 106 | return _SubredditAboutRow(api, url_parts[1]) 107 | 108 | # Eg. /user/name(/*) 109 | if len(url_parts) >= 2 and url_parts[0] in ('user', 'u'): 110 | return _UserAboutRow(api, url_parts[1]) 111 | 112 | return None 113 | -------------------------------------------------------------------------------- /redditisgtk/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from pathlib import Path 4 | from tempfile import TemporaryDirectory 5 | 6 | from pytest import fixture 7 | 8 | 9 | @fixture 10 | def datadir() -> Path: 11 | ''' 12 | Fixture that gives the path of the data directory 13 | ''' 14 | return Path(__file__).absolute().parent / 'tests-data' 15 | 16 | 17 | @fixture 18 | def json_loader(): 19 | ''' 20 | Fixture that injects a json loading function 21 | ''' 22 | def inner(name: str) -> dict: 23 | path = datadir() / (name + '.json') 24 | with open(path) as f: 25 | return json.load(f) 26 | return inner 27 | 28 | 29 | @fixture 30 | def tempdir() -> Path: 31 | ''' 32 | Fixture that gives you the path of a new temporary directory 33 | ''' 34 | with TemporaryDirectory() as dirname: 35 | yield Path(dirname).absolute() 36 | 37 | 38 | def assert_matches_snapshot(name: str, data: dict): 39 | ''' 40 | Checks that the data matches the data stored in the snapshot. 41 | 42 | If the snapshot does not exist, it creates a snapshot with the passed data 43 | ''' 44 | snap_dir = Path(__file__).absolute().parent / 'tests-data' / 'snapshots' 45 | path = snap_dir / (name + '.json') 46 | 47 | if path.exists(): 48 | with open(path) as f: 49 | expected = json.load(f) 50 | assert data == expected 51 | else: # pragma: no cover 52 | if 'CI' in os.environ: 53 | raise Exception('Snapshot not found in CI environment') 54 | 55 | with open(path, 'w') as f: 56 | json.dump(data, f, indent=2, sort_keys=True) 57 | -------------------------------------------------------------------------------- /redditisgtk/emptyview.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Sam Parkinson 2 | # 3 | # This file is part of Something for Reddit. 4 | # 5 | # Something for Reddit is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Something for Reddit is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with Something for Reddit. If not, see . 17 | 18 | from gi.repository import GObject 19 | from gi.repository import Gtk 20 | 21 | class EmptyView(Gtk.Bin): 22 | 23 | action = GObject.Signal('action') 24 | 25 | def __init__(self, title: str, action: str = None): 26 | Gtk.Bin.__init__(self) 27 | 28 | builder = Gtk.Builder.new_from_resource( 29 | '/today/sam/reddit-is-gtk/empty-view.ui') 30 | self._g = builder.get_object 31 | self.add(self._g('box')) 32 | self.get_child().show() 33 | 34 | self._g('title').props.label = title 35 | 36 | if action is not None: 37 | self._g('action').props.label = action 38 | self._g('action').props.visible = True 39 | self._g('action').connect('clicked', self.__action_clicked_cb) 40 | 41 | def __action_clicked_cb(self, button): 42 | self.action.emit() 43 | -------------------------------------------------------------------------------- /redditisgtk/gtktestutil.py: -------------------------------------------------------------------------------- 1 | import time 2 | import typing 3 | import functools 4 | from unittest.mock import MagicMock 5 | 6 | from gi.repository import Gtk 7 | from gi.repository import Gdk 8 | from gi.repository import GLib 9 | from gi.repository import Gio 10 | 11 | 12 | def with_test_mainloop(func): 13 | @functools.wraps(func) 14 | def wrapper(*args, **kwargs): 15 | # It would probably be slightly better to find this from PATH 16 | r = Gio.resource_load('./__build_prefix/share/something-for-reddit' 17 | '/reddit-is-gtk.gresource') 18 | Gio.Resource._register(r) 19 | 20 | class MyApplication(Gtk.Application): 21 | def __init__(self): 22 | Gtk.Application.__init__(self, application_id='today.sam.reddit-is-gtk') 23 | self.exception = None 24 | self.retval = None 25 | 26 | def do_activate(self): 27 | try: 28 | self.retval = func(*args, **kwargs) 29 | except Exception as e: 30 | self.exception = e 31 | app.quit() 32 | 33 | app = MyApplication() 34 | app.run() 35 | Gio.Resource._unregister(r) 36 | if app.exception is not None: 37 | raise app.exception 38 | return app.retval 39 | 40 | return wrapper 41 | 42 | 43 | def _iter_all_widgets(root: Gtk.Widget): 44 | yield root 45 | if isinstance(root, Gtk.Container): 46 | for child in root.get_children(): 47 | yield from _iter_all_widgets(child) 48 | 49 | 50 | def get_focused(root: Gtk.Widget) -> Gtk.Widget: 51 | for widget in _iter_all_widgets(root): 52 | if widget.is_focus(): 53 | return widget 54 | 55 | 56 | def get_label_for_widget(widget: Gtk.Widget): 57 | my_label = None 58 | if hasattr(widget, 'get_label'): 59 | my_label = widget.get_label() 60 | 61 | # Mainly for the stackswitcher radio buttons 62 | if not my_label and hasattr(widget, 'get_child'): 63 | child = widget.get_child() 64 | if hasattr(child, 'get_label'): 65 | my_label = child.get_label() 66 | 67 | if not my_label and hasattr(widget, 'get_text'): 68 | my_label = widget.get_text() 69 | 70 | return my_label 71 | 72 | 73 | def debug_print_widgets(root: Gtk.Widget): # pragma: no cover 74 | for widget in _iter_all_widgets(root): 75 | print(widget, get_label_for_widget(widget)) 76 | 77 | 78 | def snapshot_widget(root: Gtk.Widget) -> dict: 79 | style = root.get_style_context() 80 | snap = { 81 | 'type': type(root).__name__, 82 | 'label': get_label_for_widget(root), 83 | 'classes': style.list_classes()} 84 | 85 | if isinstance(root, Gtk.Container): 86 | snap['children'] = list(map(snapshot_widget, root.get_children())) 87 | 88 | return snap 89 | 90 | 91 | def find_widget(root: Gtk.Widget, 92 | kind: typing.Type[Gtk.Widget] = None, 93 | label: str = None, 94 | placeholder: str = None, 95 | many: bool = False): 96 | found = [] 97 | for widget in _iter_all_widgets(root): 98 | if kind is not None and not isinstance(widget, kind): 99 | continue 100 | 101 | if label is not None and get_label_for_widget(widget) != label: 102 | continue 103 | 104 | my_placeholder = None 105 | if hasattr(widget, 'get_placeholder_text'): 106 | my_placeholder = widget.get_placeholder_text() 107 | if placeholder and my_placeholder != placeholder: 108 | continue 109 | 110 | found.append(widget) 111 | 112 | if many: 113 | return found 114 | else: 115 | if len(found) == 1: 116 | return found[0] 117 | else: 118 | debug_print_widgets(root) 119 | assert len(found) == 1 120 | 121 | 122 | def wait_for(cond: typing.Callable[[], bool], timeout: float= 5): 123 | start_time = time.time() 124 | while True: 125 | if cond(): 126 | return 127 | 128 | if time.time() - start_time > timeout: 129 | raise AssertionError('Timeout expired') 130 | 131 | Gtk.main_iteration_do(False) 132 | 133 | 134 | def fake_event(keyval, event_type=Gdk.EventType.KEY_PRESS, ctrl=False): 135 | if isinstance(keyval, str): 136 | keyval = ord(keyval) 137 | 138 | ev = MagicMock() 139 | ev.type = event_type 140 | ev.keyval = keyval 141 | if ctrl: 142 | ev.state = Gdk.ModifierType.CONTROL_MASK 143 | else: 144 | ev.state = 0 145 | return ev 146 | -------------------------------------------------------------------------------- /redditisgtk/gtkutil.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Sam Parkinson 2 | # 3 | # This file is part of Something for Reddit. 4 | # 5 | # Something for Reddit is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Something for Reddit is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with Something for Reddit. If not, see . 17 | 18 | from gi.repository import Gtk 19 | from gi.repository import Gdk 20 | 21 | 22 | def process_shortcuts(shortcuts, event: Gdk.Event): 23 | ''' 24 | Shortcuts is a dict of: 25 | accelerator string: (self._function, [arguments]) 26 | 27 | Accelerator is passed to Gtk.accelerator_parse 28 | Event is the GdkEvent 29 | ''' 30 | if event.type != Gdk.EventType.KEY_PRESS: 31 | return 32 | for accel_string, value in shortcuts.items(): 33 | key, mods = Gtk.accelerator_parse(accel_string) 34 | emods = event.state & (Gdk.ModifierType.CONTROL_MASK | 35 | Gdk.ModifierType.SHIFT_MASK) 36 | 37 | if event.keyval == key and (emods & mods or mods == emods == 0): 38 | func, args = value 39 | try: 40 | func(*args) 41 | except Exception as e: 42 | raise 43 | return False 44 | return True 45 | -------------------------------------------------------------------------------- /redditisgtk/identity.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Sam Parkinson 2 | # 3 | # This file is part of Something for Reddit. 4 | # 5 | # Something for Reddit is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Something for Reddit is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with Something for Reddit. If not, see . 17 | 18 | from gi.repository import Gtk 19 | from gi.repository import GLib 20 | from gi.repository import Soup 21 | from gi.repository import WebKit2 22 | from gi.repository import GObject 23 | 24 | import os 25 | import json 26 | import urllib.parse 27 | from uuid import uuid4 28 | 29 | from redditisgtk import api 30 | from redditisgtk.readcontroller import get_data_file_path 31 | 32 | 33 | class IdentityController(GObject.GObject): 34 | 35 | token_changed = GObject.Signal('token-changed', arg_types=[]) 36 | 37 | def __init__(self, session: Soup.Session, data_path: str = None): 38 | super().__init__() 39 | 40 | self._data_path = data_path 41 | if self._data_path is None: 42 | self._data_path = get_data_file_path('identity') 43 | 44 | self._session = session 45 | self._active = None 46 | self._tokens = {} 47 | self._anon = api.AnonymousTokenManager() 48 | 49 | self.load() 50 | 51 | @property 52 | def active_token(self) -> api.TokenManager: 53 | if self._active is None: 54 | return self._anon 55 | else: 56 | return self._tokens[self._active] 57 | 58 | def load(self): 59 | if os.path.isfile(self._data_path): 60 | with open(self._data_path) as f: 61 | j = json.load(f) 62 | 63 | self._tokens = {} 64 | for id_, data in j['tokens'].items(): 65 | token = api.OAuthTokenManager( 66 | self._session, 67 | token=data, 68 | ) 69 | token.value_changed.connect(self._token_value_changed_cb) 70 | self._tokens[id_] = token 71 | self._active = j['active'] 72 | 73 | self.token_changed.emit() 74 | 75 | def _token_value_changed_cb(self, token): 76 | if token == self.active_token: 77 | self.token_changed.emit() 78 | self.save() 79 | 80 | def save(self): 81 | with open(self._data_path, 'w') as f: 82 | json.dump({ 83 | 'tokens': \ 84 | {k: v.serialize() for k, v in self._tokens.items()}, 85 | 'active': self._active, 86 | }, f) 87 | 88 | @property 89 | def all_tokens(self): 90 | yield from self._tokens.items() 91 | 92 | def switch_account(self, id): 93 | if self._active == id: 94 | return 95 | 96 | self._active = id 97 | if self._active is not None: 98 | self._tokens[self._active].refresh(lambda: self.token_changed.emit()) 99 | else: 100 | self.token_changed.emit() 101 | self.save() 102 | 103 | def remove_account(self, id): 104 | if self._active == id: 105 | self._active = None 106 | del self._tokens[id] 107 | self.token_changed.emit() 108 | self.save() 109 | 110 | def sign_in_got_code(self, code, callback): 111 | id = str(uuid4()) 112 | 113 | def done_cb(): 114 | self._active = id 115 | self.token_changed.emit() 116 | self.save() 117 | callback() 118 | 119 | self._tokens[id] = api.OAuthTokenManager( 120 | self._session, 121 | code=code, 122 | ready_callback=done_cb) 123 | self._tokens[id].value_changed.connect(self._token_value_changed_cb) 124 | -------------------------------------------------------------------------------- /redditisgtk/identitybutton.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Sam Parkinson 2 | # 3 | # This file is part of Something for Reddit. 4 | # 5 | # Something for Reddit is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Something for Reddit is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with Something for Reddit. If not, see . 17 | 18 | import urllib.parse 19 | from uuid import uuid4 20 | 21 | from gi.repository import Gtk 22 | from gi.repository import WebKit2 23 | 24 | from redditisgtk.identity import IdentityController 25 | 26 | 27 | class IdentityButton(Gtk.MenuButton): 28 | 29 | def __init__(self, ic: IdentityController): 30 | Gtk.MenuButton.__init__(self, popover=_IdentityPopover(ic)) 31 | self.__token_cb(ic) 32 | ic.token_changed.connect(self.__token_cb) 33 | 34 | def __token_cb(self, ctrl): 35 | token = ctrl.active_token 36 | self.props.label = token.user_name 37 | 38 | 39 | class _IdentityPopover(Gtk.Popover): 40 | 41 | def __init__(self, ic: IdentityController): 42 | Gtk.Popover.__init__(self) 43 | self.__token_cb(ic) 44 | ic.token_changed.connect(self.__token_cb) 45 | self._ic = ic 46 | 47 | def __token_cb(self, ctrl): 48 | if self.get_child() is not None: 49 | c = self.get_child() 50 | self.remove(c) 51 | c.destroy() 52 | 53 | listbox = Gtk.ListBox() 54 | self.add(listbox) 55 | listbox.show() 56 | 57 | anon = _AccountRow(ctrl, None, 'Anonymous', removeable=False) 58 | listbox.add(anon) 59 | anon.show() 60 | if ctrl.active_token.is_anonymous: 61 | listbox.select_row(anon) 62 | 63 | for id, token in ctrl.all_tokens: 64 | row = _AccountRow(ctrl, id, token.user_name) 65 | listbox.add(row) 66 | row.show() 67 | if token == ctrl.active_token: 68 | listbox.select_row(row) 69 | 70 | add = Gtk.Button(label='Add new account', 71 | always_show_image=True) 72 | add.connect('clicked', self.__add_clicked_cb) 73 | add.add(Gtk.Image.new_from_icon_name( 74 | 'list-add-symbolic', Gtk.IconSize.MENU)) 75 | add.get_style_context().add_class('flat') 76 | listbox.add(add) 77 | add.show() 78 | 79 | listbox.connect('row-selected', self.__row_selected_cb) 80 | 81 | def __add_clicked_cb(self, button): 82 | w = SignInWindow(self._ic) 83 | w.show() 84 | 85 | def __row_selected_cb(self, listbox, row): 86 | if row is not None: 87 | self._ic.switch_account(row.id) 88 | return True 89 | 90 | 91 | class _AccountRow(Gtk.ListBoxRow): 92 | 93 | def __init__(self, ic, id, name, removeable=True): 94 | Gtk.ListBoxRow.__init__(self) 95 | self._ic = ic 96 | self.id = id 97 | self.name = name 98 | 99 | box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 100 | self.add(box) 101 | box.add(Gtk.Label(label=name)) 102 | 103 | if removeable: 104 | remove = Gtk.Button() 105 | remove.connect('clicked', self.__remove_cb) 106 | remove.get_style_context().add_class('flat') 107 | remove.add(Gtk.Image.new_from_icon_name( 108 | 'edit-delete-symbolic', Gtk.IconSize.MENU)) 109 | box.pack_end(remove, False, False, 0) 110 | self.show_all() 111 | 112 | def __remove_cb(self, button): 113 | self._ic.remove_account(self.id) 114 | 115 | 116 | class SignInWindow(Gtk.Window): 117 | 118 | def __init__(self, ic: IdentityController): 119 | Gtk.Window.__init__(self) 120 | self.set_default_size(400, 400) 121 | self.set_title('Sign into Reddit') 122 | self._state = str(uuid4()) 123 | self._ic = ic 124 | 125 | ctx = WebKit2.WebContext.get_default() 126 | ctx.register_uri_scheme('redditgtk', self.__uri_scheme_cb) 127 | 128 | self._web = WebKit2.WebView() 129 | self._web.load_uri( 130 | 'https://www.reddit.com/api/v1/authorize.compact?{end}'.format( 131 | end=urllib.parse.urlencode(dict( 132 | redirect_uri='redditgtk://done', 133 | state=self._state, 134 | client_id='WCN3jqoJ1-0r0Q', 135 | response_type='code', 136 | duration='permanent', 137 | scope=('edit history identity mysubreddits privatemessages' 138 | ' submit subscribe vote read save'))) 139 | )) 140 | self._web.connect('notify::uri', self.__notify_uri_cb) 141 | self.add(self._web) 142 | self.show_all() 143 | 144 | def __uri_scheme_cb(self, request): 145 | # WebKit now has a security measure that doesn't allow a http(s) site 146 | # to redirect to a non-http(s) uri scheme. So this only actually gets 147 | # called for the old versions of webkit 148 | pass 149 | 150 | def __notify_uri_cb(self, webview, pspec): 151 | uri = urllib.parse.urlparse(webview.props.uri) 152 | 153 | if uri.scheme == 'redditgtk': 154 | self._show_label() 155 | 156 | d = urllib.parse.parse_qs(uri.query) 157 | if d['state'][0] != self._state: 158 | self._label.set_markup( 159 | 'Reddit did not return the same state in OAuth flow') 160 | self._close.show() 161 | return 162 | 163 | if d.get('code'): 164 | self._ic.sign_in_got_code(d['code'][0], self.__done_cb) 165 | self._label.set_markup('Going down the OAuth flow') 166 | self._spinner.show() 167 | else: 168 | self._label.set_markup( 169 | 'Reddit OAuth Error {}'.format(d['error'])) 170 | self._close.show() 171 | 172 | def __done_cb(self): 173 | self.destroy() 174 | 175 | def _show_label(self): 176 | self._web.hide() 177 | self.remove(self._web) 178 | self._web.destroy() 179 | 180 | box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 181 | self.add(box) 182 | box.show() 183 | self._label = Gtk.Label() 184 | box.add(self._label) 185 | self._label.show() 186 | self._spinner = Gtk.Spinner() 187 | box.add(self._spinner) 188 | 189 | self._close = Gtk.Button(label='Close') 190 | self._close.get_style_context().add_class('primary-action') 191 | self._close.connect('clicked', self.destroy) 192 | box.add(self._close) 193 | -------------------------------------------------------------------------------- /redditisgtk/mediapreview.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Sam Parkinson 2 | # 3 | # This file is part of Something for Reddit. 4 | # 5 | # Something for Reddit is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Something for Reddit is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with Something for Reddit. If not, see . 17 | 18 | from gi.repository import Gtk 19 | from gi.repository import Gdk 20 | from gi.repository import GdkPixbuf 21 | 22 | import subprocess 23 | from tempfile import mkstemp 24 | import urllib.parse 25 | from html.parser import HTMLParser 26 | 27 | from redditisgtk.api import RedditAPI 28 | from redditisgtk.webviews import FullscreenableWebview 29 | 30 | 31 | def _unescape(s): 32 | return urllib.parse.unquote( 33 | s.replace('<', '<').replace('>', '>') 34 | .replace('&', '&')) 35 | 36 | 37 | class _IframeSrcGetter(HTMLParser): 38 | src = None 39 | 40 | def handle_starttag(self, tag, attrs): 41 | if tag == 'iframe': 42 | for k, v in attrs: 43 | if k == 'src': 44 | self.src = v 45 | 46 | 47 | def get_preview_palette(api: RedditAPI, listing, **kwargs): 48 | if 'content' in listing.get('media_embed', {}): 49 | embed = listing['media_embed'] 50 | parser = _IframeSrcGetter() 51 | parser.feed(_unescape(embed['content'])) 52 | return WebViewPopover( 53 | uri=parser.src, 54 | width=embed['width'], height=embed['height'], 55 | **kwargs) 56 | elif len(listing.get('preview', {}).get('images', [])): 57 | uri = listing['preview']['images'][0]['source']['url'] 58 | return _ImagePreviewPalette( 59 | api, uri, **kwargs) 60 | return None 61 | 62 | 63 | class WebViewPopover(Gtk.Popover): 64 | 65 | def __init__(self, uri, width, height, **kwargs): 66 | if uri.startswith('//'): 67 | uri = 'https:' + uri 68 | 69 | Gtk.Popover.__init__(self, **kwargs) 70 | self._wv = FullscreenableWebview() 71 | self._wv.load_uri(uri) 72 | self._wv.set_size_request(width, height) 73 | self.add(self._wv) 74 | self._wv.show() 75 | 76 | 77 | class _ImagePreviewPalette(Gtk.Popover): 78 | # TODO: Scrolling and scaling 79 | 80 | def __init__(self, api: RedditAPI, uri, **kwargs): 81 | Gtk.Popover.__init__(self, **kwargs) 82 | overlay = Gtk.Overlay() 83 | self.add(overlay) 84 | overlay.show() 85 | 86 | win_w, win_h = self.get_toplevel().get_size() 87 | max_w = min(Gdk.Screen.width(), win_w / 2) 88 | max_h = min(Gdk.Screen.height(), win_h / 2) 89 | 90 | self._image = _RemoteImage(api, uri, max_w, max_h) 91 | overlay.add(self._image) 92 | self._image.show() 93 | 94 | self._eog = Gtk.Button(halign=Gtk.Align.END, 95 | valign=Gtk.Align.START, 96 | label='EoG') 97 | self._eog.connect('clicked', self.__eog_clicked_cb) 98 | overlay.add_overlay(self._eog) 99 | self._eog.show() 100 | 101 | def __eog_clicked_cb(self, button): 102 | fd, path = mkstemp() 103 | self._image.save_to(path) 104 | subprocess.call(['eog', '--fullscreen', path]) 105 | 106 | 107 | class _RemoteImage(Gtk.Bin): 108 | # TODO: Reload on error 109 | 110 | def __init__(self, api: RedditAPI, uri: str, max_w: float, max_h: float): 111 | # TODO: this really shouldn't need a reddit api. Maybe just a soup 112 | # session? This feels like a bad DI pattern right now 113 | Gtk.Bin.__init__(self) 114 | self._max_w = max_w 115 | self._max_h = max_h 116 | 117 | self._spinner = Gtk.Spinner() 118 | self.add(self._spinner) 119 | self._spinner.show() 120 | 121 | self._image = Gtk.Image() 122 | self.add(self._image) 123 | api.download_thumb(uri, self.__message_done_cb) 124 | 125 | def __message_done_cb(self, pixbuf): 126 | old_sr = self.get_size_request() 127 | 128 | # GtkImage can not scale images internally, so we need to 129 | # pre-process that 130 | scale = min( 131 | self._max_w / pixbuf.props.width, 132 | self._max_h / pixbuf.props.height) 133 | if scale < 1: 134 | new_w = pixbuf.props.width * scale 135 | new_h = pixbuf.props.height * scale 136 | pixbuf = pixbuf.scale_simple( 137 | new_w, new_h, GdkPixbuf.InterpType.BILINEAR) 138 | 139 | self._image.props.pixbuf = pixbuf 140 | self.remove(self.get_child()) 141 | self.add(self._image) 142 | self._image.show() 143 | self._image.set_size_request(*old_sr) 144 | 145 | def save_to(self, path): 146 | self._image.props.pixbuf.savev(path, 'png', [], []) 147 | -------------------------------------------------------------------------------- /redditisgtk/newmarkdown.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from xml.etree import ElementTree 3 | from xml.etree.ElementTree import Element 4 | import html 5 | 6 | import markdown 7 | from markdown.extensions import Extension 8 | from markdown.inlinepatterns import Pattern, SimpleTagPattern 9 | from markdown.treeprocessors import Treeprocessor 10 | 11 | from gi.repository import Gtk 12 | from gi.repository import Gdk 13 | from gi.repository import Pango 14 | 15 | 16 | _URI_RE = r'(https?://|/r/|/u/)([^ \r\t\n]+)' 17 | _STRIKE_RE = r'(~~)([^~]+)~~' 18 | _SUPER_RE = r'(\^)(\([^\^\)]+\)|[^\^ ]+)' 19 | 20 | 21 | class _URIPattern(Pattern): 22 | def handleMatch(self, match): 23 | uri = match.group(2) + match.group(3) 24 | el = Element('a') 25 | el.set('href', uri) 26 | el.text = markdown.util.AtomicString(uri) 27 | return el 28 | 29 | 30 | class _SuperPattern(Pattern): 31 | def handleMatch(self, match): 32 | text = match.group(3) 33 | print(text) 34 | if text.startswith('(') and text.endswith(')'): 35 | text = text[1:-1] 36 | 37 | el = Element('sup') 38 | el.text = text 39 | return el 40 | 41 | 42 | class _RedditExtension(Extension): 43 | def extendMarkdown(self, md, md_globals): 44 | md.inlinePatterns['uriregex'] = _URIPattern(_URI_RE, md) 45 | 46 | s_tag = SimpleTagPattern(_STRIKE_RE, 'strike') 47 | md.inlinePatterns.add('s', s_tag, '>not_strong') 48 | 49 | sup_tag = _SuperPattern(_SUPER_RE, md) 50 | md.inlinePatterns.add('sup', sup_tag, '>not_strong') 51 | 52 | 53 | html_pattern = md.inlinePatterns['html'] 54 | old = html_pattern.handleMatch 55 | def handleMatch(match): 56 | # Reddit allows interesting markdown, for example this should pass 57 | # through fully: "Hello !" 58 | group_number = 2 if markdown.version_info[0] <= 2 else 1 59 | raw_html = html_pattern.unescape(match.group(2)) 60 | try: 61 | root = ElementTree.fromstring('
'+raw_html+'
') 62 | except ElementTree.ParseError: 63 | # This is not proper html, so pass it through rather than 64 | # extracting it into an unchangeable stash 65 | return raw_html 66 | else: 67 | # This is proper html, so pass it through 68 | return old(data) 69 | html_pattern.handleMatch = handleMatch 70 | 71 | 72 | MDX_CONTEXT = markdown.Markdown(extensions=[_RedditExtension()]) 73 | 74 | 75 | class AlignedLabel(Gtk.Label): 76 | def __init__(self, **kwargs): 77 | super().__init__( 78 | xalign=0, 79 | justify=Gtk.Justification.LEFT, 80 | wrap=True, 81 | wrap_mode=Pango.WrapMode.WORD_CHAR, 82 | **kwargs) 83 | 84 | 85 | HTML_TO_PANGO_INLINE_TAG = { 86 | 'strong': ('', ''), 87 | 'em': ('', ''), 88 | 'strike': ('', ''), 89 | 'sup': ('', ''), 90 | 'br': ('\n', ''), 91 | # We never make these, but sometimes the reddit api does 92 | 'del': ('', ''), 93 | } 94 | 95 | def _html_inline_tag_to_pango(el: Element) -> (str, str): 96 | if el.tag in HTML_TO_PANGO_INLINE_TAG: 97 | return HTML_TO_PANGO_INLINE_TAG[el.tag] 98 | elif el.tag == 'a': 99 | if el.get('href') is None: 100 | return '', '' 101 | 102 | href = html.escape(el.get('href')) 103 | return ''.format(href), '' 104 | else: # pragma: no cover 105 | print('Unknown inline tag', el) 106 | # ElementTree.dump(el) 107 | return '', '' 108 | 109 | 110 | def _make_inline_label(el: Element, initial_text: str = None) -> Gtk.Label: 111 | fragments = [initial_text] 112 | 113 | def extract_text(el, root=False): 114 | if el.tag == 'code': 115 | # special case, as code is processed before rawhtml, so it does not 116 | # get escaped 117 | fragments.append('') 118 | text = ''.join(el.itertext()) 119 | fragments.append(html.escape(text)) 120 | fragments.append('') 121 | return 122 | 123 | if not root: 124 | start, end = _html_inline_tag_to_pango(el) 125 | fragments.append(start) 126 | fragments.append(html.escape(el.text or '').replace('\n', '')) 127 | for inner in el: 128 | extract_text(inner) 129 | fragments.append(html.escape(inner.tail or '').replace('\n', '')) 130 | if not root: 131 | fragments.append(end) 132 | 133 | extract_text(el, True) 134 | 135 | label = AlignedLabel() 136 | label.connect('activate-link', __activate_link_cb) 137 | 138 | markup = ''.join(x for x in fragments if x is not None) 139 | label.set_markup(markup) 140 | 141 | label.show() 142 | return label 143 | 144 | 145 | def __activate_link_cb(label, uri): 146 | window = label.get_toplevel() 147 | window.load_uri_from_label(uri) 148 | return True 149 | 150 | 151 | BLOCK_TAGS = ['div', 'ol', 'ul', 'blockquote'] 152 | HEADING_TAGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] 153 | 154 | 155 | def _make_li_widget(el: Element, parent: Element, index: int) -> Gtk.Widget: 156 | dot = '⚫ ' 157 | if parent is not None and parent.tag == 'ol': 158 | dot = '{}. '.format(index+1) 159 | return _make_inline_label(el, initial_text=dot) 160 | 161 | 162 | def _make_block_widget(el: Element) -> Gtk.Widget: 163 | box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 164 | box.get_style_context().add_class('mdx-block') 165 | box.get_style_context().add_class('mdx-block-'+el.tag) 166 | 167 | assert el.text is None or el.text.strip() == '' 168 | for i, inner in enumerate(el): 169 | assert inner.tail is None or inner.tail.strip() == '' 170 | box.add(convert_tree_to_widgets(inner, parent=el, index=i)) 171 | 172 | box.show() 173 | return box 174 | 175 | def _make_heading_widget(el: Element) -> Gtk.Widget: 176 | label = AlignedLabel() 177 | label.props.label = el.text 178 | label.get_style_context().add_class('mdx-heading') 179 | label.get_style_context().add_class('mdx-heading-'+el.tag) 180 | label.show() 181 | return label 182 | 183 | 184 | def _make_code_widget(el: Element) -> Gtk.Widget: 185 | assert len(list(el)) == 1 186 | child = list(el)[0] 187 | code = child.text 188 | assert code is not None 189 | 190 | label = AlignedLabel() 191 | label.props.label = html.unescape(code) 192 | label.get_style_context().add_class('mdx-block-code') 193 | label.show() 194 | return label 195 | 196 | 197 | def convert_tree_to_widgets(el: Element, parent: Element = None, 198 | index: int = None) -> Gtk.Label: 199 | if el.tag == 'p': 200 | return _make_inline_label(el) 201 | elif el.tag == 'li': 202 | return _make_li_widget(el, parent, index) 203 | elif el.tag in BLOCK_TAGS: 204 | return _make_block_widget(el) 205 | elif el.tag in HEADING_TAGS: 206 | return _make_heading_widget(el) 207 | elif el.tag == 'pre': 208 | return _make_code_widget(el) 209 | elif el.tag == 'hr': 210 | return Gtk.Separator(visible=True) 211 | else: # pragma: no cover 212 | print('Unhandled tag', el) 213 | placeholder = Gtk.Spinner() 214 | placeholder.start() 215 | placeholder.show() 216 | return placeholder 217 | 218 | 219 | def make_markdown_widget(text: str) -> Gtk.Widget: 220 | ''' 221 | Make a widget of some given text. The markdown widget will be resizable 222 | (it will wrap) and it will probably be a GTK box. 223 | 224 | Args: 225 | 226 | text - markdown text input 227 | ''' 228 | html = MDX_CONTEXT.convert(text) 229 | return make_html_widget(html) 230 | 231 | 232 | def make_html_widget(html: str) -> Gtk.Widget: 233 | ''' 234 | Make a widget given some html text. Must have a single element as root. 235 | ''' 236 | try: 237 | root = ElementTree.fromstring('
'+html+'
') 238 | except ElementTree.ParseError as e: 239 | print('Error parsing html,', e, 'for html:') 240 | print(html) 241 | label = AlignedLabel(label='Error formatting text:\n\n{}'.format(html)) 242 | label.show() 243 | return label 244 | else: 245 | return convert_tree_to_widgets(root) 246 | 247 | 248 | if __name__ == '__main__': 249 | # Usage: 250 | # python newmarkdown.py test.md 251 | with open(sys.argv[1]) as f: 252 | test_text = f.read() 253 | 254 | window = Gtk.Window() 255 | 256 | w = make_markdown_widget(test_text) 257 | sw = Gtk.ScrolledWindow() 258 | sw.add(w) 259 | sw.show() 260 | 261 | window.add(sw) 262 | window.set_default_size(400, 400) 263 | window.show() 264 | window.connect('delete-event', Gtk.main_quit) 265 | Gtk.main() 266 | -------------------------------------------------------------------------------- /redditisgtk/palettebutton.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Sam Parkinson 2 | # 3 | # This file is part of Something for Reddit. 4 | # 5 | # Something for Reddit is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Something for Reddit is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with Something for Reddit. If not, see . 17 | 18 | from gi.repository import GObject 19 | from gi.repository import Gtk 20 | 21 | 22 | class _PaletteButton(GObject.GObject): 23 | ''' 24 | Like a Gtk.MenuButton, but instead of requiring the palette to be 25 | constructed initially, it is created on demand by the "create_palette" 26 | method (when subclassed) 27 | ''' 28 | 29 | def __init__(self, button, recycle, modalify): 30 | self._button = button 31 | self._recycle = recycle 32 | self._modalify = modalify 33 | self._palette = None 34 | self._self_triggered = False 35 | 36 | self._button.connect('toggled', self.do_toggled) 37 | 38 | def do_toggled(self, button): 39 | if self._self_triggered: 40 | return 41 | 42 | if self._button.props.active: 43 | if self._palette is None: 44 | self._palette = self.create_palette() 45 | self._palette.connect('closed', self.__palette_closed_cb) 46 | self._pc = self._palette.get_child() 47 | 48 | if not self._button.props.visible and self._modalify: 49 | dialog = Gtk.Dialog(use_header_bar=True) 50 | self._palette.remove(self._pc) 51 | dialog.get_content_area().add(self._pc) 52 | 53 | dialog.props.transient_for = self._button.get_toplevel() 54 | dialog.connect('response', self.__dialog_closed_cb) 55 | dialog.show() 56 | else: 57 | self._palette.props.relative_to = button 58 | self._palette.show() 59 | else: 60 | self._palette.hide() 61 | if not self._recycle: 62 | self._palette = None 63 | 64 | def __dialog_closed_cb(self, dialog, response): 65 | dialog.get_content_area().remove(self._pc) 66 | self._palette.add(self._pc) 67 | 68 | self.__palette_closed_cb(None) 69 | 70 | def __palette_closed_cb(self, palette): 71 | self._self_triggered = True 72 | self._button.props.active = False 73 | self._self_triggered = False 74 | if not self._recycle: 75 | self._palette = None 76 | 77 | 78 | def connect_palette(button, create_palette_func, recycle_palette=False, 79 | modalify=False): 80 | p = _PaletteButton(button, recycle_palette, modalify) 81 | p.create_palette = create_palette_func 82 | return p 83 | -------------------------------------------------------------------------------- /redditisgtk/readcontroller.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Sam Parkinson 2 | # 3 | # This file is part of Something for Reddit. 4 | # 5 | # Something for Reddit is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Something for Reddit is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with Something for Reddit. If not, see . 17 | 18 | import os 19 | from gi.repository import GLib 20 | from gi.repository import GObject 21 | 22 | 23 | def get_data_file_path(name): 24 | ''' 25 | Returns the path for storing user data. 26 | 27 | Makes the directory if it does not exist currently. Does not check 28 | if the file exists. 29 | 30 | Args: 31 | name (str) 32 | ''' 33 | data_dir = GLib.get_user_data_dir() 34 | d = os.path.join(data_dir, 'reddit-is-gtk') 35 | if not os.path.isdir(d): 36 | os.makedirs(d) 37 | return os.path.join(d, name) 38 | 39 | 40 | class ReadController(GObject.GObject): 41 | 42 | def __init__(self, data_path: str = None): 43 | GObject.GObject.__init__(self) 44 | 45 | self._set = set([]) 46 | self._data_path = data_path 47 | if self._data_path is None: 48 | self._data_path = get_data_file_path('read') 49 | 50 | self.load() 51 | 52 | def read(self, name): 53 | self._set.add(name) 54 | GLib.idle_add(self.save) 55 | 56 | def is_read(self, name): 57 | return name in self._set 58 | 59 | def save(self): 60 | with open(self._data_path, 'w') as f: 61 | for i in self._set: 62 | f.write(i) 63 | f.write('\n') 64 | 65 | def load(self): 66 | if os.path.isfile(self._data_path): 67 | with open(self._data_path) as f: 68 | l = 0 69 | for i in f: 70 | self._set.add(i.strip()) 71 | l += 1 72 | 73 | 74 | _ctrl = None 75 | 76 | 77 | def get_read_controller(): 78 | global _ctrl 79 | if _ctrl is None: 80 | _ctrl = ReadController() 81 | return _ctrl 82 | -------------------------------------------------------------------------------- /redditisgtk/settings.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Sam Parkinson 2 | # 3 | # This file is part of Something for Reddit. 4 | # 5 | # Something for Reddit is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Something for Reddit is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with Something for Reddit. If not, see . 17 | 18 | from gi.repository import Gtk 19 | from gi.repository import Gio 20 | 21 | 22 | _settings = None 23 | 24 | 25 | def get_settings(): 26 | ''' 27 | Returns our Gio.Settings 28 | ''' 29 | global _settings 30 | if _settings is None: 31 | _settings = Gio.Settings(schema='today.sam.something-for-reddit') 32 | return _settings 33 | 34 | 35 | _original_theme_value = None 36 | 37 | 38 | def show_settings(): 39 | global _original_theme_value 40 | 41 | builder = Gtk.Builder.new_from_resource( 42 | '/today/sam/reddit-is-gtk/settings-window.ui') 43 | window = builder.get_object('window') 44 | window.show() 45 | 46 | 47 | def __theme_changed_cb(combo): 48 | builder.get_object('restart-warning').props.visible = \ 49 | combo.props.active_id != _original_theme_value 50 | 51 | theme_combo = builder.get_object('theme') 52 | get_settings().bind('theme', theme_combo, 53 | 'active-id', Gio.SettingsBindFlags.DEFAULT) 54 | if _original_theme_value is None: 55 | _original_theme_value = theme_combo.props.active_id 56 | __theme_changed_cb(theme_combo) 57 | theme_combo.connect('changed', __theme_changed_cb) 58 | 59 | get_settings().bind('default-sub', builder.get_object('default-sub'), 60 | 'text', Gio.SettingsBindFlags.DEFAULT) 61 | -------------------------------------------------------------------------------- /redditisgtk/submit.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Sam Parkinson 2 | # 3 | # This file is part of Something for Reddit. 4 | # 5 | # Something for Reddit is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Something for Reddit is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with Something for Reddit. If not, see . 17 | 18 | from gi.repository import Gtk 19 | from gi.repository import GObject 20 | 21 | from redditisgtk.api import RedditAPI 22 | 23 | 24 | class SubmitWindow(GObject.GObject): 25 | 26 | done = GObject.Signal('done', arg_types=[str, str]) 27 | 28 | def __init__(self, api: RedditAPI, sub=''): 29 | GObject.GObject.__init__(self) 30 | self._api = api 31 | 32 | self._b = Gtk.Builder.new_from_resource( 33 | '/today/sam/reddit-is-gtk/submit-window.ui') 34 | self.window = self._b.get_object('submit-window') 35 | 36 | self._b.get_object('sub-entry').props.text = sub 37 | self._b.get_object('submit-button').connect( 38 | 'clicked', self.__submit_clicked_cb) 39 | 40 | def show(self): 41 | self.window.show() 42 | 43 | def __submit_clicked_cb(self, button): 44 | submit = self._b.get_object('submit-button') 45 | submit.props.label = 'Submitting...' 46 | submit.props.sensitive = False 47 | 48 | data = {'title': self._b.get_object('title-entry').props.text, 49 | 'sr': self._b.get_object('sub-entry').props.text} 50 | stack = self._b.get_object('link-self-stack') 51 | if stack.props.visible_child_name == 'link': 52 | data['kind'] = 'link' 53 | data['url'] = self._b.get_object('link-entry').props.text 54 | else: 55 | data['kind'] = 'self' 56 | buf = self._b.get_object('self-textview').props.buffer 57 | data['text'] = buf.get_text(buf.get_start_iter(), 58 | buf.get_end_iter(), False) 59 | self._api.submit(data, self.__submit_done_cb) 60 | 61 | def __submit_done_cb(self, data): 62 | data = data['json'] 63 | if data.get('errors'): 64 | errors = data['errors'] 65 | error_name, error_text, error_name_lower = errors[0] 66 | 67 | error = self._b.get_object('error-label') 68 | error.props.label = error_text 69 | error.show() 70 | 71 | submit = self._b.get_object('submit-button') 72 | submit.props.sensitive = True 73 | submit.props.label = 'Submit' 74 | else: 75 | uri = data['data']['url'] 76 | self.done.emit(self._b.get_object('sub-entry').props.text, 77 | uri) 78 | self.window.hide() 79 | self.window.destroy() 80 | -------------------------------------------------------------------------------- /redditisgtk/test_aboutrow.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | from gi.repository import Gtk 4 | 5 | from redditisgtk import aboutrow 6 | from redditisgtk.gtktestutil import with_test_mainloop, find_widget, wait_for 7 | 8 | 9 | @with_test_mainloop 10 | def test_subreddit(): 11 | api = MagicMock() 12 | root = aboutrow.get_about_row(api, '/r/linux') 13 | 14 | 15 | @with_test_mainloop 16 | @patch('redditisgtk.submit.SubmitWindow', autospec=True) 17 | def test_subreddit_submit(SubmitWindow): 18 | api = MagicMock() 19 | root = aboutrow.get_about_row(api, '/r/linux') 20 | 21 | find_widget(root, label='Submit', kind=Gtk.Button).emit('clicked') 22 | wait_for(lambda: SubmitWindow.called) 23 | SubmitWindow.assert_called_with(api, sub='linux') 24 | 25 | 26 | @with_test_mainloop 27 | def test_subreddit_subscribe_button_initial_state(): 28 | api = MagicMock() 29 | api.lower_user_subs = ['/r/sub/'] 30 | 31 | root = aboutrow.get_about_row(api, '/r/linux') 32 | find_widget(root, label='Subscribe', kind=Gtk.Button) 33 | 34 | root = aboutrow.get_about_row(api, '/r/sub') 35 | find_widget(root, label='Subscribed', kind=Gtk.Button) 36 | 37 | 38 | @with_test_mainloop 39 | def test_subreddit_subscribe_button_toggle(): 40 | api = MagicMock() 41 | root = aboutrow.get_about_row(api, '/r/linux') 42 | btn = find_widget(root, label='Subscribe', kind=Gtk.Button) 43 | btn.emit('clicked') 44 | 45 | wait_for(lambda: api.set_subscribed.called) 46 | (name, active, cb), _ = api.set_subscribed.call_args 47 | assert name == 'linux' 48 | assert active == True 49 | 50 | cb(None) 51 | assert btn == find_widget(root, label='Subscribed', kind=Gtk.Button) 52 | 53 | 54 | @with_test_mainloop 55 | def test_subreddit_info(): 56 | api = MagicMock() 57 | root = aboutrow.get_about_row(api, '/r/linux') 58 | 59 | expander = find_widget(root, kind=Gtk.Expander) 60 | expander.activate() 61 | wait_for(lambda: api.get_subreddit_info.called) 62 | 63 | (name, cb), _ = api.get_subreddit_info.call_args 64 | assert name == 'linux' 65 | cb({'data': {'description': 'hello'}}) 66 | 67 | assert find_widget(root, label='hello').props.visible == True 68 | 69 | expander.activate() 70 | expander.activate() 71 | assert api.get_subreddit_info.call_count == 1 72 | 73 | 74 | @with_test_mainloop 75 | def test_user_about_row(): 76 | api = MagicMock() 77 | root = aboutrow.get_about_row(api, '/u/bob') 78 | 79 | assert find_widget(root, label='bob') 80 | 81 | wait_for(lambda: api.get_user_info.called) 82 | (name, cb), _ = api.get_user_info.call_args 83 | assert name == 'bob' 84 | cb({'data': {'link_karma': 2, 'comment_karma': 1}}) 85 | 86 | assert find_widget(root, label='2l / 1c') 87 | -------------------------------------------------------------------------------- /redditisgtk/test_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | from unittest.mock import MagicMock 4 | 5 | from redditisgtk import api 6 | 7 | 8 | def build_fake_soup_session(responses): 9 | ''' 10 | Responses is a dictionary of url -> resp 11 | 12 | If resp is a dict, it is returned as json 13 | 14 | If resp is a list, the item is popped and returned as json 15 | ''' 16 | session = MagicMock() 17 | def queue_message(msg, callback, user_data): 18 | url = msg.props.uri.to_string(True) 19 | 20 | msg.props = MagicMock() 21 | msg.props.status_code = 200 22 | 23 | resp = responses[url] 24 | if isinstance(resp, list): 25 | resp = resp.pop(0) 26 | data = bytes(json.dumps(resp), 'utf8') 27 | flat = MagicMock() 28 | flat.get_data.return_value = data 29 | msg.props.response_body.flatten.return_value = flat 30 | msg.props.response_body.data = data 31 | 32 | callback(session, msg, user_data) 33 | session.queue_message = queue_message 34 | return session 35 | 36 | def build_fake_api(responses=None, is_anonymous=True): 37 | session = build_fake_soup_session(responses or {}) 38 | token = MagicMock() 39 | token.wrap_path = lambda p: 'https://example.com' + p 40 | token.is_anonymous = is_anonymous 41 | return api.RedditAPI(session, token), session, token 42 | 43 | def test_create_api(): 44 | api, session, token = build_fake_api() 45 | assert api 46 | 47 | def test_token_changed_gets_username(): 48 | api, session, token = build_fake_api({ 49 | '/subreddits/mine/subscriber?limit=100&raw_json=1': { 50 | 'data': { 51 | 'children': [], 52 | }, 53 | }, 54 | '/api/v1/me?raw_json=1': { 55 | 'name': 'myname', 56 | }, 57 | }, is_anonymous=False) 58 | assert token.set_user_name.called 59 | (name,), _ = token.set_user_name.call_args 60 | assert name == 'myname' 61 | 62 | token.user_name = 'something' 63 | assert api.user_name is token.user_name 64 | 65 | def test_token_changed_to_annon(): 66 | api, session, token = build_fake_api() 67 | assert api.user_name is token.user_name 68 | 69 | def test_update_subscriptions(): 70 | api, session, token = build_fake_api({ 71 | '/subreddits/mine/subscriber?limit=100&raw_json=1': { 72 | 'data': { 73 | 'children': [{ 74 | 'data': { 75 | 'url': '/r/one', 76 | }, 77 | }], 78 | }, 79 | 'after': 'afterkey', 80 | }, 81 | '/subreddits/mine/subscriber?limit=100&after=afterkey&raw_json=1': { 82 | 'data': { 83 | 'children': [{ 84 | 'data': { 85 | 'url': '/r/two', 86 | }, 87 | }], 88 | }, 89 | }, 90 | }) 91 | api.subs_changed = MagicMock() 92 | 93 | api.update_subscriptions() 94 | assert api.user_subs == ['/r/one', '/r/two'] 95 | assert api.lower_user_subs == ['/r/one', '/r/two'] 96 | assert api.subs_changed.emit.called 97 | 98 | def test_retry_on_401(): 99 | api, session, token = build_fake_api({ 100 | '/test?raw_json=1': [ 101 | { 102 | 'error': 401, 103 | }, 104 | { 105 | 'win': True, 106 | }, 107 | ], 108 | }) 109 | 110 | done_cb = MagicMock() 111 | api.send_request('GET', '/test', done_cb) 112 | assert token.refresh.called 113 | (inner_cb,), _ = token.refresh.call_args 114 | inner_cb() 115 | assert done_cb.call_count == 1 116 | (data,), _ = done_cb.call_args 117 | assert data == {'win': True} 118 | 119 | 120 | def test_bubble_error(): 121 | api, session, token = build_fake_api({ 122 | '/test?raw_json=1': {'error': 403}, 123 | }) 124 | 125 | done_cb = MagicMock() 126 | api.request_failed = MagicMock() 127 | api.send_request('GET', '/test', done_cb) 128 | assert done_cb.call_count == 0 129 | 130 | assert api.request_failed.emit.called 131 | (args, msg), _ = api.request_failed.emit.call_args 132 | assert msg == 'Reddit Error: 403' 133 | 134 | 135 | def test_callback_user_data(): 136 | api, session, token = build_fake_api({ 137 | '/test?raw_json=1': {'win': True}, 138 | }) 139 | 140 | done_cb = MagicMock() 141 | ud = 'something' 142 | api.send_request('GET', '/test', done_cb, user_data=ud) 143 | assert done_cb.call_count == 1 144 | (data, ud_ref), _ = done_cb.call_args 145 | assert data == {'win': True} 146 | assert ud_ref is ud 147 | 148 | def test_load_more(datadir): 149 | with open(datadir / 'api__load-more.json') as f: 150 | j = json.load(f) 151 | api, session, token = build_fake_api({ 152 | '/api/morechildren?api_type=json&children=a%2Cb&link_id=link_name&raw_json=1': ( 153 | j['input']), 154 | }) 155 | 156 | done_cb = MagicMock() 157 | api.load_more('link_name', {'children': ['a', 'b']}, done_cb) 158 | assert done_cb.call_count == 1 159 | (data,), _ = done_cb.call_args 160 | assert data == j['output'] 161 | 162 | def test_token_manager_base(): 163 | tm = api.TokenManager() 164 | with pytest.raises(NotImplementedError): 165 | tm.refresh(lambda: None) 166 | with pytest.raises(NotImplementedError): 167 | tm.wrap_path('') 168 | with pytest.raises(NotImplementedError): 169 | tm.add_message_headers(None) 170 | with pytest.raises(NotImplementedError): 171 | tm.serialize() 172 | 173 | def test_token_anonmyous_refresh(): 174 | tm = api.AnonymousTokenManager() 175 | cb = MagicMock() 176 | tm.refresh(cb) 177 | assert cb.called 178 | 179 | def test_token_anonmyous_wrap_path(): 180 | tm = api.AnonymousTokenManager() 181 | assert tm.wrap_path('/hello') == 'https://api.reddit.com/hello' 182 | 183 | def test_token_anonmyous_add_message_headers(): 184 | tm = api.AnonymousTokenManager() 185 | tm.add_message_headers(None) 186 | 187 | def test_token_oauth_refresh_initial(): 188 | session = build_fake_soup_session({ 189 | '/api/v1/access_token': { 190 | 'win': 1, 191 | }, 192 | }) 193 | cb = MagicMock() 194 | tm = api.OAuthTokenManager(session, code='code', ready_callback=cb) 195 | assert cb.called 196 | assert tm.serialize()['win'] == 1 197 | 198 | def test_token_oauth_refresh(): 199 | session = build_fake_soup_session({ 200 | '/api/v1/access_token': { 201 | 'win': 1, 202 | }, 203 | }) 204 | cb = MagicMock() 205 | cb2 = MagicMock() 206 | tm = api.OAuthTokenManager( 207 | session, 208 | token={'refresh_token': 'yes', 'win': 0}, 209 | ready_callback=cb) 210 | tm.value_changed = MagicMock() 211 | tm.refresh(cb2) 212 | assert cb.called 213 | assert cb2.called 214 | assert tm.serialize()['win'] == 1 215 | assert tm.serialize()['refresh_token'] == 'yes' 216 | assert tm.value_changed.emit.called 217 | 218 | def test_token_oauth_set_username(): 219 | tm = api.OAuthTokenManager(None) 220 | tm.value_changed = MagicMock() 221 | assert tm.user_name != 'me' 222 | tm.set_user_name('me') 223 | assert tm.user_name == 'me' 224 | assert tm.serialize()['username'] == 'me' 225 | assert tm.value_changed.emit.called 226 | 227 | def test_token_oauth_add_message_headers(): 228 | msg = MagicMock() 229 | tm = api.OAuthTokenManager(None, token={'access_token': 1}) 230 | tm.add_message_headers(msg) 231 | args, _ = msg.props.request_headers.append.call_args 232 | assert args == ('Authorization', 'bearer 1') 233 | 234 | def test_api_factory(): 235 | factory = api.APIFactory(MagicMock()) 236 | token1 = MagicMock() 237 | token2 = MagicMock() 238 | api1 = factory.get_for_token(token1) 239 | api2 = factory.get_for_token(token2) 240 | api1b = factory.get_for_token(token1) 241 | assert api1 is api1b 242 | assert api1 is not api2 243 | -------------------------------------------------------------------------------- /redditisgtk/test_comments.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import MagicMock 3 | 4 | from gi.repository import Gtk 5 | 6 | from redditisgtk import comments 7 | from redditisgtk.gtktestutil import (with_test_mainloop, find_widget, wait_for, 8 | get_focused, fake_event) 9 | 10 | PERMALINK = '/r/MaliciousCompliance/comments/9vzevr/you_need_a_doctors_note_you_got_it/' 11 | 12 | 13 | @with_test_mainloop 14 | def test_load_selftext(datadir): 15 | api = MagicMock() 16 | 17 | root = comments.CommentsView(api, permalink=PERMALINK) 18 | assert find_widget(root, kind=Gtk.Spinner) 19 | 20 | (method, link, cb), _ = api.send_request.call_args 21 | assert method == 'GET' 22 | assert link == PERMALINK 23 | 24 | with open(datadir / 'comments--thread.json') as f: 25 | cb(json.load(f)) 26 | 27 | # Title: 28 | assert find_widget(root, label='You need a doctor’s note? You got it!') 29 | # Body: 30 | assert find_widget(root, label='This happened today.') 31 | -------------------------------------------------------------------------------- /redditisgtk/test_emptyview.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from gi.repository import Gtk 4 | 5 | from redditisgtk import emptyview 6 | from redditisgtk.gtktestutil import with_test_mainloop, find_widget, wait_for 7 | 8 | 9 | @with_test_mainloop 10 | def test_emptyview_has_label(): 11 | root = emptyview.EmptyView('yo') 12 | assert find_widget(root, label='yo') 13 | 14 | 15 | @with_test_mainloop 16 | def test_emptyview_with_action(): 17 | root = emptyview.EmptyView('yo', action='do') 18 | root.action = MagicMock() 19 | find_widget(root, label='do', kind=Gtk.Button).emit('clicked') 20 | wait_for(lambda: root.action.emit.called) 21 | -------------------------------------------------------------------------------- /redditisgtk/test_gtkutil.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from redditisgtk import gtkutil 4 | from redditisgtk.gtktestutil import fake_event 5 | 6 | 7 | def test_process_shortcuts(): 8 | up_cb = MagicMock() 9 | k_cb = MagicMock() 10 | 11 | shortcuts = { 12 | 'Up': (up_cb, ['up']), 13 | 'k': (k_cb, ['k']), 14 | } 15 | 16 | gtkutil.process_shortcuts(shortcuts, fake_event('a', event_type=None)) 17 | assert not up_cb.called 18 | assert not k_cb.called 19 | 20 | gtkutil.process_shortcuts(shortcuts, fake_event(65362)) 21 | assert up_cb.called 22 | assert up_cb.call_args[0] == ('up',) 23 | 24 | gtkutil.process_shortcuts(shortcuts, fake_event('k')) 25 | assert not k_cb.called 26 | 27 | gtkutil.process_shortcuts(shortcuts, fake_event('k', ctrl=True)) 28 | assert k_cb.called 29 | assert k_cb.call_args[0] == ('k',) 30 | -------------------------------------------------------------------------------- /redditisgtk/test_identity.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | import shutil 4 | from unittest.mock import MagicMock, patch 5 | 6 | from redditisgtk import api 7 | from redditisgtk import identity 8 | 9 | def build_identity_controller(path: str = None): 10 | if path is None: 11 | path = '/fake/wow-this-is-a-fake-path' 12 | 13 | session = MagicMock() 14 | ic = identity.IdentityController(session, path) 15 | return ic, session 16 | 17 | 18 | def test_identity_controller_create(): 19 | ic, session = build_identity_controller() 20 | 21 | 22 | def test_identity_controller_default_active(): 23 | ic, session = build_identity_controller() 24 | assert isinstance(ic.active_token, api.TokenManager) 25 | 26 | 27 | def test_identity_controller_load_file(datadir): 28 | with patch('redditisgtk.api.OAuthTokenManager') as FakeTokenManager: 29 | ic, session = build_identity_controller( 30 | path=datadir / 'identity--load.json') 31 | 32 | assert isinstance(ic.active_token, MagicMock) 33 | 34 | token = FakeTokenManager.call_args[1]['token'] 35 | assert token['access_token'] == 'AT' 36 | assert token['username'] == 'myname' 37 | 38 | 39 | def test_token_changed(datadir, tempdir): 40 | path = tempdir / 'i.json' 41 | shutil.copy(datadir / 'identity--load.json', path) 42 | 43 | with patch('redditisgtk.api.OAuthTokenManager') as FakeTokenManager: 44 | ic, session = build_identity_controller(path=path) 45 | ic.token_changed = MagicMock() 46 | 47 | ic.active_token.serialize.return_value = {'win': 1} 48 | 49 | assert ic.active_token.value_changed.connect.called 50 | (callback,), _ = ic.active_token.value_changed.connect.call_args 51 | 52 | callback(ic.active_token) 53 | assert ic.token_changed.emit.called 54 | 55 | with open(path) as f: 56 | data = json.load(f) 57 | assert data['active'] == 'testid' 58 | assert data['tokens']['testid'] == {'win': 1} 59 | 60 | 61 | def test_identity_controller_delete(datadir, tempdir): 62 | path = tempdir / 'i.json' 63 | shutil.copy(datadir / 'identity--load.json', path) 64 | 65 | with patch('redditisgtk.api.OAuthTokenManager') as FakeTokenManager: 66 | ic, session = build_identity_controller(path=path) 67 | ic.token_changed = MagicMock() 68 | 69 | ic.remove_account('testid') 70 | 71 | assert ic.token_changed.emit.called 72 | assert isinstance(ic.active_token, api.AnonymousTokenManager) 73 | 74 | with open(path) as f: 75 | data = json.load(f) 76 | assert data['active'] == None 77 | assert data['tokens'] == {} 78 | -------------------------------------------------------------------------------- /redditisgtk/test_identitybutton.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from gi.repository import Gtk 4 | 5 | from redditisgtk import identitybutton 6 | from redditisgtk.gtktestutil import with_test_mainloop, find_widget, wait_for 7 | 8 | @with_test_mainloop 9 | def test_button_shows_name(): 10 | ic = MagicMock() 11 | ic.active_token.user_name = 'UN1' 12 | 13 | btn = identitybutton.IdentityButton(ic) 14 | assert find_widget(btn, label='UN1', kind=Gtk.Button) 15 | 16 | (cb,), _ = ic.token_changed.connect.call_args 17 | ic.active_token.user_name = 'UN2' 18 | cb(ic) 19 | assert find_widget(btn, label='UN2', kind=Gtk.Button) 20 | 21 | 22 | @with_test_mainloop 23 | def test_popover_lists_accounts(): 24 | ic = MagicMock() 25 | ic.all_tokens = [ 26 | (1, MagicMock(user_name='user name 1')), 27 | (2, MagicMock(user_name='user name 2'))] 28 | 29 | popover = identitybutton._IdentityPopover(ic) 30 | assert find_widget(popover, label='Anonymous') 31 | assert find_widget(popover, label='user name 1') 32 | assert find_widget(popover, label='user name 2') 33 | 34 | ic.all_tokens = [(1, MagicMock(user_name='user name 1 new'))] 35 | (cb,), _ = ic.token_changed.connect.call_args 36 | cb(ic) 37 | assert find_widget(popover, label='Anonymous') 38 | assert find_widget(popover, label='user name 1 new') 39 | assert find_widget(popover, label='user name 2', many=True) == [] 40 | assert find_widget(popover, label='user name 1', many=True) == [] 41 | 42 | @with_test_mainloop 43 | def test_popover_selects_row(): 44 | ic = MagicMock() 45 | ic.all_tokens = [ 46 | (1, MagicMock(user_name='user name 1')), 47 | (2, MagicMock(user_name='user name 2'))] 48 | ic.active_token = ic.all_tokens[0][1] 49 | 50 | popover = identitybutton._IdentityPopover(ic) 51 | 52 | def get_row(text: str): 53 | label = find_widget(popover, label=text) 54 | while not isinstance(label, Gtk.ListBoxRow): 55 | assert label 56 | label = label.get_parent() 57 | return label 58 | 59 | row1 = get_row('user name 1') 60 | row2 = get_row('user name 2') 61 | listbox = find_widget(popover, kind=Gtk.ListBox) 62 | 63 | assert listbox.get_selected_rows() == [row1] 64 | listbox.emit('row-selected', row2) 65 | wait_for(lambda: ic.switch_account.called) 66 | assert ic.switch_account.call_args[0][0] == 2 67 | -------------------------------------------------------------------------------- /redditisgtk/test_newmarkdown.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from redditisgtk import newmarkdown 4 | from redditisgtk.gtktestutil import (with_test_mainloop, snapshot_widget, 5 | find_widget) 6 | from redditisgtk.conftest import assert_matches_snapshot 7 | 8 | 9 | def test_hello(): 10 | widget = newmarkdown.make_markdown_widget('hello') 11 | assert_matches_snapshot('newmarkdown--hello', snapshot_widget(widget)) 12 | 13 | 14 | def test_inline(): 15 | widget = newmarkdown.make_markdown_widget('**b** _i_ ~~s~~ ``') 16 | assert_matches_snapshot('newmarkdown--inline', snapshot_widget(widget)) 17 | 18 | 19 | def test_super(): 20 | w = newmarkdown.make_markdown_widget('hello ^my world') 21 | assert_matches_snapshot('newmarkdown--super', snapshot_widget(w)) 22 | 23 | w = newmarkdown.make_markdown_widget('hello ^(woah long) world') 24 | assert_matches_snapshot('newmarkdown--super-long', snapshot_widget(w)) 25 | 26 | 27 | def test_blockquote(): 28 | widget = newmarkdown.make_markdown_widget(''' 29 | hello world 30 | 31 | > **quoted** 32 | > 33 | > > sub quote 34 | ''') 35 | assert_matches_snapshot('newmarkdown--quote', snapshot_widget(widget)) 36 | 37 | 38 | def test_hr(): 39 | widget = newmarkdown.make_markdown_widget(''' 40 | hello 41 | 42 | --- 43 | 44 | world 45 | ''') 46 | assert_matches_snapshot('newmarkdown--hr', snapshot_widget(widget)) 47 | 48 | 49 | def test_heading(): 50 | widget = newmarkdown.make_markdown_widget(''' 51 | # big 52 | ### small 53 | ''') 54 | assert_matches_snapshot('newmarkdown--heading', snapshot_widget(widget)) 55 | 56 | 57 | def test_list(): 58 | widget = newmarkdown.make_markdown_widget(''' 59 | 1. hello 60 | 3. world 61 | 62 | * yeah 63 | * sam 64 | ''') 65 | assert_matches_snapshot('newmarkdown--list', snapshot_widget(widget)) 66 | 67 | 68 | def test_link(): 69 | widget = newmarkdown.make_markdown_widget('/r/linux') 70 | assert_matches_snapshot('newmarkdown--link--r', snapshot_widget(widget)) 71 | 72 | cb = MagicMock() 73 | label = find_widget(widget, label='/r/linux') 74 | label.get_toplevel().load_uri_from_label = cb 75 | 76 | label.emit('activate-link', '/r/linux') 77 | assert cb.called 78 | (link,), _ = cb.call_args 79 | assert link == '/r/linux' 80 | 81 | 82 | def test_code(): 83 | widget = newmarkdown.make_markdown_widget(''' 84 | hello: 85 | 86 | 87 | 88 | ''') 89 | assert_matches_snapshot('newmarkdown--code', snapshot_widget(widget)) 90 | 91 | 92 | def test_escaping(): 93 | # This is "valid", e.g. 94 | # https://www.reddit.com/r/programmingcirclejerk/comments/9v7ix9/github_cee_lo_green_implements_fk_you_as_a_feature/e9adb06/ 95 | widget = newmarkdown.make_markdown_widget('Please refer to for') 96 | assert_matches_snapshot('newmarkdown--escaping', snapshot_widget(widget)) 97 | 98 | w = newmarkdown.make_markdown_widget('Code: ``') 99 | assert_matches_snapshot('newmarkdown--escaping-code', snapshot_widget(w)) 100 | 101 | w = newmarkdown.make_markdown_widget('& I <3 you') 102 | assert_matches_snapshot('newmarkdown--escaping-amp', snapshot_widget(w)) 103 | 104 | def test_error(): 105 | # OK so this comment is no longer valid, so I'm not going to bother to fix 106 | # things for it: 107 | # https://www.reddit.com/r/reddit.com/comments/6ewgt/reddit_markdown_primer_or_how_do_you_do_all_that/c03nmy1/ 108 | w = newmarkdown.make_html_widget('

Issue ™

') 109 | assert_matches_snapshot('newmarkdown--error-handler', snapshot_widget(w)) 110 | -------------------------------------------------------------------------------- /redditisgtk/test_posttopbar.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import MagicMock, patch 3 | 4 | from gi.repository import Gtk 5 | 6 | from redditisgtk import posttopbar 7 | from redditisgtk.gtktestutil import (with_test_mainloop, find_widget, wait_for, 8 | fake_event) 9 | 10 | 11 | @with_test_mainloop 12 | def test_for_comment(datadir): 13 | api = MagicMock() 14 | toplevel_cv = MagicMock() 15 | 16 | with open(datadir / 'posttopbar--comment.json') as f: 17 | data = json.load(f) 18 | 19 | bar = posttopbar.PostTopBar(api, data, toplevel_cv) 20 | # author 21 | assert find_widget(bar, label='andnbspsc', kind=Gtk.Button) 22 | # score 23 | assert find_widget(bar, label='score hidden', kind=Gtk.Button) 24 | # no subreddit 25 | assert not find_widget(bar, label='linux', many=True) 26 | 27 | 28 | @with_test_mainloop 29 | def test_for_post(datadir): 30 | api = MagicMock() 31 | toplevel_cv = MagicMock() 32 | 33 | with open(datadir / 'posttopbar--post.json') as f: 34 | data = json.load(f) 35 | 36 | bar = posttopbar.PostTopBar(api, data, toplevel_cv, show_subreddit=True) 37 | # author 38 | assert find_widget(bar, label='sandragen', kind=Gtk.Button) 39 | # score 40 | assert find_widget(bar, label='score hidden', kind=Gtk.Button) 41 | # subreddit 42 | assert find_widget(bar, label='linux', many=True) 43 | 44 | 45 | @with_test_mainloop 46 | def test_vote_key(datadir): 47 | api = MagicMock() 48 | toplevel_cv = MagicMock() 49 | with open(datadir / 'posttopbar--comment.json') as f: 50 | data = json.load(f) 51 | 52 | bar = posttopbar.PostTopBar(api, data, toplevel_cv) 53 | bar.get_toplevel = lambda: api 54 | bar.do_event(fake_event('u')) 55 | assert api.vote.call_args[0] == ('t1_e9nhj7n', +1) 56 | bar.do_event(fake_event('d')) 57 | assert api.vote.call_args[0] == ('t1_e9nhj7n', -1) 58 | bar.do_event(fake_event('n')) 59 | assert api.vote.call_args[0] == ('t1_e9nhj7n', 0) 60 | 61 | 62 | @with_test_mainloop 63 | def test_reply_palette(datadir): 64 | api = MagicMock() 65 | toplevel_cv = MagicMock() 66 | with open(datadir / 'posttopbar--comment.json') as f: 67 | data = json.load(f) 68 | 69 | bar = posttopbar.PostTopBar(api, data, toplevel_cv) 70 | bar.get_toplevel = lambda: api 71 | poproot = Gtk.Popover() 72 | with patch('gi.repository.Gtk.Popover') as Popover: 73 | Popover.return_value = poproot 74 | bar.do_event(fake_event('r')) 75 | 76 | wait_for(lambda: Popover.called) 77 | 78 | assert poproot.props.visible 79 | tv = find_widget(poproot, kind=Gtk.TextView) 80 | tv.props.buffer.set_text('hello') 81 | 82 | btn = find_widget(poproot, kind=Gtk.Button, label='Post Reply') 83 | btn.emit('clicked') 84 | wait_for(lambda: btn.props.sensitive is False) 85 | (name, text, cb), _ = api.reply.call_args 86 | assert name == 't1_e9nhj7n' 87 | assert text == 'hello' 88 | 89 | cb({'json': {'data': {'things': [{'data': {'id': 'MYID'}}]}}}) 90 | assert poproot.props.visible == False 91 | -------------------------------------------------------------------------------- /redditisgtk/test_readcontroller.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | from redditisgtk import readcontroller 4 | 5 | @patch('os.makedirs') 6 | @patch('os.path.isdir', return_value=False) 7 | def test_get_data_file_path(isdir, makedirs): 8 | path = readcontroller.get_data_file_path('name') 9 | assert path.endswith('name') 10 | assert path.startswith('/') 11 | assert makedirs.called 12 | 13 | 14 | @patch('os.makedirs') 15 | @patch('os.path.isdir', return_value=True) 16 | def test_get_data_file_path_no_create(isdir, makedirs): 17 | path = readcontroller.get_data_file_path('name') 18 | assert not makedirs.called 19 | 20 | 21 | def test_readcontroller_load(tmpdir): 22 | path = tmpdir / 'read' 23 | with open(path, 'w') as f: 24 | f.write('a1\na2') 25 | ctrl = readcontroller.ReadController(data_path=path) 26 | assert ctrl.is_read('a1') 27 | assert ctrl.is_read('a2') 28 | assert not ctrl.is_read('b1') 29 | 30 | ctrl.read('b1') 31 | assert ctrl.is_read('b1') 32 | 33 | ctrl.save() 34 | with open(path) as f: 35 | assert sorted(f.read().splitlines()) == ['a1', 'a2', 'b1'] 36 | -------------------------------------------------------------------------------- /redditisgtk/test_subentry.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from gi.repository import Gtk 4 | 5 | from redditisgtk import subentry 6 | from redditisgtk.gtktestutil import (with_test_mainloop, find_widget, wait_for, 7 | fake_event) 8 | 9 | 10 | def test_clean_sub(): 11 | assert subentry.clean_sub('') == '/' 12 | assert subentry.clean_sub('hello') == '/hello' 13 | assert subentry.clean_sub('/u/sam') == '/user/sam' 14 | 15 | 16 | def test_clean_sub_passes_through_uris(): 17 | assert subentry.clean_sub('https://') == 'https://' 18 | assert subentry.clean_sub('http://reddit.com') == 'http://reddit.com' 19 | 20 | 21 | def test_format_sub_for_api_frontpage(): 22 | assert subentry.format_sub_for_api('') == '/hot' 23 | assert subentry.format_sub_for_api('/') == '/hot' 24 | assert subentry.format_sub_for_api('/top') == '/top' 25 | 26 | 27 | def test_format_sub_for_api_subreddit(): 28 | assert subentry.format_sub_for_api('r/linux') == '/r/linux/hot' 29 | assert subentry.format_sub_for_api('/r/l/top?t=all') == '/r/l/top?t=all' 30 | 31 | 32 | def test_format_sub_for_api_user(): 33 | assert subentry.format_sub_for_api('/u/sam') == '/user/sam/overview' 34 | assert subentry.format_sub_for_api('/u/sam/up') == '/user/sam/up' 35 | 36 | 37 | @with_test_mainloop 38 | def test_subentry_create(): 39 | api = MagicMock() 40 | api.user_name = 'username' 41 | root = subentry.SubEntry(api, text='/r/linux') 42 | 43 | assert find_widget(root, label='/r/linux') 44 | 45 | 46 | @with_test_mainloop 47 | def test_subentry_palette_activate(): 48 | api = MagicMock() 49 | api.user_name = 'username' 50 | root = subentry.SubEntry(api) 51 | root.activate = MagicMock() 52 | 53 | down_button = find_widget(root, kind=Gtk.Button) 54 | down_button.emit('clicked') 55 | poproot = root._palette # err IDK about this 56 | wait_for(lambda: poproot.props.visible) 57 | 58 | btn = find_widget( 59 | poproot, label='/user/username/submitted', kind=Gtk.Button) 60 | btn.emit('clicked') 61 | wait_for(lambda: root.activate.emit.called) 62 | assert root.activate.emit.call_args[0][0] == '/user/username/submitted' 63 | 64 | 65 | @with_test_mainloop 66 | def test_subentry_palette_subreddits(): 67 | api = MagicMock() 68 | api.user_name = 'username' 69 | api.user_subs = ['/r/linux'] 70 | root = subentry.SubEntry(api) 71 | 72 | down_button = find_widget(root, kind=Gtk.Button) 73 | down_button.emit('clicked') 74 | poproot = root._palette # err IDK about this 75 | wait_for(lambda: poproot.props.visible) 76 | 77 | assert find_widget(poproot, label='/r/linux', many=True) 78 | assert not find_widget(poproot, label='/r/gnu', many=True) 79 | 80 | api.user_subs = ['/r/gnu'] 81 | (cb,), _ = api.subs_changed.connect.call_args 82 | cb(api) 83 | assert not find_widget(poproot, label='/r/linux', many=True) 84 | assert find_widget(poproot, label='/r/gnu', many=True) 85 | 86 | 87 | @with_test_mainloop 88 | def test_subentry_open_uri(): 89 | api = MagicMock() 90 | api.user_name = 'username' 91 | root = subentry.SubEntry(api) 92 | toplevel = MagicMock() 93 | 94 | entry = find_widget(root, kind=Gtk.Entry) 95 | # err IDK about this 96 | entry.is_focus = lambda: True 97 | entry.props.text = 'https://reddit.com/r/yes' 98 | 99 | poproot = root._palette # err IDK about this 100 | poproot.get_toplevel = lambda: toplevel 101 | wait_for(lambda: poproot.props.visible) 102 | 103 | btn = find_widget(poproot, label='Open this reddit.com URI', 104 | kind=Gtk.Button) 105 | btn.emit('clicked') 106 | wait_for(lambda: toplevel.goto_reddit_uri.called) 107 | toplevel.goto_reddit_uri.assert_called_once_with( 108 | 'https://reddit.com/r/yes') 109 | 110 | 111 | @with_test_mainloop 112 | def test_subentry_palette_subreddits_filter(): 113 | api = MagicMock() 114 | api.user_name = 'username' 115 | api.user_subs = ['/r/linux', '/r/gnu'] 116 | root = subentry.SubEntry(api) 117 | poproot = root._palette # err IDK about this 118 | 119 | entry = find_widget(root, kind=Gtk.Entry) 120 | entry.props.text = '/r/l' 121 | 122 | 123 | # When using the button, all should be visible 124 | down_button = find_widget(root, kind=Gtk.Button) 125 | down_button.emit('clicked') 126 | wait_for(lambda: poproot.props.visible) 127 | 128 | assert find_widget(poproot, label='/r/linux', many=True) 129 | assert find_widget(poproot, label='/r/gnu', many=True) 130 | 131 | # err IDK about this 132 | entry.is_focus = lambda: True 133 | entry.props.text = '/r/li' 134 | wait_for(lambda: poproot.props.visible) 135 | 136 | assert find_widget(poproot, label='/r/linux', many=True) 137 | assert not find_widget(poproot, label='/r/gnu', many=True) 138 | -------------------------------------------------------------------------------- /redditisgtk/test_sublist.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import MagicMock, patch 3 | 4 | from gi.repository import Gtk 5 | 6 | from redditisgtk import sublist 7 | from redditisgtk.gtktestutil import (with_test_mainloop, find_widget, wait_for, 8 | fake_event) 9 | 10 | 11 | class FixtureMessageRow(Gtk.Label): 12 | def __init__(self, api, data): 13 | assert data['win'] 14 | Gtk.Label.__init__(self, label='message row') 15 | 16 | 17 | class FixtureLinkRow(Gtk.Label): 18 | goto_comments = MagicMock() 19 | 20 | def __init__(self, api, data): 21 | assert data['win'] 22 | Gtk.Label.__init__(self, label='link row') 23 | 24 | 25 | class FixtureMoreItemsRow(Gtk.Label): 26 | load_more = MagicMock() 27 | 28 | def __init__(self, data): 29 | assert data == 'win' 30 | Gtk.Label.__init__(self, label='more items row') 31 | 32 | 33 | @with_test_mainloop 34 | @patch('redditisgtk.aboutrow.get_about_row', 35 | return_value=Gtk.Label(label='about row')) 36 | @patch('redditisgtk.sublistrows.MessageRow', FixtureMessageRow) 37 | @patch('redditisgtk.sublistrows.LinkRow', FixtureLinkRow) 38 | @patch('redditisgtk.sublistrows.MoreItemsRow', FixtureMoreItemsRow) 39 | def test_sublist_create(get_about_row): 40 | api = MagicMock() 41 | root = sublist.SubList(api, '/r/linux') 42 | 43 | assert find_widget(root, kind=Gtk.Spinner) 44 | assert root.get_uri() == '/r/linux' 45 | 46 | (sub, cb), _ = api.get_list.call_args 47 | assert sub == '/r/linux' 48 | data = { 49 | 'data': { 50 | 'children': [ 51 | {'kind': 't1', 'win': 1}, 52 | {'kind': 't3', 'win': 1}, 53 | ], 54 | 'after': 'win', 55 | }, 56 | } 57 | # Load the data 58 | cb(data) 59 | assert not find_widget(root, kind=Gtk.Spinner, many=True) 60 | 61 | get_about_row.assert_called_with(api, '/r/linux') 62 | assert find_widget(root, label='about row', kind=Gtk.Label) 63 | assert find_widget(root, kind=FixtureMessageRow) 64 | assert find_widget(root, kind=FixtureLinkRow) 65 | assert find_widget(root, kind=FixtureMoreItemsRow) 66 | -------------------------------------------------------------------------------- /redditisgtk/test_sublistrows.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import MagicMock, patch 3 | 4 | from gi.repository import Gtk 5 | 6 | from redditisgtk import sublistrows 7 | from redditisgtk.gtktestutil import (with_test_mainloop, find_widget, wait_for, 8 | fake_event) 9 | 10 | @with_test_mainloop 11 | def test_subitemrow_thumb_from_preview(json_loader): 12 | api = MagicMock() 13 | row = sublistrows.LinkRow(api, 14 | json_loader('sublistrows--thumb-from-previews')) 15 | 16 | assert api.download_thumb.called 17 | (url, cb), _ = api.download_thumb.call_args 18 | assert url == 'https://external-preview.redd.it/AxVEZvB8AZsAPFSlrGM3HGDCss0bxYEbNu89NmgUdTg.jpg?width=108&crop=smart&auto=webp&s=a906c3a7241def971591ded4d7f9ff9abe6b050c' 19 | 20 | 21 | @with_test_mainloop 22 | def test_message_row_pm(json_loader): 23 | api = MagicMock() 24 | row = sublistrows.MessageRow(api, json_loader('sublistrows--pm')) 25 | 26 | assert find_widget(row, label='re: Blog down') 27 | assert find_widget(row, label='thanks a lot!') 28 | # author: 29 | assert find_widget(row, label='bambambazooka', kind=Gtk.Button) 30 | 31 | 32 | @with_test_mainloop 33 | def test_message_row_pm_shortcuts(json_loader): 34 | api = MagicMock() 35 | toplevel = MagicMock() 36 | 37 | row = sublistrows.MessageRow(api, json_loader('sublistrows--pm')) 38 | row.get_toplevel = lambda: toplevel 39 | 40 | row.do_event(fake_event('a')) 41 | toplevel.goto_sublist.assert_called_once_with('/u/bambambazooka') 42 | -------------------------------------------------------------------------------- /redditisgtk/test_submit.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import MagicMock 3 | 4 | from gi.repository import Gtk 5 | 6 | from redditisgtk import submit 7 | from redditisgtk.gtktestutil import with_test_mainloop, find_widget, wait_for 8 | 9 | 10 | @with_test_mainloop 11 | def test_create_window(): 12 | api = MagicMock() 13 | window = submit.SubmitWindow(api) 14 | root = window.window 15 | assert find_widget(root, placeholder='Subreddit').props.text == '' 16 | 17 | 18 | @with_test_mainloop 19 | def test_create_window_with_subreddit(): 20 | api = MagicMock() 21 | window = submit.SubmitWindow(api, sub='linux') 22 | root = window.window 23 | assert find_widget(root, placeholder='Subreddit').props.text == 'linux' 24 | 25 | 26 | @with_test_mainloop 27 | def test_submit_empty(): 28 | api = MagicMock() 29 | window = submit.SubmitWindow(api) 30 | root = window.window 31 | 32 | submit_button = find_widget(root, label='Submit', kind=Gtk.Button) 33 | submit_button.emit('clicked') 34 | 35 | wait_for(lambda: api.submit.called) 36 | 37 | loading = find_widget(root, label='Submitting...', kind=Gtk.Button) 38 | assert loading.props.sensitive == False 39 | 40 | 41 | @with_test_mainloop 42 | def test_submit_link(): 43 | api = MagicMock() 44 | window = submit.SubmitWindow(api) 45 | root = window.window 46 | 47 | find_widget(root, placeholder='Title').props.text = 'Some Title' 48 | find_widget(root, placeholder='Link').props.text = 'example.com' 49 | find_widget(root, placeholder='Subreddit').props.text = 'test' 50 | find_widget(root, label='Submit', kind=Gtk.Button).emit('clicked') 51 | 52 | wait_for(lambda: api.submit.called) 53 | (data, cb), _ = api.submit.call_args 54 | assert data == { 55 | 'kind': 'link', 56 | 'sr': 'test', 57 | 'title': 'Some Title', 58 | 'url': 'example.com', 59 | } 60 | 61 | 62 | @with_test_mainloop 63 | def test_submit_self(): 64 | api = MagicMock() 65 | window = submit.SubmitWindow(api) 66 | root = window.window 67 | 68 | self_post = find_widget(root, kind=Gtk.Button, label='Self Post') 69 | self_post.props.active = True 70 | stack = find_widget(root, kind=Gtk.Stack) 71 | wait_for(lambda: stack.props.visible_child_name == 'self') 72 | 73 | find_widget(root, placeholder='Title').props.text = 'Some Title' 74 | find_widget(root, kind=Gtk.TextView).props.buffer.set_text('self') 75 | find_widget(root, placeholder='Subreddit').props.text = 'test' 76 | find_widget(root, label='Submit', kind=Gtk.Button).emit('clicked') 77 | 78 | wait_for(lambda: api.submit.called) 79 | (data, cb), _ = api.submit.call_args 80 | assert data == { 81 | 'kind': 'self', 82 | 'sr': 'test', 83 | 'title': 'Some Title', 84 | 'text': 'self', 85 | } 86 | 87 | @with_test_mainloop 88 | def test_submit_error(datadir): 89 | api = MagicMock() 90 | window = submit.SubmitWindow(api) 91 | root = window.window 92 | 93 | submit_button = find_widget(root, label='Submit', kind=Gtk.Button) 94 | submit_button.emit('clicked') 95 | wait_for(lambda: api.submit.called) 96 | (data, callback), _ = api.submit.call_args 97 | 98 | with open(datadir / 'submit--ratelimit-response.json') as f: 99 | data = json.load(f) 100 | msg = data['json']['errors'][0][1] 101 | callback(data) 102 | 103 | assert submit_button.props.sensitive 104 | label = find_widget(root, label=msg) 105 | assert label.props.visible 106 | 107 | 108 | @with_test_mainloop 109 | def test_submit_good(datadir): 110 | api = MagicMock() 111 | window = submit.SubmitWindow(api) 112 | window.done = MagicMock() 113 | root = window.window 114 | 115 | submit_button = find_widget(root, label='Submit', kind=Gtk.Button) 116 | submit_button.emit('clicked') 117 | wait_for(lambda: api.submit.called) 118 | (data, callback), _ = api.submit.call_args 119 | 120 | with open(datadir / 'submit--good-response.json') as f: 121 | callback(json.load(f)) 122 | 123 | assert window.done.emit.called 124 | (sub, uri), _ = window.done.emit.call_args 125 | assert uri == 'https://www.reddit.com/r/test/comments/9teb69/test/' 126 | -------------------------------------------------------------------------------- /redditisgtk/tests-data/identity--load.json: -------------------------------------------------------------------------------- 1 | {"tokens": {"testid": {"access_token": "AT", "token_type": "bearer", "expires_in": 3600, "refresh_token": "RT", "scope": "edit history identity mysubreddits privatemessages read save submit subscribe vote", "time": 1540967369.7608967, "username": "myname"}}, "active": "testid"} 2 | -------------------------------------------------------------------------------- /redditisgtk/tests-data/posttopbar--comment.json: -------------------------------------------------------------------------------- 1 | {"subreddit_id": "t5_2qh1a", "approved_at_utc": null, "ups": 1, "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "removal_reason": null, "link_id": "t3_9wuoss", "author_flair_template_id": null, "likes": null, "no_follow": true, "replies": "", "user_reports": [], "saved": false, "id": "e9nhj7n", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "report_reasons": null, "author": "andnbspsc", "can_mod_post": false, "send_replies": true, "parent_id": "t1_e9nfjwa", "score": 1, "author_fullname": "t2_zlqp1", "approved_by": null, "downs": 0, "body": "Oh woops ok bye!", "edited": false, "author_flair_css_class": null, "is_submitter": true, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "collapsed_reason": null, "body_html": "

Oh woops ok bye!

\n
", "stickied": false, "subreddit_type": "public", "can_gild": true, "gildings": {"gid_1": 0, "gid_2": 0, "gid_3": 0}, "author_flair_text_color": null, "score_hidden": true, "permalink": "/r/linux/comments/9wuoss/keyboard_shortcuts_between_terminal_and_other_gui/e9nhj7n/", "num_reports": null, "name": "t1_e9nhj7n", "created": 1542185030.0, "subreddit": "linux", "author_flair_text": null, "created_utc": 1542156230.0, "subreddit_name_prefixed": "r/linux", "controversiality": 0, "depth": 1, "author_flair_background_color": null, "mod_reports": [], "mod_note": null, "distinguished": null} 2 | -------------------------------------------------------------------------------- /redditisgtk/tests-data/posttopbar--post.json: -------------------------------------------------------------------------------- 1 | {"subreddit_id": "t5_2qh1a", "approved_at_utc": null, "ups": 1, "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "removal_reason": null, "link_id": "t3_9wuoss", "author_flair_template_id": null, "likes": null, "no_follow": true, "replies": {"kind": "Listing", "data": {"modhash": null, "dist": null, "children": [{"kind": "t1", "data": {"subreddit_id": "t5_2qh1a", "approved_at_utc": null, "ups": 1, "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "removal_reason": null, "link_id": "t3_9wuoss", "author_flair_template_id": null, "likes": null, "no_follow": true, "replies": "", "user_reports": [], "saved": false, "id": "e9nhj7n", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "report_reasons": null, "author": "andnbspsc", "can_mod_post": false, "send_replies": true, "parent_id": "t1_e9nfjwa", "score": 1, "author_fullname": "t2_zlqp1", "approved_by": null, "downs": 0, "body": "Oh woops ok bye!", "edited": false, "author_flair_css_class": null, "is_submitter": true, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "collapsed_reason": null, "body_html": "

Oh woops ok bye!

\n
", "stickied": false, "subreddit_type": "public", "can_gild": true, "gildings": {"gid_1": 0, "gid_2": 0, "gid_3": 0}, "author_flair_text_color": null, "score_hidden": true, "permalink": "/r/linux/comments/9wuoss/keyboard_shortcuts_between_terminal_and_other_gui/e9nhj7n/", "num_reports": null, "name": "t1_e9nhj7n", "created": 1542185030.0, "subreddit": "linux", "author_flair_text": null, "created_utc": 1542156230.0, "subreddit_name_prefixed": "r/linux", "controversiality": 0, "depth": 1, "author_flair_background_color": null, "mod_reports": [], "mod_note": null, "distinguished": null}}], "after": null, "before": null}}, "user_reports": [], "saved": false, "id": "e9nfjwa", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "report_reasons": null, "author": "sandragen", "can_mod_post": false, "send_replies": true, "parent_id": "t3_9wuoss", "score": 1, "author_fullname": "t2_hwtp5", "approved_by": null, "downs": 0, "body": "Rule 1", "edited": false, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "collapsed_reason": null, "body_html": "

Rule 1

\n
", "stickied": false, "subreddit_type": "public", "can_gild": true, "gildings": {"gid_1": 0, "gid_2": 0, "gid_3": 0}, "author_flair_text_color": null, "score_hidden": true, "permalink": "/r/linux/comments/9wuoss/keyboard_shortcuts_between_terminal_and_other_gui/e9nfjwa/", "num_reports": null, "name": "t1_e9nfjwa", "created": 1542183175.0, "subreddit": "linux", "author_flair_text": null, "created_utc": 1542154375.0, "subreddit_name_prefixed": "r/linux", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "mod_reports": [], "mod_note": null, "distinguished": null} 2 | 3 | -------------------------------------------------------------------------------- /redditisgtk/tests-data/snapshots/newmarkdown--code.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "classes": [], 5 | "label": "hello:", 6 | "type": "AlignedLabel" 7 | }, 8 | { 9 | "classes": [ 10 | "mdx-block-code" 11 | ], 12 | "label": "\n\n", 13 | "type": "AlignedLabel" 14 | } 15 | ], 16 | "classes": [ 17 | "vertical", 18 | "mdx-block", 19 | "mdx-block-div" 20 | ], 21 | "label": null, 22 | "type": "Box" 23 | } -------------------------------------------------------------------------------- /redditisgtk/tests-data/snapshots/newmarkdown--error-handler.json: -------------------------------------------------------------------------------- 1 | { 2 | "classes": [], 3 | "label": "Error formatting text:\n\n

Issue ™

", 4 | "type": "AlignedLabel" 5 | } -------------------------------------------------------------------------------- /redditisgtk/tests-data/snapshots/newmarkdown--escaping-amp.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "classes": [], 5 | "label": "& I <3 you", 6 | "type": "AlignedLabel" 7 | } 8 | ], 9 | "classes": [ 10 | "vertical", 11 | "mdx-block", 12 | "mdx-block-div" 13 | ], 14 | "label": null, 15 | "type": "Box" 16 | } -------------------------------------------------------------------------------- /redditisgtk/tests-data/snapshots/newmarkdown--escaping-code.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "classes": [], 5 | "label": "Code: <hello></hello>", 6 | "type": "AlignedLabel" 7 | } 8 | ], 9 | "classes": [ 10 | "vertical", 11 | "mdx-block", 12 | "mdx-block-div" 13 | ], 14 | "label": null, 15 | "type": "Box" 16 | } -------------------------------------------------------------------------------- /redditisgtk/tests-data/snapshots/newmarkdown--escaping.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "classes": [], 5 | "label": "Please refer to <url> for", 6 | "type": "AlignedLabel" 7 | } 8 | ], 9 | "classes": [ 10 | "vertical", 11 | "mdx-block", 12 | "mdx-block-div" 13 | ], 14 | "label": null, 15 | "type": "Box" 16 | } -------------------------------------------------------------------------------- /redditisgtk/tests-data/snapshots/newmarkdown--heading.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "classes": [ 5 | "mdx-heading", 6 | "mdx-heading-h1" 7 | ], 8 | "label": "big", 9 | "type": "AlignedLabel" 10 | }, 11 | { 12 | "classes": [ 13 | "mdx-heading", 14 | "mdx-heading-h3" 15 | ], 16 | "label": "small", 17 | "type": "AlignedLabel" 18 | } 19 | ], 20 | "classes": [ 21 | "vertical", 22 | "mdx-block", 23 | "mdx-block-div" 24 | ], 25 | "label": null, 26 | "type": "Box" 27 | } -------------------------------------------------------------------------------- /redditisgtk/tests-data/snapshots/newmarkdown--hello.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "classes": [], 5 | "label": "hello", 6 | "type": "AlignedLabel" 7 | } 8 | ], 9 | "classes": [ 10 | "vertical", 11 | "mdx-block", 12 | "mdx-block-div" 13 | ], 14 | "label": null, 15 | "type": "Box" 16 | } -------------------------------------------------------------------------------- /redditisgtk/tests-data/snapshots/newmarkdown--hr.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "classes": [], 5 | "label": "hello", 6 | "type": "AlignedLabel" 7 | }, 8 | { 9 | "classes": [ 10 | "horizontal" 11 | ], 12 | "label": null, 13 | "type": "Separator" 14 | }, 15 | { 16 | "classes": [], 17 | "label": "world", 18 | "type": "AlignedLabel" 19 | } 20 | ], 21 | "classes": [ 22 | "vertical", 23 | "mdx-block", 24 | "mdx-block-div" 25 | ], 26 | "label": null, 27 | "type": "Box" 28 | } -------------------------------------------------------------------------------- /redditisgtk/tests-data/snapshots/newmarkdown--inline.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "classes": [], 5 | "label": "b i s <code>", 6 | "type": "AlignedLabel" 7 | } 8 | ], 9 | "classes": [ 10 | "vertical", 11 | "mdx-block", 12 | "mdx-block-div" 13 | ], 14 | "label": null, 15 | "type": "Box" 16 | } -------------------------------------------------------------------------------- /redditisgtk/tests-data/snapshots/newmarkdown--link--r.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "classes": [], 5 | "label": "/r/linux", 6 | "type": "AlignedLabel" 7 | } 8 | ], 9 | "classes": [ 10 | "vertical", 11 | "mdx-block", 12 | "mdx-block-div" 13 | ], 14 | "label": null, 15 | "type": "Box" 16 | } -------------------------------------------------------------------------------- /redditisgtk/tests-data/snapshots/newmarkdown--list.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "children": [ 5 | { 6 | "classes": [], 7 | "label": "1. hello", 8 | "type": "AlignedLabel" 9 | }, 10 | { 11 | "classes": [], 12 | "label": "2. world", 13 | "type": "AlignedLabel" 14 | }, 15 | { 16 | "classes": [], 17 | "label": "3. yeah", 18 | "type": "AlignedLabel" 19 | }, 20 | { 21 | "classes": [], 22 | "label": "4. sam", 23 | "type": "AlignedLabel" 24 | } 25 | ], 26 | "classes": [ 27 | "vertical", 28 | "mdx-block", 29 | "mdx-block-ol" 30 | ], 31 | "label": null, 32 | "type": "Box" 33 | } 34 | ], 35 | "classes": [ 36 | "vertical", 37 | "mdx-block", 38 | "mdx-block-div" 39 | ], 40 | "label": null, 41 | "type": "Box" 42 | } -------------------------------------------------------------------------------- /redditisgtk/tests-data/snapshots/newmarkdown--quote.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "classes": [], 5 | "label": "hello world", 6 | "type": "AlignedLabel" 7 | }, 8 | { 9 | "children": [ 10 | { 11 | "classes": [], 12 | "label": "quoted", 13 | "type": "AlignedLabel" 14 | }, 15 | { 16 | "children": [ 17 | { 18 | "classes": [], 19 | "label": "sub quote", 20 | "type": "AlignedLabel" 21 | } 22 | ], 23 | "classes": [ 24 | "vertical", 25 | "mdx-block", 26 | "mdx-block-blockquote" 27 | ], 28 | "label": null, 29 | "type": "Box" 30 | } 31 | ], 32 | "classes": [ 33 | "vertical", 34 | "mdx-block", 35 | "mdx-block-blockquote" 36 | ], 37 | "label": null, 38 | "type": "Box" 39 | } 40 | ], 41 | "classes": [ 42 | "vertical", 43 | "mdx-block", 44 | "mdx-block-div" 45 | ], 46 | "label": null, 47 | "type": "Box" 48 | } -------------------------------------------------------------------------------- /redditisgtk/tests-data/snapshots/newmarkdown--super-long.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "classes": [], 5 | "label": "hello woah long world", 6 | "type": "AlignedLabel" 7 | } 8 | ], 9 | "classes": [ 10 | "vertical", 11 | "mdx-block", 12 | "mdx-block-div" 13 | ], 14 | "label": null, 15 | "type": "Box" 16 | } -------------------------------------------------------------------------------- /redditisgtk/tests-data/snapshots/newmarkdown--super.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "classes": [], 5 | "label": "hello my world", 6 | "type": "AlignedLabel" 7 | } 8 | ], 9 | "classes": [ 10 | "vertical", 11 | "mdx-block", 12 | "mdx-block-div" 13 | ], 14 | "label": null, 15 | "type": "Box" 16 | } -------------------------------------------------------------------------------- /redditisgtk/tests-data/sublistrows--pm.json: -------------------------------------------------------------------------------- 1 | {"kind": "t4", "data": {"first_message": 690341637, "first_message_name": "t4_bf0f0l", "subreddit": null, "likes": null, "replies": "", "id": "bf34ua", "subject": "re: Blog down", "was_comment": false, "score": 0, "author": "bambambazooka", "num_comments": null, "parent_id": "t4_bf34ky", "subreddit_name_prefixed": null, "new": false, "body": "thanks a lot!", "dest": "samdroid_", "body_html": "

thanks a lot!

\n
", "name": "t4_bf34ua", "created": 1522961304.0, "created_utc": 1522932504.0, "context": "", "distinguished": null}} 2 | -------------------------------------------------------------------------------- /redditisgtk/tests-data/sublistrows--thumb-from-previews.json: -------------------------------------------------------------------------------- 1 | {"data": {"approved_at_utc": null, "subreddit": "soccer", "selftext": "", "author_fullname": "t2_ta1lp", "saved": false, "mod_reason_title": null, "gilded": 1, "clicked": false, "title": "Red Star Belgrade 2-0 Liverpool - Pavkov 29' (Great Goal)", "link_flair_richtext": [{"e": "text", "t": "Media"}], "subreddit_name_prefixed": "r/soccer", "hidden": false, "pwls": 6, "link_flair_css_class": "media", "downs": 0, "thumbnail_height": 78, "hide_score": false, "name": "t3_9uqwp7", "quarantine": false, "link_flair_text_color": "dark", "author_flair_background_color": "", "subreddit_type": "public", "ups": 7211, "domain": "clippituser.tv", "media_embed": {"content": "", "width": 600, "scrolling": false, "height": 337}, "thumbnail_width": 140, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": {"type": "clippituser.tv", "oembed": {"provider_url": "http://clippit.tv", "description": "UEFA Champions League Soccer: Red Star Belgrade vs. Liverpool | TNT", "title": "UEFA Champions League Soccer: Red Star Belgrade vs. Liverpool | TNT", "thumbnail_width": 640, "height": 337, "width": 600, "html": "", "version": "1.0", "provider_name": "Clippit", "thumbnail_url": "https://clips.clippit.tv/plyeng/thumbnail_share.jpg", "type": "video", "thumbnail_height": 360}}, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {"content": "", "width": 600, "scrolling": false, "media_domain_url": "https://www.redditmedia.com/mediaembed/9uqwp7", "height": 337}, "link_flair_text": "Media", "can_mod_post": false, "score": 7211, "approved_by": null, "thumbnail": "image", "edited": false, "author_flair_css_class": "s1 7 team-7 country-usa", "author_flair_richtext": [{"e": "text", "t": "United States"}], "gildings": {"gid_1": 1, "gid_2": 1, "gid_3": 0}, "post_hint": "rich:video", "content_categories": null, "is_self": false, "mod_note": null, "created": 1541557567.0, "link_flair_type": "richtext", "wls": 6, "banned_by": null, "author_flair_type": "richtext", "contest_mode": false, "selftext_html": null, "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": true, "pinned": false, "over_18": false, "preview": {"images": [{"source": {"url": "https://external-preview.redd.it/AxVEZvB8AZsAPFSlrGM3HGDCss0bxYEbNu89NmgUdTg.jpg?auto=webp&s=40dfcc7795f611acf86e8963dc1b3fef2412ca6a", "width": 640, "height": 360}, "resolutions": [{"url": "https://external-preview.redd.it/AxVEZvB8AZsAPFSlrGM3HGDCss0bxYEbNu89NmgUdTg.jpg?width=108&crop=smart&auto=webp&s=a906c3a7241def971591ded4d7f9ff9abe6b050c", "width": 108, "height": 60}, {"url": "https://external-preview.redd.it/AxVEZvB8AZsAPFSlrGM3HGDCss0bxYEbNu89NmgUdTg.jpg?width=216&crop=smart&auto=webp&s=9a957e0d1d877667ec90f74e8ebf3669503357b2", "width": 216, "height": 121}, {"url": "https://external-preview.redd.it/AxVEZvB8AZsAPFSlrGM3HGDCss0bxYEbNu89NmgUdTg.jpg?width=320&crop=smart&auto=webp&s=faf1caaff25518035287d11ab33293ebe4848ad5", "width": 320, "height": 180}, {"url": "https://external-preview.redd.it/AxVEZvB8AZsAPFSlrGM3HGDCss0bxYEbNu89NmgUdTg.jpg?width=640&crop=smart&auto=webp&s=f22637b21961bbf3a265f7b9f8971e11409789c8", "width": 640, "height": 360}], "variants": {}, "id": "GIdzayMU86gAtFQOUfHX8nW06AkjcKoORfsKqIYWX1E"}], "enabled": false}, "media_only": false, "link_flair_template_id": null, "can_gild": true, "spoiler": false, "locked": false, "author_flair_text": "United States", "visited": false, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qi58", "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "", "id": "9uqwp7", "is_robot_indexable": true, "report_reasons": null, "author": "HerbalDreamin", "num_crossposts": 0, "num_comments": 597, "send_replies": true, "whitelist_status": "all_ads", "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": "dark", "permalink": "/r/soccer/comments/9uqwp7/red_star_belgrade_20_liverpool_pavkov_29_great/", "parent_whitelist_status": "all_ads", "stickied": false, "url": "https://www.clippituser.tv/c/plyeng", "subreddit_subscribers": 1219547, "created_utc": 1541528767.0, "media": {"type": "clippituser.tv", "oembed": {"provider_url": "http://clippit.tv", "description": "UEFA Champions League Soccer: Red Star Belgrade vs. Liverpool | TNT", "title": "UEFA Champions League Soccer: Red Star Belgrade vs. Liverpool | TNT", "thumbnail_width": 640, "height": 337, "width": 600, "html": "", "version": "1.0", "provider_name": "Clippit", "thumbnail_url": "https://clips.clippit.tv/plyeng/thumbnail_share.jpg", "type": "video", "thumbnail_height": 360}}, "is_video": false}} 2 | 3 | -------------------------------------------------------------------------------- /redditisgtk/tests-data/submit--good-response.json: -------------------------------------------------------------------------------- 1 | {"json": {"errors": [], "data": {"url": "https://www.reddit.com/r/test/comments/9teb69/test/", "drafts_count": 0, "id": "9teb69", "name": "t3_9teb69"}}} 2 | -------------------------------------------------------------------------------- /redditisgtk/tests-data/submit--ratelimit-response.json: -------------------------------------------------------------------------------- 1 | {"json": {"ratelimit": 510.691727, "errors": [["RATELIMIT", "you are doing that too much. try again in 8 minutes.", "ratelimit"]]}} -------------------------------------------------------------------------------- /redditisgtk/webviews.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Sam Parkinson 2 | # 3 | # This file is part of Something for Reddit. 4 | # 5 | # Something for Reddit is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Something for Reddit is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with Something for Reddit. If not, see . 17 | 18 | 19 | import subprocess 20 | from gi.repository import Gtk 21 | from gi.repository import WebKit2 22 | 23 | 24 | def open_uri_external(uri: str): 25 | ''' 26 | Open the given uri in an external browser 27 | ''' 28 | subprocess.call(['xdg-open', uri]) 29 | 30 | 31 | class FullscreenableWebview(WebKit2.WebView): 32 | 33 | def do_enter_fullscreen(self): 34 | self._old_parent = self.get_parent() 35 | self._old_parent.remove(self) 36 | self._old_parent.get_toplevel().hide() 37 | 38 | self._fullscreen = Gtk.Window() 39 | self._fullscreen.add(self) 40 | self._fullscreen.show() 41 | 42 | def do_leave_fullscreen(self): 43 | self._fullscreen.remove(self) 44 | self._old_parent.add(self) 45 | self._old_parent.get_toplevel().show() 46 | del self._old_parent 47 | 48 | self._fullscreen.destroy() 49 | del self._fullscreen 50 | 51 | _load_when_visible = None 52 | def load_when_visible(self, uri): 53 | self._load_when_visible = uri 54 | 55 | def do_map(self): 56 | WebKit2.WebView.do_map(self) 57 | 58 | if self._load_when_visible is not None: 59 | self.load_uri(self._load_when_visible) 60 | self._load_when_visible = None 61 | 62 | 63 | class ProgressContainer(Gtk.Overlay): 64 | ''' 65 | Overlays a progress bar on a webview passed to the constructor 66 | ''' 67 | 68 | def __init__(self, webview): 69 | Gtk.Overlay.__init__(self) 70 | self._webview = webview 71 | self.add(self._webview) 72 | self._webview.show() 73 | 74 | self._progress = Gtk.ProgressBar(halign=Gtk.Align.FILL, 75 | valign=Gtk.Align.START) 76 | self.add_overlay(self._progress) 77 | 78 | self._webview.connect('notify::estimated-load-progress', 79 | self.__notify_progress_cb) 80 | 81 | def __notify_progress_cb(self, webview, pspec): 82 | progress = webview.props.estimated_load_progress 83 | if progress == 1.0: 84 | self._progress.hide() 85 | else: 86 | self._progress.show() 87 | self._progress.set_fraction(progress) 88 | 89 | 90 | class WebviewToolbar(Gtk.Bin): 91 | 92 | def __init__(self, webview): 93 | Gtk.Bin.__init__(self) 94 | self._webview = webview 95 | self._b = Gtk.Builder.new_from_resource( 96 | '/today/sam/reddit-is-gtk/webview-toolbar.ui') 97 | self.add(self._b.get_object('toolbar')) 98 | self._b.get_object('toolbar').show_all() 99 | 100 | self._b.get_object('back').connect( 101 | 'clicked', self.__clicked_cb, webview.go_back) 102 | self._b.get_object('forward').connect( 103 | 'clicked', self.__clicked_cb, webview.go_forward) 104 | self._b.get_object('external').connect('clicked', self.__external_cb) 105 | 106 | webview.connect('load-changed', self.__load_changed_cb) 107 | 108 | def __load_changed_cb(self, webview, load_event): 109 | self._b.get_object('back').props.sensitive = \ 110 | self._webview.can_go_back() 111 | self._b.get_object('forward').props.sensitive = \ 112 | self._webview.can_go_forward() 113 | 114 | def __external_cb(self, button): 115 | uri = self._webview.props.uri 116 | open_uri_external(uri) 117 | 118 | def __clicked_cb(self, button, func): 119 | func() 120 | -------------------------------------------------------------------------------- /screenshots/0.2.1-askreddit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/071733a9e6050d0d171e6620c189b7860af56bab/screenshots/0.2.1-askreddit.png -------------------------------------------------------------------------------- /screenshots/0.2.1-dankmemes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/071733a9e6050d0d171e6620c189b7860af56bab/screenshots/0.2.1-dankmemes.png -------------------------------------------------------------------------------- /screenshots/0.2.1-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samdroid-apps/something-for-reddit/071733a9e6050d0d171e6620c189b7860af56bab/screenshots/0.2.1-dark.png -------------------------------------------------------------------------------- /today.sam.reddit-is-gtk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-id": "today.sam.reddit-is-gtk", 3 | "runtime": "org.gnome.Platform", 4 | "runtime-version": "3.28", 5 | "sdk": "org.gnome.Sdk", 6 | "command": "reddit-is-gtk", 7 | 8 | "finish-args": [ 9 | /* Display server */ 10 | "--share=ipc", 11 | "--socket=x11", 12 | "--socket=wayland", 13 | 14 | /* DRI for WK2 rendering */ 15 | "--device=dri", 16 | 17 | /* Access to Reddit */ 18 | "--share=network", 19 | 20 | /* DConf for some configuration storage */ 21 | "--filesystem=xdg-run/dconf", "--filesystem=~/.config/dconf:ro", 22 | "--talk-name=ca.desrt.dconf", "--env=DCONF_USER_CONFIG_DIR=.config/dconf", 23 | 24 | /* Audio for WK2 */ 25 | "--socket=pulseaudio" 26 | ], 27 | "cleanup": [ 28 | "/include", 29 | "/lib/pkgconfig", 30 | "/share/pkgconfig", 31 | "/share/aclocal", 32 | "/man", 33 | "/share/man", 34 | "/share/gtk-doc", 35 | "/share/vala", 36 | "*.la", 37 | "*.a" 38 | ], 39 | 40 | "rename-desktop-file": "reddit-is-gtk.desktop", 41 | 42 | "modules": [ 43 | { 44 | "name": "pipdeps", 45 | "buildsystem": "simple", 46 | 47 | "build-commands": [ 48 | "pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} markdown arrow" 49 | ], 50 | "sources": [ 51 | { 52 | "type": "file", 53 | "url": "https://files.pythonhosted.org/packages/3c/52/7bae9e99a7a4be6af4a713fe9b692777e6468d28991c54c273dfb6ec9fb2/Markdown-3.0.1.tar.gz", 54 | "sha256": "d02e0f9b04c500cde6637c11ad7c72671f359b87b9fe924b2383649d8841db7c" 55 | }, 56 | { 57 | "type": "file", 58 | "url": "https://files.pythonhosted.org/packages/e0/86/4eb5228a43042e9a80fe8c84093a8a36f5db34a3767ebd5e1e7729864e7b/arrow-0.12.1.tar.gz", 59 | "sha256": "a558d3b7b6ce7ffc74206a86c147052de23d3d4ef0e17c210dd478c53575c4cd" 60 | }, 61 | { 62 | "type": "file", 63 | "url": "https://files.pythonhosted.org/packages/0e/01/68747933e8d12263d41ce08119620d9a7e5eb72c876a3442257f74490da0/python-dateutil-2.7.5.tar.gz", 64 | "sha256": "88f9287c0174266bb0d8cedd395cfba9c58e87e5ad86b2ce58859bc11be3cf02" 65 | }, 66 | { 67 | "type": "file", 68 | "url": "https://files.pythonhosted.org/packages/16/d8/bc6316cf98419719bd59c91742194c111b6f2e85abac88e496adefaf7afe/six-1.11.0.tar.gz", 69 | "sha256": "70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9" 70 | } 71 | ], 72 | 73 | "modules": [ 74 | { 75 | "name": "setuptools_scm", 76 | "buildsystem": "simple", 77 | "build-commands": [ 78 | "pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} setuptools_scm" 79 | ], 80 | "sources": [ 81 | { 82 | "type": "file", 83 | "url": "https://files.pythonhosted.org/packages/09/b4/d148a70543b42ff3d81d57381f33104f32b91f970ad7873f463e75bf7453/setuptools_scm-3.1.0.tar.gz", 84 | "sha256": "1191f2a136b5e86f7ca8ab00a97ef7aef997131f1f6d4971be69a1ef387d8b40" 85 | } 86 | ] 87 | } 88 | ] 89 | }, 90 | { 91 | "name": "sassc", 92 | "config-opts": ["--with-libsass=/usr/share/runtime/share/themes/Yaru-dark/gtk-3.0"], 93 | "cleanup": ["*"], 94 | "sources": [ 95 | { 96 | "type": "archive", 97 | "url": "https://github.com/sass/sassc/archive/3.5.0.tar.gz", 98 | "sha256": "26f54e31924b83dd706bc77df5f8f5553a84d51365f0e3c566df8de027918042" 99 | }, 100 | { 101 | "type": "script", 102 | "dest-filename": "autogen.sh", 103 | "commands": ["autoreconf -si"] 104 | } 105 | ], 106 | "modules": [ 107 | { 108 | "name": "libsass", 109 | "cleanup": ["*"], 110 | "sources": [ 111 | { 112 | "type": "archive", 113 | "url": "https://github.com/sass/libsass/archive/3.5.4.tar.gz", 114 | "sha256": "5f61cbcddaf8e6ef7a725fcfa5d05297becd7843960f245197ebb655ff868770" 115 | }, 116 | { 117 | "type": "script", 118 | "dest-filename": "autogen.sh", 119 | "commands": ["autoreconf -si"] 120 | } 121 | ] 122 | } 123 | ] 124 | }, 125 | { 126 | "name": "reddit-is-gtk", 127 | "buildsystem": "autotools", 128 | "sources": [ 129 | { 130 | "type": "git", 131 | "url": "https://github.com/samdroid-apps/something-for-reddit" 132 | } 133 | ] 134 | } 135 | ] 136 | } 137 | --------------------------------------------------------------------------------