├── data ├── vaccine.gschema.valid ├── hicolor │ ├── 16x16 │ │ └── apps │ │ │ └── org.vaccine.app.png │ ├── 24x24 │ │ └── apps │ │ │ └── org.vaccine.app.png │ ├── 32x32 │ │ └── apps │ │ │ └── org.vaccine.app.png │ ├── 48x48 │ │ └── apps │ │ │ └── org.vaccine.app.png │ ├── 96x96 │ │ └── apps │ │ │ └── org.vaccine.app.png │ ├── 256x256 │ │ └── apps │ │ │ └── org.vaccine.app.png │ ├── 512x512 │ │ └── apps │ │ │ └── org.vaccine.app.png │ └── scalable │ │ └── apps │ │ └── org.vaccine.app.svg ├── org.vaccine.app.desktop ├── post-install.sh ├── meson.build ├── style.3.20.css ├── org.vaccine.app.gschema.xml ├── menus.ui ├── style.css ├── org.vaccine.app.appdata.xml ├── vaccine.gresource.xml └── ui │ ├── catalog-widget.ui │ ├── tab.ui │ ├── shortcuts-window.ui │ ├── error-dialog.ui │ ├── thread-pane.ui │ ├── catalog-item.ui │ ├── post-list-row.ui │ ├── preferences-view.ui │ ├── video-preview-widget.ui │ ├── media-view.ui │ └── main-window.ui ├── .gitignore ├── Makefile ├── res ├── vaccine-catalog.png ├── vaccine-mediaview.png └── vaccine-panelview.png ├── src ├── config.h.in ├── view │ ├── ShortcutsWindow.vala │ ├── PreferencesView.vala │ ├── NotebookPage.vala │ ├── CatalogWidget.vala │ ├── CatalogItem.vala │ ├── CoverImage.vala │ ├── widgets │ │ └── VideoPreviewWidget.vala │ ├── ThreadPane.vala │ ├── PanelView.vala │ ├── PostListRow.vala │ ├── MediaView.vala │ └── MainWindow.vala ├── config.vapi ├── ErrorDialog.vala ├── PostReplies.vala ├── meson.build ├── model │ ├── Page.vala │ ├── Board.vala │ └── Post.vala ├── util │ ├── Stripper.vala │ ├── PostTransformer.vala │ └── PostTextBuffer.vala ├── Catalog.vala ├── Thread.vala ├── App.vala ├── FourChan.vala └── media │ ├── ImagePreview.vala │ ├── MediaPreview.vala │ ├── MediaStore.vala │ └── VideoPreview.vala ├── .gitmodules ├── notes.txt ├── contrib ├── meson.build └── bayes-glib-1.0.vapi ├── vaccine.doap ├── meson.build ├── README.md └── org.vaccine.app.json /data/vaccine.gschema.valid: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.c 2 | build/ 3 | repo/ 4 | .flatpak-builder/ 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | debug: 2 | -meson build 3 | ninja -C build 4 | gdb -ex run build/vaccine 5 | -------------------------------------------------------------------------------- /res/vaccine-catalog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VaccineApp/vaccine/master/res/vaccine-catalog.png -------------------------------------------------------------------------------- /res/vaccine-mediaview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VaccineApp/vaccine/master/res/vaccine-mediaview.png -------------------------------------------------------------------------------- /res/vaccine-panelview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VaccineApp/vaccine/master/res/vaccine-panelview.png -------------------------------------------------------------------------------- /src/config.h.in: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #define PACKAGE_NAME "@package@" 4 | 5 | #define PACKAGE_VERSION "@version@" 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "contrib/bayes-glib"] 2 | path = contrib/bayes-glib 3 | url = https://github.com/VaccineApp/bayes-glib.git 4 | -------------------------------------------------------------------------------- /data/hicolor/16x16/apps/org.vaccine.app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VaccineApp/vaccine/master/data/hicolor/16x16/apps/org.vaccine.app.png -------------------------------------------------------------------------------- /data/hicolor/24x24/apps/org.vaccine.app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VaccineApp/vaccine/master/data/hicolor/24x24/apps/org.vaccine.app.png -------------------------------------------------------------------------------- /data/hicolor/32x32/apps/org.vaccine.app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VaccineApp/vaccine/master/data/hicolor/32x32/apps/org.vaccine.app.png -------------------------------------------------------------------------------- /data/hicolor/48x48/apps/org.vaccine.app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VaccineApp/vaccine/master/data/hicolor/48x48/apps/org.vaccine.app.png -------------------------------------------------------------------------------- /data/hicolor/96x96/apps/org.vaccine.app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VaccineApp/vaccine/master/data/hicolor/96x96/apps/org.vaccine.app.png -------------------------------------------------------------------------------- /data/hicolor/256x256/apps/org.vaccine.app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VaccineApp/vaccine/master/data/hicolor/256x256/apps/org.vaccine.app.png -------------------------------------------------------------------------------- /data/hicolor/512x512/apps/org.vaccine.app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VaccineApp/vaccine/master/data/hicolor/512x512/apps/org.vaccine.app.png -------------------------------------------------------------------------------- /src/view/ShortcutsWindow.vala: -------------------------------------------------------------------------------- 1 | [GtkTemplate (ui = "/org/vaccine/app/shortcuts-window.ui")] 2 | public class Vaccine.ShortcutsWindow : Gtk.ShortcutsWindow { } 3 | -------------------------------------------------------------------------------- /src/config.vapi: -------------------------------------------------------------------------------- 1 | [CCode (cprefix="", lower_case_cprefix="", cheader_filename="config.h")] 2 | namespace Config { 3 | public const string PACKAGE_NAME; 4 | public const string PACKAGE_VERSION; 5 | } 6 | -------------------------------------------------------------------------------- /data/org.vaccine.app.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Name=Vaccine 4 | Comment=A Desktop 4chan Client 5 | Exec=vaccine 6 | Terminal=false 7 | Icon=org.vaccine.app 8 | Categories=Network; 9 | StartupNotify=true 10 | X-GNOME-UsesNotifications=true 11 | -------------------------------------------------------------------------------- /data/post-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Packaging tools define DESTDIR and this isn't needed for them 4 | if [[ $DESTDIR == "" ]]; then 5 | glib-compile-schemas $MESON_INSTALL_PREFIX/share/glib-2.0/schemas 6 | gtk-update-icon-cache -qt $MESON_INSTALL_PREFIX/share/icons/hicolor 7 | update-desktop-database $MESON_INSTALL_PREFIX/share/applications 8 | fi 9 | -------------------------------------------------------------------------------- /notes.txt: -------------------------------------------------------------------------------- 1 | TODO: 2 | - nice logging like https://github.com/GNOME/gnome-builder/blob/master/libide/ide-log.c 3 | - fix PanelView rendering 4 | - avoid all Gtk warnings 5 | - post formatting (try every board to test ancient stickies) 6 | - post generator (markov chain) 7 | - animations 8 | - touchscreen support 9 | - relative times (e.g. 6 hours ago) 10 | - GtkSourceView for [code][/code] 11 | -------------------------------------------------------------------------------- /contrib/meson.build: -------------------------------------------------------------------------------- 1 | bayes_glib = files([ 2 | 'bayes-glib/src/bayes-classifier.c', 3 | 'bayes-glib/src/bayes-guess.c', 4 | 'bayes-glib/src/bayes-storage-memory.c', 5 | 'bayes-glib/src/bayes-storage.c', 6 | 'bayes-glib/src/bayes-tokenizer.c', 7 | ]) 8 | 9 | bayes_glib_args = ['--vapidir=' + meson.current_source_dir(), '--pkg=bayes-glib-1.0'] 10 | bayes_glib_inc = include_directories('bayes-glib/src') 11 | 12 | json_glib_local_args = ['--vapidir=' + meson.current_source_dir()] 13 | -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | gnome = import('gnome') 2 | 3 | gnome.compile_schemas() 4 | 5 | resfile = files('vaccine.gresource.xml') 6 | res = gnome.compile_resources('my', resfile, source_dir: '.') 7 | res_args = ['--gresources', resfile] 8 | 9 | install_subdir('hicolor', install_dir: 'share/icons') 10 | install_data('org.vaccine.app.desktop', install_dir: 'share/applications') 11 | install_data('org.vaccine.app.appdata.xml', install_dir: 'share/appdata') 12 | install_data('org.vaccine.app.gschema.xml', install_dir: 'share/glib-2.0/schemas') 13 | -------------------------------------------------------------------------------- /src/ErrorDialog.vala: -------------------------------------------------------------------------------- 1 | [GtkTemplate (ui = "/org/vaccine/app/error-dialog.ui")] 2 | class Vaccine.ErrorDialog : Gtk.Dialog { 3 | [GtkChild] unowned Gtk.Label label; 4 | 5 | public ErrorDialog (string message) { 6 | Object (use_header_bar: 1); 7 | var header = this.get_header_bar () as Gtk.HeaderBar; 8 | header.title = "Error"; 9 | header.subtitle = "oh shit son"; 10 | label.label = message; 11 | 12 | this.set_transient_for ((Application.get_default () as Gtk.Application).active_window); 13 | this.show_all (); 14 | this.run (); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/view/PreferencesView.vala: -------------------------------------------------------------------------------- 1 | [GtkTemplate (ui = "/org/vaccine/app/preferences-view.ui")] 2 | class Vaccine.PreferencesView : Gtk.Window { 3 | [GtkChild] unowned Gtk.Switch show_trips; 4 | [GtkChild] unowned Gtk.Switch filter_nsfw_content; 5 | [GtkChild] unowned Gtk.SpinButton image_cache_size_mb; 6 | [GtkChild] unowned Gtk.Switch use_dark_theme; 7 | 8 | public PreferencesView () { 9 | App.settings.bind ("show-trips", show_trips, "active", SettingsBindFlags.DEFAULT); 10 | App.settings.bind ("filter-nsfw-content", filter_nsfw_content, "active", SettingsBindFlags.DEFAULT); 11 | App.settings.bind ("image-cache-size-mb", image_cache_size_mb, "value", SettingsBindFlags.DEFAULT); 12 | App.settings.bind ("use-dark-theme", use_dark_theme, "active", SettingsBindFlags.DEFAULT); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/view/NotebookPage.vala: -------------------------------------------------------------------------------- 1 | public interface Vaccine.NotebookPage : Gtk.Widget { 2 | public abstract string search_text { get; set; } 3 | public abstract void open_in_browser (); 4 | public abstract void refresh (); 5 | } 6 | 7 | [GtkTemplate (ui = "/org/vaccine/app/tab.ui")] 8 | public class Vaccine.Tab : Gtk.Box { 9 | [GtkChild] private unowned Gtk.Label tablabel; 10 | [GtkChild] private unowned Gtk.Button closebutton; 11 | 12 | private Gtk.Widget pane; 13 | 14 | public Tab (Gtk.Notebook notebook, Gtk.Widget child, bool closeable) { 15 | this.pane = child; // needed to avoid mem leaks 16 | child.bind_property ("name", tablabel, "label", BindingFlags.SYNC_CREATE); 17 | closebutton.visible = closeable; 18 | closebutton.clicked.connect (button => this.pane.destroy ()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /data/style.3.20.css: -------------------------------------------------------------------------------- 1 | .tile scrolledwindow undershoot { 2 | background-image: none; 3 | } 4 | 5 | :not(:backdrop) .tile label:disabled { 6 | color: @theme_text_color; 7 | } 8 | 9 | /* separators between rows */ 10 | .post-list-row + .post-list-row { 11 | border-top: 1px solid rgba(0, 0, 0, 0.1); 12 | } 13 | 14 | textview, text { 15 | background: none; 16 | } 17 | 18 | .tile { 19 | box-shadow: inset 0 1px @theme_base_color, 0 1px 1px alpha(black,0.4); 20 | border: 1px solid mix(@theme_base_color,@theme_fg_color,0.3); 21 | background-image: none; 22 | background-color: mix(@theme_base_color,@theme_bg_color,0.3); 23 | } 24 | 25 | .tile:hover { 26 | background-color: @theme_base_color; 27 | } 28 | 29 | .tile:active { 30 | border-color: @theme_selected_bg_color; 31 | box-shadow: none; 32 | color: @theme_selected_bg_color; 33 | } 34 | 35 | .tile:backdrop { 36 | box-shadow: none; 37 | border-color: @unfocused_borders; 38 | } 39 | -------------------------------------------------------------------------------- /data/org.vaccine.app.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | true 6 | Show names and tripcodes 7 | 8 | 9 | false 10 | Filter NSFW content 11 | 12 | 13 | 14 | 15 | 50.0 16 | Image cache size (MB) 17 | 18 | 19 | 20 | false 21 | Use dark theme 22 | 23 | 24 | (100,100,1100,800) 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/PostReplies.vala: -------------------------------------------------------------------------------- 1 | using Gee; 2 | 3 | public class Vaccine.PostReplies : Object, ListModel { 4 | public Post post { get; set; } 5 | private ArrayList replies = new ArrayList (); 6 | 7 | public PostReplies (Post post) { 8 | Object (post: post); 9 | post.thread.items_changed.connect (update_replies); 10 | update_replies (-1, -1, -1); 11 | } 12 | 13 | private void update_replies (uint pos, uint rem, uint add) { 14 | var quote = ">>%lld".printf (post.no); 15 | replies.clear (); 16 | foreach (var p in post.thread.posts) 17 | if (p.com.contains (quote)) 18 | replies.add (p); 19 | } 20 | 21 | public Object? get_item (uint position) { 22 | return replies[(int) position] as Object; 23 | } 24 | 25 | public Type get_item_type () { 26 | return typeof (Post); 27 | } 28 | 29 | public uint get_n_items () { 30 | return replies.size; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | src = files([ 2 | 'App.vala', 3 | 'Catalog.vala', 4 | 'FourChan.vala', 5 | 'Thread.vala', 6 | 'PostReplies.vala', 7 | 'ErrorDialog.vala', 8 | 'media/MediaPreview.vala', 9 | 'media/ImagePreview.vala', 10 | 'media/VideoPreview.vala', 11 | 'media/MediaStore.vala', 12 | 'model/Board.vala', 13 | 'model/Page.vala', 14 | 'model/Post.vala', 15 | 'util/PostTextBuffer.vala', 16 | 'util/PostTransformer.vala', 17 | 'util/Stripper.vala', 18 | 'view/CatalogItem.vala', 19 | 'view/CatalogWidget.vala', 20 | 'view/CoverImage.vala', 21 | 'view/MainWindow.vala', 22 | 'view/MediaView.vala', 23 | 'view/NotebookPage.vala', 24 | 'view/PanelView.vala', 25 | 'view/PostListRow.vala', 26 | 'view/PreferencesView.vala', 27 | 'view/ShortcutsWindow.vala', 28 | 'view/ThreadPane.vala', 29 | 'view/widgets/VideoPreviewWidget.vala', 30 | 31 | 'config.vapi' 32 | ]) 33 | 34 | config = configure_file(input: 'config.h.in', output: 'config.h', configuration: conf) 35 | config_inc = include_directories('.') 36 | -------------------------------------------------------------------------------- /vaccine.doap: -------------------------------------------------------------------------------- 1 | 6 | Vaccine 7 | A 4chan browser using GTK+ 8 | A 4chan browser, in Vala, using GTK+ 9 | 10 | 11 | 12 | Vala 13 | 14 | 15 | Prince781 16 | 17 | 18 | 19 | 20 | benwaffle 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('vaccine', 'vala', 'c') 2 | 3 | conf = configuration_data() 4 | conf.set('package', 'Vaccine') 5 | conf.set('version', '0.0.2') 6 | 7 | deps = [ 8 | dependency('glib-2.0', version: '>=2.44'), 9 | dependency('gobject-2.0'), 10 | dependency('gtk+-3.0'), 11 | dependency('json-glib-1.0'), 12 | dependency('libsoup-2.4'), 13 | dependency('gee-0.8'), 14 | dependency('gstreamer-1.0'), 15 | dependency('gstreamer-base-1.0'), 16 | dependency('gtksourceview-3.0'), 17 | meson.get_compiler('c').find_library('m', required: false) 18 | ] 19 | 20 | gtksink = run_command('gst-inspect-1.0', 'gtksink') 21 | if gtksink.returncode() != 0 22 | error('you need gtksink') 23 | endif 24 | 25 | subdir('contrib') 26 | subdir('data') 27 | subdir('src') 28 | 29 | executable('vaccine', 30 | [src, res, bayes_glib], 31 | dependencies: deps, 32 | vala_args: [res_args, bayes_glib_args, json_glib_local_args], 33 | include_directories: [config_inc, bayes_glib_inc], 34 | c_args: '-w', 35 | install: true) 36 | 37 | meson.add_install_script('data/post-install.sh') 38 | -------------------------------------------------------------------------------- /src/model/Page.vala: -------------------------------------------------------------------------------- 1 | using Gee; 2 | 3 | public class Vaccine.Page : Object, Json.Serializable { 4 | public string? board { get; set; } 5 | 6 | /** 7 | * The page number 8 | */ 9 | public uint? page { get; set; } 10 | 11 | /** 12 | * The threads on this page 13 | */ 14 | public ArrayList threads { get; set; } 15 | 16 | public bool deserialize_property (string prop_name, out Value val, ParamSpec pspec, Json.Node property_node) { 17 | if (prop_name != "threads") { 18 | val = Value (pspec.value_type); 19 | return default_deserialize_property (prop_name, out val, pspec, property_node); 20 | } 21 | 22 | var list = new ArrayList (); 23 | property_node.get_array ().foreach_element ((arr, index, node) => { 24 | var o = Json.gobject_deserialize (typeof (ThreadOP), node) as ThreadOP; 25 | assert (o != null); 26 | o.board = board; 27 | o.bump_order = index; 28 | list.add ((!) o); 29 | }); 30 | 31 | val = Value (list.get_type ()); 32 | val.set_object (list); 33 | return true; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Vaccine 2 | ======= 3 | 4 | ![Catalog](res/vaccine-catalog.png) 5 | 6 | ![Media View](res/vaccine-mediaview.png) 7 | 8 | ![Panel View](res/vaccine-panelview.png) 9 | 10 | This is an imageboard browser for Linux that is written in Vala and uses GTK. 11 | Please contribute and report bugs. 12 | 13 | # Dependencies 14 | | Package name | Version | 15 | |--------------------------|----------| 16 | | glib2 | >= 2.44 | 17 | | vala | | 18 | | gtk3 | >=3.18 | 19 | | libsoup | >=2.4 | 20 | | libgee | >=0.18 | 21 | | gstreamer | >=1.6 | 22 | | gstreamer-plugins-bad | >=1.6 | 23 | | json-glib | >=1.0 | 24 | | gtksourceview3 | >=3.16 | 25 | 26 | # Build Dependencies 27 | | Package | 28 | |------------------| 29 | | meson | 30 | | appstream-glib | 31 | | vala | 32 | 33 | Try it 34 | --- 35 | ```Bash 36 | $ git clone --recursive https://github.com/VaccineApp/vaccine 37 | $ cd vaccine 38 | $ make 39 | ``` 40 | 41 | # Build flatpak 42 | ```Bash 43 | $ flatpak-builder build org.vaccine.app.json 44 | $ flatpak-builder --run build org.vaccine.app.json vaccine 45 | ``` 46 | -------------------------------------------------------------------------------- /src/util/Stripper.vala: -------------------------------------------------------------------------------- 1 | // strips XML tags, not clothes 2 | 3 | public class Vaccine.Stripper : Object { 4 | private const MarkupParser parser = { null, null, visit_text, null, null }; 5 | 6 | private MarkupParseContext ctx; 7 | 8 | private string dest = ""; 9 | 10 | public Stripper () { 11 | ctx = new MarkupParseContext(parser, 0, this, null); 12 | } 13 | 14 | void visit_text (MarkupParseContext context, string text, size_t text_len) throws MarkupError { 15 | dest += text; 16 | } 17 | 18 | public static string? transform_post (string com) { 19 | var xfm = new Stripper (); 20 | var post = PostTransformer.common_clean (com) 21 | .split ("\n")[0] 22 | .replace ("\r", "") 23 | .replace (">", ">") 24 | .replace ("'", "'") 25 | .replace ("&", "&"); 26 | try { 27 | xfm.ctx.parse ("<_top_level>" + post + "", -1); // requires a top-level element 28 | } catch (MarkupError e) { 29 | debug (e.message); 30 | return null; 31 | } 32 | // print (@"\n\x1b[35m==========================================\x1b[0m\n$com\n\t\t\t\t\x1b[44mv\x1b[0m\n$(xfm.dest)\n\n"); 33 | return xfm.dest; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Catalog.vala: -------------------------------------------------------------------------------- 1 | using Gee; 2 | 3 | public class Vaccine.Catalog : Object { 4 | public signal void downloaded (string board, ArrayList catalog); 5 | 6 | public async void download (string board) 7 | { 8 | if (board.length == 0) 9 | return; // can't have requires () on an async function? 10 | var catalog = new ArrayList (); 11 | try { 12 | var json = new Json.Parser (); 13 | var stream = yield FourChan.soup.send_async (new Soup.Message ("GET", "https://a.4cdn.org/" + board + "/catalog.json")); 14 | if (yield json.load_from_stream_async (stream, null)) { 15 | json.get_root () 16 | .get_array () 17 | .foreach_element ((arr, index, node) => { 18 | var page = Json.gobject_deserialize (typeof (Page), node) as Page; 19 | assert (page != null); 20 | page.board = board; 21 | foreach (var op in page.threads) 22 | op.board = board; 23 | catalog.add (page); 24 | }); 25 | } 26 | } catch (Error e) { 27 | debug (e.message); 28 | } 29 | downloaded (board, catalog); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /data/menus.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Preferences 5 | app.preferences 6 | 7 |
8 | 9 | Keyboard Shortcuts 10 | win.show-help-overlay 11 | 12 | 13 | About 14 | app.about 15 | 16 | 17 | Quit 18 | app.quit 19 | 20 |
21 |
22 | 23 | 24 | _Bump Order 25 | catalog.sort_bump_order 26 | 27 | 28 | Creation _Date 29 | catalog.sort_creation_date 30 | 31 | 32 | Last _Reply 33 | catalog.sort_last_reply 34 | 35 | 36 | Reply _Count 37 | catalog.sort_reply_count 38 | 39 | 40 |
41 | -------------------------------------------------------------------------------- /data/style.css: -------------------------------------------------------------------------------- 1 | VaccineCatalogWidget GtkScrolledWindow, 2 | VaccineCatalogWidget GtkViewport { 3 | border: none; 4 | } 5 | 6 | VaccineCatalogItem .textarea GtkLabel:insensitive { 7 | color: @theme_text_color; 8 | } 9 | 10 | VaccineCatalogItem GtkScrolledWindow { 11 | background: none; 12 | } 13 | 14 | VaccineThreadPane > GtkBox.heading-box { 15 | background-color: @theme_base_color; 16 | } 17 | 18 | VaccineThreadPane > GtkScrolledWindow { 19 | border: none; 20 | border-top: 1px solid @theme_bg_color; 21 | } 22 | 23 | /* separators between rows */ 24 | VaccinePostListRow + VaccinePostListRow { 25 | border-top: 1px solid rgba(0, 0, 0, 0.1); 26 | } 27 | 28 | GtkTextView { 29 | background: none; 30 | } 31 | 32 | GtkTextView:selected { 33 | background: @theme_selected_bg_color; 34 | } 35 | 36 | GtkScrolledWindow.text-view-wrapper { 37 | border: none; 38 | } 39 | 40 | 41 | .tile { 42 | box-shadow: inset 0 1px @theme_base_color, 0 1px 1px alpha(black,0.4); 43 | border: 1px solid mix(@theme_base_color,@theme_fg_color,0.3); 44 | background-image: none; 45 | background-color: mix(@theme_base_color,@theme_bg_color,0.3); 46 | } 47 | 48 | .tile:hover { 49 | background-color: @theme_base_color; 50 | } 51 | 52 | .tile:active { 53 | border-color: @theme_selected_bg_color; 54 | box-shadow: none; 55 | color: @theme_selected_bg_color; 56 | } 57 | 58 | .tile:backdrop { 59 | box-shadow: none; 60 | border-color: @unfocused_borders; 61 | } 62 | -------------------------------------------------------------------------------- /data/org.vaccine.app.appdata.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | org.vaccine.app.desktop 5 | CC0-1.0 6 | GPL-3.0 7 | Vaccine 8 | A Desktop 4chan Client 9 | 10 |

11 | Vaccine is a desktop client for 4chan.org using GTK+. 12 |

13 |
14 | 15 | 16 | https://raw.githubusercontent.com/VaccineApp/vaccine/cdc3b1feb75c928cb2a43c749e59278f97fe6a90/res/vaccine-catalog.png 17 | 18 | 19 | https://raw.githubusercontent.com/VaccineApp/vaccine/cdc3b1feb75c928cb2a43c749e59278f97fe6a90/res/vaccine-panelview.png 20 | 21 | 22 | https://raw.githubusercontent.com/VaccineApp/vaccine/cdc3b1feb75c928cb2a43c749e59278f97fe6a90/res/vaccine-mediaview.png 23 | 24 | 25 | https://vaccineapp.github.io 26 | https://github.com/vaccineapp/vaccine/issues 27 | iofelben@gmail.com 28 | 29 | 30 | AppMenu 31 | HiDpiIcon 32 | ModernToolkit 33 | 34 |
35 | -------------------------------------------------------------------------------- /src/view/CatalogWidget.vala: -------------------------------------------------------------------------------- 1 | [GtkTemplate (ui = "/org/vaccine/app/catalog-widget.ui")] 2 | public class Vaccine.CatalogWidget : Gtk.Box, NotebookPage { 3 | [GtkChild] public unowned Gtk.FlowBox layout; 4 | 5 | public string search_text { get; set; } 6 | 7 | public CatalogWidget () { 8 | name = "Catalog"; 9 | layout.set_filter_func (child => { 10 | if (search_text == null) 11 | return true; 12 | var item = child.get_child () as CatalogItem; 13 | string query = Markup.escape_text (search_text.down ()); 14 | string subject = item.post_subject.label.down (); 15 | string comment = item.post_comment.label.down (); 16 | return subject.contains (query) || comment.contains (query); 17 | }); 18 | notify["search-text"].connect (layout.invalidate_filter); 19 | } 20 | 21 | public new void add (MainWindow win, ThreadOP t) { 22 | layout.add (new CatalogItem (win, t)); 23 | } 24 | 25 | public void clear () { 26 | layout.foreach (w => w.destroy ()); 27 | } 28 | 29 | public void open_in_browser () { 30 | try { 31 | AppInfo.launch_default_for_uri ("https://boards.4chan.org/%s/".printf (FourChan.board), null); 32 | } catch (Error e) { 33 | warning (e.message); 34 | } 35 | } 36 | 37 | public void refresh () { 38 | FourChan.catalog.download.begin (FourChan.board); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /org.vaccine.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-id": "org.vaccine.app", 3 | "runtime": "org.gnome.Platform", 4 | "runtime-version": "3.24", 5 | "sdk": "org.gnome.Sdk", 6 | "command": "vaccine", 7 | "finish-args": [ 8 | "--socket=x11", 9 | "--socket=wayland", 10 | "--share=network", 11 | "--filesystem=xdg-run/dconf", 12 | "--filesystem=~/.config/dconf:ro", 13 | "--talk-name=ca.desrt.dconf", 14 | "--env=DCONF_USER_CONFIG_DIR=.config/dconf" 15 | ], 16 | "cleanup": ["*.a", "*.la", "/include"], 17 | "modules": [ 18 | { 19 | "name": "gee-0.8", 20 | "build-options" : { 21 | "env": { 22 | "PKG_CONFIG_GOBJECT_INTROSPECTION_1_0_GIRDIR": "/app/share/gir-1.0", 23 | "PKG_CONFIG_GOBJECT_INTROSPECTION_1_0_TYPELIBDIR": "/app/lib/girepository-1.0" 24 | } 25 | }, 26 | "sources": [ 27 | { 28 | "type": "git", 29 | "url": "git://git.gnome.org/libgee" 30 | } 31 | ] 32 | }, 33 | { 34 | "name": "gtksourceview", 35 | "sources": [ 36 | { 37 | "type": "git", 38 | "url": "git://git.gnome.org/gtksourceview", 39 | "branch": "gnome-3-24" 40 | } 41 | ] 42 | }, 43 | { 44 | "name": "vaccine", 45 | "buildsystem": "meson", 46 | "builddir": true, 47 | "sources": [ 48 | { 49 | "type": "git", 50 | "url": "https://github.com/VaccineApp/vaccine.git" 51 | } 52 | ] 53 | } 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /src/view/CatalogItem.vala: -------------------------------------------------------------------------------- 1 | [GtkTemplate (ui = "/org/vaccine/app/catalog-item.ui")] 2 | public class Vaccine.CatalogItem : Gtk.Button { 3 | // TODO: show # of replies (and make it look good) 4 | private weak MainWindow main_window; 5 | 6 | [GtkChild] private unowned Gtk.Stack image_stack; 7 | 8 | [GtkChild] public unowned Gtk.Label post_subject; 9 | [GtkChild] public unowned Gtk.Label post_comment; 10 | [GtkChild] public unowned Gtk.Label post_n_replies; 11 | 12 | private Cancellable? cancel = null; 13 | 14 | public ThreadOP op { get; construct; } 15 | 16 | public CatalogItem (MainWindow win, ThreadOP t) { 17 | Object (op: t); 18 | this.main_window = win; 19 | 20 | if (t.filename != null) { // deleted files 21 | cancel = t.get_thumbnail (buf => { 22 | cancel = null; 23 | var image = new CoverImage (buf); 24 | image_stack.add (image); 25 | image_stack.set_visible_child (image); 26 | }); 27 | } 28 | if (t.com != null) 29 | this.post_comment.label = PostTransformer.transform_post (t.com); 30 | this.post_n_replies.label = "%u replies".printf (t.replies); 31 | if (t.sub != null) 32 | this.post_subject.label = "%s".printf (t.sub); 33 | else 34 | post_subject.destroy (); 35 | } 36 | 37 | ~CatalogItem () { 38 | if (cancel != null) 39 | cancel.cancel (); 40 | } 41 | 42 | [GtkCallback] 43 | public void show_thread () { 44 | main_window.show_thread (op.no, op.pixbuf); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /data/vaccine.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ui/main-window.ui 5 | ui/thread-pane.ui 6 | ui/post-list-row.ui 7 | ui/catalog-widget.ui 8 | ui/catalog-item.ui 9 | ui/preferences-view.ui 10 | ui/media-view.ui 11 | ui/video-preview-widget.ui 12 | ui/error-dialog.ui 13 | ui/tab.ui 14 | ui/shortcuts-window.ui 15 | style.css 16 | style.3.20.css 17 | code-training-set.json 18 | 19 | 20 | menus.ui 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/view/CoverImage.vala: -------------------------------------------------------------------------------- 1 | /** 2 | * Shows an image such that it fills its container, like 3 | * background-size: cover; 4 | * background-position: center; 5 | */ 6 | public class Vaccine.CoverImage : Gtk.Widget { 7 | private Cairo.ImageSurface image; 8 | 9 | construct { 10 | this.set_has_window (false); 11 | } 12 | 13 | public CoverImage (Gdk.Pixbuf pixbuf) { 14 | this.image = (Cairo.ImageSurface) Gdk.cairo_surface_create_from_pixbuf (pixbuf, 1, null); 15 | this.visible = true; 16 | } 17 | 18 | public override void get_preferred_width (out int min, out int nat) { 19 | min = nat = image.get_width (); 20 | } 21 | 22 | public override void get_preferred_height (out int min, out int nat) { 23 | min = nat = image.get_height (); 24 | } 25 | 26 | public override bool draw (Cairo.Context cr) { 27 | Gtk.Allocation alloc; 28 | get_allocation (out alloc); 29 | 30 | int image_width = image.get_width (); 31 | int image_height = image.get_height (); 32 | 33 | double scale_x = (double) alloc.width / image_width; 34 | double scale_y = (double) alloc.height / image_height; 35 | 36 | if (scale_x * image_height >= alloc.height) { 37 | double offset = (alloc.height - image_height * scale_x) / 2; 38 | cr.translate (0, offset); 39 | cr.scale (scale_x, scale_x); 40 | } else if (scale_y * image_width >= alloc.width) { 41 | double offset = (alloc.width - image_width * scale_y) / 2; 42 | cr.translate (offset, 0); 43 | cr.scale (scale_y, scale_y); 44 | } else { 45 | assert_not_reached (); 46 | } 47 | 48 | cr.set_source_surface (image, 0, 0); 49 | cr.paint (); 50 | return Gdk.EVENT_PROPAGATE; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /data/ui/catalog-widget.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 43 | 44 | -------------------------------------------------------------------------------- /data/ui/tab.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 50 | 51 | -------------------------------------------------------------------------------- /src/view/widgets/VideoPreviewWidget.vala: -------------------------------------------------------------------------------- 1 | [GtkTemplate (ui = "/org/vaccine/app/video-preview-widget.ui")] 2 | public class Vaccine.VideoPreviewWidget : Gtk.Overlay { 3 | [GtkChild] private unowned Gtk.Box sink_holder; 4 | 5 | // control holder 6 | [GtkChild] private unowned Gtk.Revealer controls_revealer; 7 | 8 | // controls 9 | [GtkChild] public unowned Gtk.Button btn_play; 10 | [GtkChild] public unowned Gtk.Image btn_play_img; 11 | [GtkChild] public unowned Gtk.Scale progress_scale; 12 | [GtkChild] public unowned Gtk.ToggleButton toggle_repeat; 13 | 14 | // info 15 | [GtkChild] public unowned Gtk.Label progress_text_start; 16 | [GtkChild] public unowned Gtk.Label progress_text_end; 17 | 18 | // gtksink element 19 | public dynamic Gst.Element video_sink { private set; get; } 20 | private Gtk.Widget? area; 21 | 22 | public VideoPreviewWidget () { 23 | // init gst stuff 24 | dynamic Gst.Element? gtk_sink = Gst.ElementFactory.make ("gtksink", "video_sink"); 25 | if (gtk_sink == null) { 26 | gtk_sink = Gst.ElementFactory.make ("gtksink", "video_sink"); 27 | if (gtk_sink == null) 28 | error("Failed to created gtksink."); 29 | } 30 | video_sink = (!) gtk_sink; 31 | area = gtk_sink.widget; 32 | sink_holder.pack_start (area); 33 | 34 | sink_holder.show_all (); 35 | add_events (Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK); 36 | } 37 | 38 | [GtkCallback] 39 | private bool mouse_enter_cb (Gdk.EventCrossing event) { 40 | controls_revealer.reveal_child = true; 41 | return true; 42 | } 43 | 44 | [GtkCallback] 45 | private bool mouse_leave_cb (Gdk.EventCrossing event) { 46 | // don't hide if our mouse "leaves" into a descendant widget 47 | if (event.detail != Gdk.NotifyType.INFERIOR) 48 | controls_revealer.reveal_child = false; 49 | return true; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/view/ThreadPane.vala: -------------------------------------------------------------------------------- 1 | [GtkTemplate (ui = "/org/vaccine/app/thread-pane.ui")] 2 | public class Vaccine.ThreadPane : Gtk.Box, NotebookPage { 3 | [GtkChild] private unowned Gtk.ListBox list; 4 | [GtkChild] private unowned Gtk.Box heading_box; 5 | [GtkChild] private unowned Gtk.Label heading; 6 | 7 | public string search_text { get; set; } 8 | 9 | // we already have it from the catalog 10 | Gdk.Pixbuf? op_thumb; 11 | string board; 12 | int64 no = -1; 13 | ListModel? model = null; 14 | 15 | // UI is prioritized, call set_model later when you have data 16 | public void set_model (ListModel model) { 17 | this.model = model; 18 | notify["search-text"].connect (() => { 19 | if (model is Thread) { 20 | ((Thread) model).set_filter (search_text); 21 | } 22 | }); 23 | list.bind_model (model, item => { 24 | var post = item as Post; 25 | if (post.isOP && post.pixbuf == null) 26 | post.get_thumbnail (() => {}); 27 | return new PostListRow (post, post.isOP ? op_thumb : null); 28 | }); 29 | } 30 | 31 | public ThreadPane (string board, int64 no, Gdk.Pixbuf? op_thumb = null) { 32 | this.board = board; 33 | this.no = no; 34 | this.op_thumb = op_thumb; 35 | } 36 | 37 | public ThreadPane.with_title (string title) { 38 | // OP could theoretically guess the post number 39 | // assert (!model.get_item (0).isOP); 40 | heading_box.visible = true; 41 | heading.label = "" + title + ""; 42 | } 43 | 44 | public void open_in_browser () { 45 | try { 46 | AppInfo.launch_default_for_uri ("https://boards.4chan.org/%s/thread/%lld".printf (board, no), null); 47 | } catch (Error e) { 48 | warning (e.message); 49 | } 50 | } 51 | 52 | public void refresh () { 53 | if (model is Thread) { 54 | ((Thread) model).update_thread (); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Thread.vala: -------------------------------------------------------------------------------- 1 | using Gee; 2 | 3 | public class Vaccine.Thread : Object, ListModel { 4 | public ArrayList? posts = null; 5 | 6 | public string get_title () { 7 | var op = posts[0] as ThreadOP; 8 | 9 | if (op.sub != null) { 10 | string? sub = Stripper.transform_post (op.sub); 11 | return "/%s/ — %s".printf (board, sub); 12 | } 13 | 14 | if (op.com != null) { 15 | string? com = Stripper.transform_post (op.com); 16 | return "/%s/ — %s".printf (board, com); 17 | } 18 | 19 | return "/%s/ — %lld".printf (board, no); 20 | } 21 | 22 | public string board { get; construct; } 23 | public int64 no { get; construct; } 24 | 25 | private uint timeout_id = -1; 26 | 27 | public Thread (string board, int64 no) { 28 | Object (board: board, no: no); 29 | // TODO make pref, min 10 sec per API rules 30 | timeout_id = Timeout.add_seconds (10, () => { 31 | this.update_thread (); 32 | return Source.CONTINUE; 33 | }); 34 | } 35 | 36 | ~Thread () { 37 | stop_updating (); 38 | } 39 | 40 | public void stop_updating () { 41 | if (timeout_id != -1) { 42 | Source.remove (timeout_id); 43 | timeout_id = -1; 44 | } 45 | } 46 | 47 | public void append (Post p) { 48 | posts.add (p); 49 | items_changed (posts.size-1, 0, 1); 50 | } 51 | 52 | public Object? get_item (uint position) { 53 | return posts[(int) position] as Object; 54 | } 55 | 56 | public Type get_item_type () { 57 | return typeof (Post); 58 | } 59 | 60 | public uint get_n_items () { 61 | return posts == null ? 0 : posts.size; 62 | } 63 | 64 | public void set_filter (string text) { 65 | foreach (Post p in posts) 66 | p.visible = (p.com != null && text in p.com); 67 | } 68 | 69 | public void update_thread () { 70 | debug ("updating thread %lld".printf (no)); 71 | FourChan.dl_thread.begin (this); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/model/Board.vala: -------------------------------------------------------------------------------- 1 | public class Vaccine.Board : Object { 2 | /** 3 | * Short board code 4 | */ 5 | public string board { get; set; } 6 | 7 | /** 8 | * Full board name 9 | */ 10 | public string title { get; set; } 11 | 12 | /** 13 | * Safe for work 14 | */ 15 | public uint ws_board { get; set; } 16 | 17 | /** 18 | * Threads per page 19 | */ 20 | public uint per_page { get; set; } 21 | 22 | /** 23 | * Number of pages 24 | */ 25 | public uint pages { get; set; } 26 | 27 | /** 28 | * Maximum filesize 29 | */ 30 | public uint max_filesize { get; set; } 31 | 32 | /** 33 | * Maximum webm filesize 34 | */ 35 | public uint max_webm_filesize { get; set; } 36 | 37 | /** 38 | * Maximum length of comment 39 | */ 40 | public uint max_comment_chars { get; set; } 41 | 42 | /** 43 | * Maximum number of posts that can bump a thread 44 | */ 45 | public uint bump_limit { get; set; } 46 | 47 | /** 48 | * Maximum number of images that can be posted in a thread 49 | */ 50 | public uint image_limit { get; set; } 51 | 52 | public class Cooldowns : Object { 53 | public uint threads { get; set; } 54 | public uint replies { get; set; } 55 | public uint images { get; set; } 56 | public uint replies_uintra { get; set; } 57 | public uint images_uintra { get; set; } 58 | } 59 | 60 | public Cooldowns cooldowns { get; set; } 61 | public uint user_ids { get; set; } 62 | 63 | /** 64 | * Whether this board support spoilers 65 | */ 66 | public uint spoilers { get; set; } 67 | public uint custom_spoilers { get; set; } 68 | 69 | /** 70 | * Whether this board is archived 71 | */ 72 | public uint is_archived { get; set; } 73 | 74 | /** 75 | * Whether this board supports country flags 76 | */ 77 | public uint country_flags { get; set; } 78 | 79 | /** 80 | * Whether this board supports math tags 81 | */ 82 | public uint math_tags { get; set; } 83 | 84 | /** 85 | * Whether this board supports code tags 86 | */ 87 | public uint code_tags { get; set; } 88 | } 89 | -------------------------------------------------------------------------------- /data/ui/shortcuts-window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 55 | 56 | -------------------------------------------------------------------------------- /contrib/bayes-glib-1.0.vapi: -------------------------------------------------------------------------------- 1 | /* bayes-glib-1.0.vapi generated by vapigen-0.30, do not modify. */ 2 | 3 | [CCode (cprefix = "Bayes", gir_namespace = "Bayes", gir_version = "1.0", lower_case_cprefix = "bayes_")] 4 | namespace Bayes { 5 | [CCode (cheader_filename = "bayes-glib.h", type_id = "bayes_classifier_get_type ()")] 6 | public class Classifier : GLib.Object { 7 | [CCode (has_construct_function = false)] 8 | public Classifier (); 9 | public unowned Bayes.Storage get_storage (); 10 | public GLib.List guess (string text); 11 | public void set_storage (Bayes.Storage? storage); 12 | public void set_tokenizer (owned Bayes.Tokenizer tokenizer); 13 | public void train (string name, string text); 14 | public Bayes.Storage storage { get; set; } 15 | } 16 | [CCode (cheader_filename = "bayes-glib.h", ref_function = "bayes_guess_ref", type_id = "bayes_guess_get_type ()", unref_function = "bayes_guess_unref")] 17 | [Compact] 18 | public class Guess { 19 | [CCode (has_construct_function = false)] 20 | public Guess (string name, double probability); 21 | public unowned string get_name (); 22 | public double get_probability (); 23 | public Bayes.Guess @ref (); 24 | public void unref (); 25 | } 26 | [CCode (cheader_filename = "bayes-glib.h", type_id = "bayes_storage_memory_get_type ()")] 27 | public class StorageMemory : GLib.Object, Bayes.Storage, Json.Serializable { 28 | [CCode (has_construct_function = false)] 29 | public StorageMemory (); 30 | [CCode (has_construct_function = false)] 31 | public StorageMemory.from_file (string filename) throws GLib.Error; 32 | [CCode (has_construct_function = false)] 33 | public StorageMemory.from_stream (GLib.InputStream stream, GLib.Cancellable? cancellable = null) throws GLib.Error; 34 | public bool save_to_file (string filename) throws GLib.Error; 35 | [NoAccessorMethod] 36 | public Bayes.Tokens corpus { get; set; } 37 | [NoAccessorMethod] 38 | public GLib.HashTable names { owned get; set; } 39 | } 40 | [CCode (cheader_filename = "bayes-glib.h", type_cname = "BayesStorageInterface", type_id = "bayes_storage_get_type ()")] 41 | public interface Storage : GLib.Object { 42 | public void add_token (string name, string token); 43 | public abstract void add_token_count (string name, string token, uint count); 44 | [CCode (array_length = false, array_null_terminated = true)] 45 | public abstract string[] get_names (); 46 | public abstract uint get_token_count (string? name, string? token); 47 | public abstract double get_token_probability (string name, string token); 48 | } 49 | [CCode (cheader_filename = "bayes-glib.h", has_type_id = false)] 50 | public struct Tokens { 51 | } 52 | [CCode (array_length = false, array_null_terminated = true, cheader_filename = "bayes-glib.h", instance_pos = 1.9)] 53 | public delegate string[] Tokenizer (string text); 54 | [CCode (array_length = false, array_null_terminated = true, cheader_filename = "bayes-glib.h")] 55 | public static string[] tokenizer_code_tokens (string text, void* user_data); 56 | [CCode (array_length = false, array_null_terminated = true, cheader_filename = "bayes-glib.h")] 57 | public static string[] tokenizer_word (string text, void* user_data); 58 | } 59 | -------------------------------------------------------------------------------- /data/ui/error-dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 74 | 75 | -------------------------------------------------------------------------------- /src/App.vala: -------------------------------------------------------------------------------- 1 | namespace Vaccine { 2 | public const string PROGRAM_VERSION = "0.0.1"; 3 | 4 | public class App : Gtk.Application { 5 | public static Settings settings; 6 | public FourChan chan = new FourChan (); 7 | public Bayes.Classifier code_classifier = new Bayes.Classifier (); 8 | PreferencesView? prefs = null; 9 | 10 | public App () { 11 | Object (application_id: "org.vaccine.app", 12 | flags: ApplicationFlags.FLAGS_NONE); 13 | try { 14 | var istream = GLib.resources_open_stream ("/org/vaccine/app/code-training-set.json", GLib.ResourceLookupFlags.NONE); 15 | code_classifier.storage = new Bayes.StorageMemory.from_stream (istream); 16 | // FIXME: bayes-glib bindings 17 | code_classifier.set_tokenizer (text => { 18 | return Bayes.tokenizer_code_tokens (text, null); 19 | }); 20 | debug ("loaded source code training file"); 21 | } catch (Error e) { 22 | debug ("failed to load training set: %s", e.message); 23 | code_classifier.storage = new Bayes.StorageMemory (); 24 | } 25 | } 26 | 27 | public MainWindow main_window { get; private set; } 28 | 29 | const ActionEntry[] actions = { 30 | { "preferences", show_preferences }, 31 | { "about", show_about }, 32 | { "quit", quit } 33 | }; 34 | 35 | void show_preferences () { 36 | if (prefs != null) 37 | return; 38 | 39 | prefs = new PreferencesView (); 40 | prefs.transient_for = main_window; 41 | prefs.delete_event.connect (() => { 42 | prefs = null; 43 | return Gdk.EVENT_PROPAGATE; 44 | }); 45 | prefs.present (); 46 | } 47 | 48 | void show_about () { 49 | string[] authors = {"benwaffle", "Prince781"}; 50 | Gtk.show_about_dialog (get_active_window (), 51 | program_name: Config.PACKAGE_NAME, 52 | version: Config.PACKAGE_VERSION, 53 | copyright: "Copyright © 2016 - Vaccine Developers", 54 | authors: authors, 55 | website: "https://github.com/VaccineApp/vaccine", 56 | website_label: "GitHub", 57 | license_type: Gtk.License.GPL_3_0, 58 | comments: "A GTK3 imageboard client", 59 | logo_icon_name: "org.vaccine.app"); 60 | } 61 | 62 | protected override void startup () { 63 | base.startup (); 64 | add_action_entries (actions, this); 65 | 66 | settings = new Settings (application_id); 67 | settings.bind ("use-dark-theme", 68 | Gtk.Settings.get_default (), "gtk-application-prefer-dark-theme", 69 | SettingsBindFlags.DEFAULT); 70 | 71 | main_window = new MainWindow (this); 72 | 73 | var provider = new Gtk.CssProvider (); 74 | if (Gtk.check_version (3, 20, 0) == null) 75 | provider.load_from_resource (resource_base_path + "/style.3.20.css"); 76 | else 77 | provider.load_from_resource (resource_base_path + "/style.css"); 78 | Gtk.StyleContext.add_provider_for_screen (main_window.get_screen (), 79 | provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); 80 | } 81 | 82 | protected override void activate () { 83 | base.activate (); 84 | main_window.present (); 85 | } 86 | } 87 | } 88 | 89 | int main (string[] args) { 90 | var app = new Vaccine.App (); 91 | Gst.init (ref args); 92 | int res = app.run (args); 93 | Gst.deinit (); 94 | return res; 95 | } 96 | -------------------------------------------------------------------------------- /data/ui/thread-pane.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 96 | 97 | -------------------------------------------------------------------------------- /src/util/PostTransformer.vala: -------------------------------------------------------------------------------- 1 | // pango markup 2 | public class Vaccine.PostTransformer : Object { 3 | private const MarkupParser parser = { 4 | visit_start, 5 | visit_end, 6 | visit_text, 7 | visit_passthrough, 8 | error 9 | }; 10 | 11 | private uint a_tag_level = 0; // handles nested tags...thanks mods 12 | 13 | private MarkupParseContext ctx; 14 | 15 | private string dest = ""; 16 | 17 | public PostTransformer () { 18 | ctx = new MarkupParseContext(parser, 0, this, null); 19 | } 20 | 21 | void visit_text (MarkupParseContext context, string text, size_t text_len) throws MarkupError { 22 | if (a_tag_level == 0) { // we are not inside an tag, so wrap links 23 | string link; 24 | try { 25 | link = /(\w+:\/\/\S*)/.replace(text, -1, 0, "\\1"); 26 | } catch (RegexError e) { 27 | debug (e.message); 28 | link = text; 29 | } 30 | dest += link; 31 | } else { 32 | dest += text; 33 | } 34 | } 35 | 36 | void visit_start (MarkupParseContext context, string elem, string[] attrs, string[] vals) throws MarkupError { 37 | if (elem == "a") a_tag_level++; 38 | 39 | // is Pango's monospace element 40 | // should we check for class="prettyprint"? 41 | else if (elem == "pre") elem = "tt"; 42 | 43 | // our dummy top-level XML element 44 | else if (elem == "_top_level") return; 45 | 46 | // 4chan oddly puts around some quotelinks 47 | // I think this was used for greentext in the past and the ancient stickies have never been change 48 | else if (elem == "font") return; 49 | 50 | dest += "<" + elem; 51 | for (int i = 0; i < attrs.length; ++i) { 52 | if (attrs[i] == "target") { 53 | ; 54 | } else if (attrs[i] == "class") { 55 | if (vals[i] == "quote") 56 | dest += " foreground=\"#789922\""; 57 | } else { 58 | dest += " %s=\"%s\"".printf (attrs[i], vals[i]); 59 | } 60 | } 61 | dest += ">"; 62 | } 63 | 64 | void visit_end (MarkupParseContext context, string elem) throws MarkupError { 65 | if (elem == "a") a_tag_level--; 66 | else if (elem == "pre") elem = "tt"; 67 | else if (elem == "_top_level") return; 68 | else if (elem == "font") return; 69 | 70 | dest += ""; 71 | } 72 | 73 | void visit_passthrough (MarkupParseContext context, string passthrough_text, size_t text_len) throws MarkupError { 74 | debug (@"visit_passthrough: $passthrough_text\n"); 75 | } 76 | 77 | void error (MarkupParseContext context, Error error) { 78 | debug (@"error: $(error.message)\n"); 79 | } 80 | 81 | public static string transform_post (string com) throws MarkupError { 82 | var xfm = new PostTransformer (); 83 | var post = common_clean (com).replace ("&", "&"); 84 | xfm.ctx.parse ("<_top_level>" + post + "", -1); // requires a top-level element 85 | // TODO: remove when it all works 86 | // print (@"\n\x1b[35m==========================================\x1b[0m\n$com\n\t\t\t\t\x1b[44mv\x1b[0m\n$(xfm.dest)\n\n"); 87 | return xfm.dest; 88 | } 89 | 90 | public static string common_clean (string com) { 91 | string br_replace = "\n"; 92 | if (com.contains ("\n")) 93 | br_replace = ""; 94 | 95 | return com 96 | .replace ("

", br_replace) 97 | .replace ("
", br_replace) // unclosed tag 98 | .replace ("
", br_replace) 99 | .replace ("", "") 100 | .replace ("[math]", "") 101 | .replace ("[/math]", "") 102 | .replace ("[eqn]", "") 103 | .replace ("[/eqn]", ""); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/FourChan.vala: -------------------------------------------------------------------------------- 1 | using Gee; 2 | 3 | public class Vaccine.FourChan : Object { 4 | public static Catalog catalog = new Catalog (); 5 | public static Soup.Session soup = new Soup.Session (); 6 | 7 | // NOTE: pass to function if it is async, 8 | // otherwise function can access it directly 9 | private static string _board; // TODO: save & restore 10 | public static string board { 11 | get { return _board; } 12 | set { 13 | _board = value; 14 | catalog.download.begin (_board); 15 | } 16 | } 17 | 18 | // TODO: do once in constructor and save result? 19 | public static async ArrayList get_boards () { 20 | var list = new ArrayList (); 21 | try { 22 | var json = new Json.Parser (); 23 | InputStream stream = yield soup.send_async (new Soup.Message ("GET", "https://a.4cdn.org/boards.json")); 24 | if (yield json.load_from_stream_async (stream, null)) { 25 | json.get_root () 26 | .get_object () 27 | .get_array_member ("boards") 28 | .foreach_element ((arr, index, node) => { 29 | var board = Json.gobject_deserialize (typeof (Board), node) as Board; 30 | list.add (board); 31 | }); 32 | } 33 | } catch (Error e) { 34 | new ErrorDialog (e.message); 35 | error (e.message); 36 | } 37 | return list; 38 | } 39 | 40 | public static async void dl_thread (Thread thread) { 41 | try { 42 | var json = new Json.Parser (); 43 | InputStream stream = yield soup.send_async (new Soup.Message ("GET", 44 | "https://a.4cdn.org/%s/thread/%lld.json".printf (thread.board, thread.no))); 45 | if (yield json.load_from_stream_async (stream, null)) { 46 | var posts = new ArrayList (); 47 | json.get_root () 48 | .get_object () 49 | .get_array_member ("posts") 50 | .foreach_element ((arr, index, node) => { 51 | Post p; 52 | if (index == 0) p = Json.gobject_deserialize (typeof (ThreadOP), node) as ThreadOP; 53 | else p = Json.gobject_deserialize (typeof (Post), node) as Post; 54 | assert (p != null); 55 | p.thread = thread; 56 | posts.add (p); 57 | }); 58 | if (thread.posts == null) { 59 | thread.posts = posts; 60 | thread.items_changed (0, 0, posts.size); 61 | } else { 62 | // update thread 63 | int old_n_posts = thread.posts.size; 64 | uint added = 0; 65 | for (int i = 0; i < posts.size; ++i) { 66 | if (i > thread.posts.size-1) { // new post 67 | thread.posts.add (posts[i]); 68 | ++added; 69 | } else if (thread.posts[i].no != posts[i].no) { // post deleted 70 | thread.posts.remove_at (i); 71 | thread.items_changed (i, 1, 0); 72 | --i; 73 | } 74 | } 75 | if (added > 0) { 76 | thread.items_changed (old_n_posts, 0, added); 77 | } 78 | } 79 | } 80 | } catch (Error e) { 81 | new ErrorDialog (e.message); 82 | error (e.message); 83 | } 84 | } 85 | 86 | public static async Gdk.PixbufAnimation? download_image (string url, Cancellable cancel) { 87 | var msg = new Soup.Message ("GET", url); 88 | try { 89 | var stream = yield soup.send_async (msg, cancel); 90 | return yield new Gdk.PixbufAnimation.from_stream_async (stream, cancel); 91 | } catch (Error e) { 92 | debug (e.message); 93 | return null; 94 | } 95 | } 96 | 97 | public static string get_post_time (uint time) { 98 | return new DateTime.from_unix_local (time).format ("%a, %b %e, %Y @ %l:%M %P"); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/media/ImagePreview.vala: -------------------------------------------------------------------------------- 1 | public class Vaccine.ImagePreview : MediaPreview { 2 | private Gdk.PixbufAnimation? image_data; 3 | private Gdk.PixbufAnimationIter? frame_iter; 4 | private Cancellable? image_data_load_cancel; 5 | 6 | public bool is_animated { get { return !image_data.is_static_image (); } } 7 | 8 | private weak Gdk.Pixbuf? frame { 9 | get { 10 | return frame_iter != null ? frame_iter.get_pixbuf () : null; 11 | } 12 | } 13 | 14 | uint? timeout_id = null; 15 | 16 | // holds reference to single DrawingArea 17 | private Gtk.DrawingArea? canvas; 18 | 19 | public override bool loaded { get { return image_data != null; } } 20 | 21 | public ImagePreview (MediaStore media_store, Post post) 22 | requires (post.filename != null && post.ext != null) 23 | { 24 | base (media_store, post); 25 | image_data_load_cancel = new Cancellable (); 26 | } 27 | 28 | public override void cancel_everything () { 29 | if (image_data_load_cancel != null) 30 | image_data_load_cancel.cancel (); 31 | base.cancel_everything (); 32 | } 33 | 34 | ~ImagePreview () { 35 | if (canvas != null) 36 | stop_with_widget (); 37 | debug ("ImagePreview dtor"); 38 | } 39 | 40 | public override void init_with_widget (Gtk.Widget widget) 41 | requires (canvas == null) 42 | requires (widget is Gtk.DrawingArea) 43 | { 44 | canvas = widget as Gtk.DrawingArea; 45 | if (loaded) { // reset frame_iter on init 46 | frame_iter = image_data.get_iter (null); 47 | if (image_data.is_static_image ()) 48 | canvas.queue_draw (); 49 | else 50 | timeout_id = Timeout.add (frame_iter.get_delay_time (), update_animated_image); 51 | } else { // download image if not loaded 52 | FourChan.download_image.begin (url, image_data_load_cancel, (obj, res) => { 53 | image_data = FourChan.download_image.end (res); 54 | image_data_load_cancel = null; 55 | frame_iter = image_data.get_iter (null); 56 | if (image_data.is_static_image ()) 57 | canvas.queue_draw (); 58 | else 59 | timeout_id = Timeout.add (frame_iter.get_delay_time (), update_animated_image); 60 | }); 61 | } 62 | canvas.draw.connect (draw_image); 63 | } 64 | 65 | public override void stop_with_widget () 66 | requires (canvas != null) 67 | { 68 | canvas.draw.disconnect (draw_image); 69 | if (timeout_id != null) { 70 | Source.remove ((!) timeout_id); 71 | timeout_id = null; 72 | } 73 | frame_iter = null; 74 | canvas = null; 75 | } 76 | 77 | private bool update_animated_image () { 78 | if (frame_iter.advance (null)) 79 | canvas.queue_draw (); 80 | return Source.CONTINUE; 81 | } 82 | 83 | private bool draw_image (Cairo.Context ctx) { 84 | if (!loaded) 85 | return false; 86 | double i_ratio = (double) image_data.get_width () / image_data.get_height (); 87 | int w_width, w_height; // widget dimensions 88 | w_width = canvas.get_allocated_width (); 89 | w_height = canvas.get_allocated_height (); 90 | double w_ratio = (double) w_width / w_height; 91 | int r_width, r_height; // rendered image 92 | double r_padding_x, r_padding_y; 93 | if (w_ratio >= i_ratio) { 94 | r_height = w_height; 95 | r_width = (int) Math.round (r_height * i_ratio); 96 | r_padding_x = (double)(w_width - r_width) / 2; 97 | r_padding_y = 0; 98 | } else { 99 | r_width = w_width; 100 | r_height = (int) Math.round (r_width / i_ratio); 101 | r_padding_x = 0; 102 | r_padding_y = (double)(w_height - r_height) / 2; 103 | } 104 | double scale_x = (double) r_width / frame.width; 105 | double scale_y = (double) r_height / frame.height; 106 | ctx.translate (r_padding_x, r_padding_y); 107 | ctx.scale (scale_x, scale_y); 108 | Gdk.cairo_set_source_pixbuf (ctx, frame, 0, 0); 109 | ctx.rectangle (0, 0, frame.width, frame.height); 110 | ctx.fill (); 111 | return true; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/media/MediaPreview.vala: -------------------------------------------------------------------------------- 1 | public abstract class Vaccine.MediaPreview : Object { 2 | public const string[] supported_images = { ".gif", ".png", ".jpg" }; 3 | public const string[] supported_videos = { ".webm" }; 4 | 5 | public static bool is_supported (string extension) { 6 | foreach (var ext in supported_images) 7 | if (extension == ext) 8 | return true; 9 | foreach (var ext in supported_videos) 10 | if (extension == ext) 11 | return true; 12 | return false; 13 | } 14 | 15 | /** 16 | * Creates a new MediaPreview from a post, if the media is supported. 17 | * Otherwise returns null. 18 | */ 19 | public static MediaPreview? from_post (MediaStore media_store, Post post) { 20 | foreach (var ext in supported_images) 21 | if (post.ext == ext) 22 | return new ImagePreview (media_store, post); 23 | foreach (var ext in supported_videos) 24 | if (post.ext == ext) 25 | return new VideoPreview (media_store, post); 26 | return null; /* TODO: add support for more files */ 27 | } 28 | 29 | public weak MediaStore store { get; construct; } 30 | 31 | /** 32 | * The post number 33 | */ 34 | public int64 id { get; construct; } 35 | 36 | /** 37 | * The remote filename. 38 | */ 39 | public string url { get; construct; } 40 | 41 | /** 42 | * The local filename. 43 | */ 44 | public string filename { get; construct; } 45 | 46 | /** 47 | * file extension 48 | */ 49 | public string extension { get; construct; } 50 | 51 | /** 52 | * The thumbnail URL 53 | */ 54 | public string thumbnail_url { get; construct; } 55 | 56 | /** 57 | * The thumbnail 58 | */ 59 | public Gdk.Pixbuf? thumbnail { get; private set; } 60 | 61 | protected MediaPreview (MediaStore media_store, Post post) { 62 | Object (store: media_store, 63 | id: post.no, 64 | url: @"https://i.4cdn.org/$(post.board)/$(post.tim)$(post.ext)", 65 | filename: @"$(post.filename)$(post.ext)", 66 | extension: post.ext, 67 | thumbnail_url: @"https://i.4cdn.org/$(post.board)/$(post.tim)s.jpg"); 68 | } 69 | 70 | public Cancellable? cancel_thumbnail_download; 71 | 72 | public virtual void cancel_everything () { 73 | if (cancel_thumbnail_download != null) 74 | cancel_thumbnail_download.cancel (); 75 | } 76 | 77 | construct { 78 | cancel_thumbnail_download = new Cancellable (); 79 | FourChan.download_image.begin (thumbnail_url, cancel_thumbnail_download, (obj, res) => { 80 | var anim = FourChan.download_image.end (res); 81 | if (cancel_thumbnail_download.is_cancelled ()) 82 | return; 83 | int width = 128; 84 | Gdk.Pixbuf image = anim.get_static_image (); 85 | if (image.width > width) 86 | thumbnail = image.scale_simple (width, (int)(image.height * ((double)width/image.width)), Gdk.InterpType.BILINEAR); 87 | else 88 | thumbnail = image; 89 | Gtk.TreePath path; 90 | Gtk.TreeIter iter; 91 | if (store.get_path_and_iter (this, out path, out iter)) 92 | store.row_changed (path, iter); 93 | cancel_thumbnail_download = null; 94 | }); 95 | } 96 | 97 | ~MediaPreview () { 98 | cancel_everything (); 99 | debug ("Cancelling all pending operations"); 100 | } 101 | 102 | /** 103 | * If the file has been loaded. 104 | */ 105 | public abstract bool loaded { get; } 106 | 107 | /** 108 | * Renders the preview onto the widget or onto a new widget created as a 109 | * child of the given widget. 110 | */ 111 | public abstract void init_with_widget (Gtk.Widget widget); 112 | 113 | /** 114 | * Stops all operations and pending operations with the previous widget. 115 | */ 116 | public abstract void stop_with_widget (); 117 | 118 | /** 119 | * Self-explanatory. Downloads and saves the media to a file. 120 | * @throws Error if file is not yet loaded 121 | */ 122 | public async bool save_as (string path) throws Error { 123 | var message = new Soup.Message ("GET", this.url); 124 | var istream = yield FourChan.soup.send_async (message, null); 125 | var fstream = yield File.new_for_path (path).create_readwrite_async (FileCreateFlags.NONE); 126 | var os = fstream.output_stream as FileOutputStream; 127 | os.splice (istream, OutputStreamSpliceFlags.CLOSE_SOURCE); 128 | return yield fstream.close_async (); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /data/hicolor/scalable/apps/org.vaccine.app.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 60 | 63 | 68 | 74 | 80 | 86 | 92 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /src/view/PanelView.vala: -------------------------------------------------------------------------------- 1 | using Gtk; 2 | 3 | public class Vaccine.PanelView : Container, NotebookPage { 4 | private List _children; 5 | 6 | private uint _current = 0; 7 | public uint current { 8 | get { return _current; } 9 | set { 10 | _current = value; 11 | queue_draw (); 12 | } 13 | } 14 | 15 | private uint _max_visible; 16 | public uint max_visible { 17 | get { return _max_visible; } 18 | set { 19 | _max_visible = value; 20 | queue_resize (); 21 | } 22 | } 23 | 24 | public PanelView (uint maximum_visible = 2) { 25 | // Container 26 | base.set_has_window (false); 27 | base.set_can_focus (true); 28 | base.set_redraw_on_allocate (false); 29 | // Widget 30 | this.set_visible (true); 31 | // misc 32 | max_visible = maximum_visible; 33 | _children = new List (); 34 | } 35 | 36 | public override void add (Widget widget) { 37 | _children.append (widget); 38 | widget.set_parent (this); 39 | if (get_visible () && widget.get_visible ()) { 40 | queue_resize_no_redraw (); 41 | uint len = _children.length (); 42 | if (len - current > max_visible) 43 | current = len - max_visible; 44 | } 45 | } 46 | 47 | public override void remove (Widget widget) { 48 | unowned List elem = _children.find (widget); 49 | int position = _children.position (elem); 50 | if (elem.next != null) 51 | remove (elem.next.data); 52 | _children.remove (widget); 53 | if (current >= position) 54 | current = position >= max_visible ? position - max_visible : position - 1; 55 | else if (position - (int)current < max_visible) 56 | current = current > 0 ? current - 1 : current; 57 | widget.unparent (); 58 | if (get_visible () && widget.get_visible ()) 59 | queue_resize_no_redraw (); 60 | } 61 | 62 | public override void forall_internal (bool include_internal, Gtk.Callback callback) { 63 | _children.foreach (w => callback (w)); 64 | } 65 | 66 | public override SizeRequestMode get_request_mode () { 67 | return SizeRequestMode.WIDTH_FOR_HEIGHT; 68 | } 69 | 70 | public override Type child_type () { 71 | return typeof (ThreadPane); 72 | } 73 | 74 | public override void size_allocate (Allocation allocation) { 75 | uint length = _children.length (); 76 | int border_width = (int) get_border_width (); 77 | Allocation child_allocation = Allocation (); 78 | child_allocation.x = allocation.x + border_width; 79 | child_allocation.y = allocation.y + border_width; 80 | 81 | int visible = (int) uint.min(max_visible, length); 82 | child_allocation.width = (allocation.width - 2*border_width) / visible; 83 | child_allocation.height = allocation.height - 2*border_width; 84 | 85 | uint nthchild = 0; 86 | _children.foreach (widget => { 87 | Allocation widget_allocation = Allocation() { 88 | x = child_allocation.x + 89 | (nthchild <= current ? 0 : (int)(nthchild - current)*child_allocation.width), 90 | y = child_allocation.y, 91 | width = child_allocation.width, 92 | height = child_allocation.height 93 | }; 94 | widget.size_allocate (widget_allocation); 95 | if (widget.get_realized ()) 96 | widget.show (); 97 | ++nthchild; 98 | }); 99 | 100 | base.size_allocate (allocation); 101 | } 102 | 103 | public override void get_preferred_width (out int minimum_width, out int natural_width) { 104 | base.get_preferred_width (out minimum_width, out natural_width); 105 | int visible = (int) uint.min(max_visible, _children.length ()); 106 | Requisition child_minsize, child_natsize; 107 | if (visible > 0) { 108 | _children.nth_data (current).get_preferred_size (out child_minsize, out child_natsize); 109 | minimum_width = int.max(visible * child_minsize.width, minimum_width); 110 | natural_width = int.max(visible * child_natsize.width, natural_width); 111 | } 112 | } 113 | 114 | /* public new void get_preferred_size (out Requisition minimum_size, out Requisition natural_size) { 115 | unowned List list = _children.nth (current); 116 | minimum_size = { 0, 0 }; 117 | natural_size = { 0, 0 }; 118 | 119 | int i = 0; 120 | for (unowned List obj = list; obj.next != null && i < max_visible; obj = obj.next, ++i) { 121 | Widget child = obj.data; 122 | Requisition child_minsize, child_natsize; 123 | child.get_preferred_size (out child_minsize, out child_natsize); 124 | minimum_size.width += child_minsize.width; 125 | minimum_size.height += child_minsize.height; 126 | natural_size.width += child_natsize.width; 127 | natural_size.height += child_natsize.height; 128 | } 129 | } */ 130 | 131 | // What is the point of this method? 132 | public override bool draw (Cairo.Context cr) { 133 | base.draw (cr); 134 | return false; 135 | } 136 | 137 | private NotebookPage threadpane () { 138 | return (NotebookPage) _children.data; 139 | } 140 | 141 | public string search_text { 142 | get { return threadpane ().search_text; } 143 | set { threadpane ().search_text = value; } 144 | } 145 | 146 | public void open_in_browser () { 147 | threadpane ().open_in_browser (); 148 | } 149 | 150 | public void refresh () { 151 | threadpane ().refresh (); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/util/PostTextBuffer.vala: -------------------------------------------------------------------------------- 1 | public class Vaccine.PostTextBuffer : Object { 2 | private const MarkupParser parser = { 3 | visit_start, 4 | visit_end, 5 | visit_text, 6 | visit_passthrough, 7 | error 8 | }; 9 | 10 | //private uint a_tag_level = 0; // handles nested tags...thanks mods 11 | private MarkupParseContext ctx; 12 | private string src; 13 | private Gtk.TextView? text_view; 14 | private Gtk.TextBuffer buffer; 15 | private Gtk.TextIter iter; 16 | private string debug_text; 17 | 18 | private string current_tag = null; 19 | 20 | static List guess_language (string data) { 21 | return (Application.get_default () as App).code_classifier.guess (data); 22 | } 23 | 24 | void visit_start (MarkupParseContext context, string elem, string[] attrs, string[] vals) throws MarkupError { 25 | if (elem == "pre") 26 | current_tag = "code"; 27 | if (elem == "b") 28 | current_tag = "bold"; 29 | if (elem == "u") 30 | current_tag = "underline"; 31 | if (elem == "math" || elem == "eqn") 32 | current_tag = "math"; 33 | 34 | for (int i = 0; i < attrs.length; ++i) { 35 | if (elem == "span" && attrs[i] == "class" && vals[i] == "quote") 36 | current_tag = "greentext"; 37 | if (elem == "a" && attrs[i] == "class" && vals[i] == "quotelink") 38 | current_tag = "link"; 39 | } 40 | 41 | /*if (elem != "_top_level") 42 | debug_text += @"[$current_tag]";*/ 43 | } 44 | 45 | void visit_text (MarkupParseContext context, string text, size_t text_len) throws MarkupError { 46 | bool inside_code = current_tag == "code"; 47 | var link_regex = /(\w+:\/\/\S*)/; 48 | var tokens = inside_code ? new string[] { text } : link_regex.split (text); 49 | foreach (var elem in tokens) { 50 | if (!inside_code && link_regex.match (elem)) { 51 | buffer.insert_with_tags_by_name (ref iter, elem, -1, "link", current_tag); 52 | debug_text += elem; 53 | } else if (current_tag == "code" && text_view != null) { 54 | buffer.insert (ref iter, "\n", -1); 55 | Gtk.TextChildAnchor anchor = buffer.create_child_anchor (iter); 56 | Gtk.SourceView source_view = new Gtk.SourceView (); 57 | source_view.buffer.text = elem; 58 | 59 | // debug_text += @"\n[gtksourceview]$elem[/gtksourceview]\n"; 60 | 61 | Gtk.SourceBuffer sbuffer = source_view.buffer as Gtk.SourceBuffer; 62 | sbuffer.style_scheme = Gtk.SourceStyleSchemeManager.get_default ().get_scheme ("tango"); 63 | sbuffer.highlight_syntax = true; 64 | sbuffer.undo_manager = null; 65 | source_view.monospace = true; 66 | source_view.editable = false; 67 | source_view.input_hints = Gtk.InputHints.NONE; 68 | source_view.show_line_numbers = true; 69 | 70 | // guess programming language 71 | bool result_uncertain; 72 | string type = ContentType.guess (null, elem.data, out result_uncertain); 73 | debug ("GtkSourceView: type '%s' %s", type, result_uncertain ? "(uncertain)" : ""); 74 | if (result_uncertain || type == "text/plain" || !ContentType.is_a (type, "text/plain")) { 75 | List guesses = guess_language (elem); 76 | string lang; 77 | if (guesses != null) { 78 | var best_guess = guesses.data; 79 | debug ("guessing %s with probability %f", 80 | best_guess.get_name (), best_guess.get_probability ()); 81 | lang = best_guess.get_name (); 82 | } else { 83 | debug ("could not guess language"); 84 | lang = "text"; 85 | } 86 | sbuffer.language = Gtk.SourceLanguageManager.get_default ().get_language (lang); 87 | } else 88 | sbuffer.language = Gtk.SourceLanguageManager.get_default ().guess_language (null, type); 89 | 90 | text_view.add_child_at_anchor (source_view, anchor); 91 | // FIXME: text_view does not resize properly (initially) 92 | text_view.show_all (); 93 | 94 | buffer.get_end_iter (out iter); 95 | buffer.insert (ref iter, "\n", -1); 96 | } else { 97 | // debug_text += elem; 98 | buffer.insert_with_tags_by_name (ref iter, elem, -1, current_tag); 99 | } 100 | } 101 | } 102 | 103 | void visit_end (MarkupParseContext context, string elem) throws MarkupError { 104 | /*if (elem != "_top_level") 105 | debug_text += @"[/$current_tag]";*/ 106 | current_tag = null; 107 | } 108 | 109 | void visit_passthrough (MarkupParseContext context, string passthrough_text, size_t text_len) throws MarkupError { 110 | debug (@"visit_passthrough: $passthrough_text\n"); 111 | } 112 | 113 | void error (MarkupParseContext context, Error error) { 114 | debug (@"error: $(error.message)\n"); 115 | } 116 | 117 | public PostTextBuffer (string com) { 118 | this.src = com; 119 | // debug_text = ""; 120 | ctx = new MarkupParseContext(parser, 0, this, null); 121 | } 122 | 123 | public void fill_text_buffer (Gtk.TextBuffer buffer, Gtk.TextView? text_view) throws MarkupError { 124 | this.buffer = buffer; 125 | this.text_view = text_view; 126 | buffer.get_iter_at_offset (out this.iter, 0); 127 | var post = PostTransformer.common_clean (src); 128 | this.ctx.parse ("<_top_level>" + post + "", -1); // requires a top-level element 129 | // print (@"\n\x1b[35m==========================================\x1b[0m\n$src\n\t\t\t\t\x1b[44mv\x1b[0m\n$debug_text\n\n"); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/view/PostListRow.vala: -------------------------------------------------------------------------------- 1 | [GtkTemplate (ui = "/org/vaccine/app/post-list-row.ui")] 2 | public class Vaccine.PostListRow : Gtk.ListBoxRow { 3 | [GtkChild] private unowned Gtk.Label post_name; 4 | [GtkChild] private unowned Gtk.Label post_time; 5 | [GtkChild] private unowned Gtk.Label post_no; 6 | 7 | [GtkChild] private unowned Gtk.TextView post_textview; 8 | [GtkChild] private unowned Gtk.Button image_button; 9 | [GtkChild] private unowned Gtk.Image post_thumbnail; 10 | [GtkChild] private unowned Gtk.Button responses_button; 11 | 12 | private Cancellable? cancel = null; 13 | 14 | public Post post { get; construct; } 15 | 16 | static Gtk.TextTagTable tags = new Gtk.TextTagTable (); 17 | 18 | static construct { 19 | var greentext = new Gtk.TextTag ("greentext"); 20 | greentext.foreground = "#789922"; 21 | tags.add (greentext); 22 | 23 | var link = new Gtk.TextTag ("link"); 24 | link.foreground = "#2a76c6"; 25 | link.underline = Pango.Underline.SINGLE; 26 | link.event.connect ((obj, event, iter) => { 27 | switch (event.type) { 28 | case Gdk.EventType.BUTTON_RELEASE: // click 29 | Gtk.TextIter select_start, select_end; 30 | Gtk.TextView textview = obj as Gtk.TextView; 31 | 32 | textview.buffer.get_selection_bounds (out select_start, out select_end); 33 | // if text is selected, don't open any links 34 | if (select_start.get_offset () != select_end.get_offset ()) 35 | return false; 36 | var iter_begin = iter; 37 | var iter_end = iter; 38 | if (iter_begin.backward_to_tag_toggle (null) 39 | && iter_end.forward_to_tag_toggle (null)) { 40 | string url = iter_begin.get_text (iter_end); 41 | try { 42 | // try open 43 | AppInfo.launch_default_for_uri (url, null); 44 | } catch (Error e) { 45 | warning (url + ": " + e.message); 46 | } 47 | } 48 | break; 49 | default: 50 | return false; 51 | } 52 | return false; 53 | }); 54 | tags.add (link); 55 | 56 | var code = new Gtk.TextTag ("code"); 57 | code.font = "monospace"; 58 | tags.add (code); 59 | 60 | var bold = new Gtk.TextTag ("bold"); 61 | bold.weight = Pango.Weight.BOLD; 62 | tags.add (bold); 63 | 64 | var underline = new Gtk.TextTag ("underline"); 65 | underline.underline = Pango.Underline.SINGLE; 66 | tags.add (underline); 67 | 68 | // TODO spoiler 69 | 70 | // TODO math 71 | var math = new Gtk.TextTag ("math"); 72 | math.font = "serif"; 73 | tags.add (math); 74 | } 75 | 76 | private Gdk.Cursor cursor_text; 77 | private Gdk.Cursor cursor_pointer; 78 | 79 | public PostListRow (Post post, Gdk.Pixbuf? thumbnail = null) { 80 | Object (post: post); 81 | 82 | post.bind_property ("visible", this, "visible", BindingFlags.DEFAULT); 83 | 84 | App.settings.bind ("show-trips", post_name, "visible", SettingsBindFlags.GET); 85 | 86 | string name = post.name; 87 | if (post.capcode != null) 88 | name += " ## " + post.capcode[0].toupper().to_string () + post.capcode.substring (1); 89 | 90 | post_name.label = "%s %s".printf (name, post.trip ?? ""); 91 | post_time.label = FourChan.get_post_time (post.time); 92 | post_no.label = "No. %lld".printf (post.no); 93 | 94 | if (post.filename == null) { 95 | assert (!post.isOP); 96 | image_button.destroy (); 97 | } else if (thumbnail != null) { 98 | post_thumbnail.pixbuf = thumbnail; 99 | } else { 100 | cancel = post.get_thumbnail (buf => { 101 | cancel = null; 102 | post_thumbnail.pixbuf = buf; 103 | }); 104 | } 105 | 106 | post_textview.buffer = new Gtk.TextBuffer (tags); 107 | if (post.com != null) { 108 | try { 109 | new PostTextBuffer (post.com).fill_text_buffer (post_textview.buffer, post_textview); 110 | } catch (Error e) { 111 | debug (e.message); 112 | } 113 | } 114 | 115 | cursor_text = new Gdk.Cursor.from_name (post_textview.get_display (), "text"); 116 | cursor_pointer = new Gdk.Cursor.from_name (post_textview.get_display (), "pointer"); 117 | 118 | post_textview.motion_notify_event.connect (event => { 119 | int x, y; 120 | post_textview.window_to_buffer_coords (Gtk.TextWindowType.WIDGET, (int) event.x, (int) event.y, out x, out y); 121 | 122 | Gtk.TextIter mouse; 123 | post_textview.get_iter_at_location (out mouse, x, y); 124 | 125 | var cursor = cursor_text; 126 | foreach (var tag in mouse.get_tags ()) { 127 | if (tag.name == "link") { 128 | cursor = cursor_pointer; 129 | break; 130 | } 131 | } 132 | post_textview.get_window (Gtk.TextWindowType.TEXT).cursor = cursor; 133 | return false; 134 | }); 135 | 136 | // TODO property binding for thread updates 137 | var nreplies = post.nreplies; 138 | if (nreplies == 0) { 139 | responses_button.destroy (); 140 | } else { 141 | responses_button.label = (nreplies > 99 ? "99+" : nreplies.to_string ()) + (nreplies == 1 ? " reply" : " replies"); 142 | } 143 | } 144 | 145 | ~PostListRow () { 146 | if (cancel != null) 147 | cancel.cancel (); 148 | } 149 | 150 | [GtkCallback] 151 | private void show_responses () { 152 | var panelView = get_ancestor (typeof (PanelView)) as PanelView; 153 | var tpane = get_ancestor (typeof (ThreadPane)) as ThreadPane; 154 | var children = panelView.get_children (); 155 | int position = children.index (tpane); 156 | Gtk.Widget? next; 157 | if ((next = children.nth_data (position + 1)) != null) 158 | panelView.remove (next); 159 | var threadpane = new ThreadPane.with_title ("Replies to No. %lld".printf (post.no)); 160 | panelView.add (threadpane); 161 | threadpane.set_model (new PostReplies (post)); 162 | } 163 | 164 | [GtkCallback] 165 | private void show_media_view () { 166 | var win = (Application.get_default () as App).main_window; 167 | new MediaView (win, post).present (); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/model/Post.vala: -------------------------------------------------------------------------------- 1 | namespace Vaccine { 2 | public class Post : Object { 3 | /** 4 | * Post number 5 | */ 6 | public int64 no { get; set; } 7 | 8 | /** 9 | * Reply to 10 | */ 11 | public uint resto { get; set; } 12 | 13 | /** 14 | * Date and time 15 | * 16 | * Format: MM/DD/YY(Day)HH:MM 17 | * Some boards have :SS. 18 | * Time is in EST/EDT 19 | */ 20 | public string now { get; set; } 21 | 22 | /** 23 | * Unix timestamp 24 | */ 25 | public uint time { get; set; } 26 | 27 | /** 28 | * Name 29 | */ 30 | public string name { get; set; } 31 | 32 | /** 33 | * Tripcode 34 | * 35 | * Format: 36 | * !tripcode 37 | * !!securetripcode 38 | */ 39 | public string? trip { get; set; } 40 | 41 | /** 42 | * ID 43 | * Mod, Admin, Developer 44 | */ 45 | public string? id { get; set; } 46 | 47 | /** 48 | * Capcode 49 | * 50 | * none, mod, admin, admin_highlight, developer 51 | */ 52 | public string? capcode { get; set; } 53 | 54 | /** 55 | * Country code 56 | * 2 characters, ISO-3166-1 alpha-2 57 | */ 58 | public string? country { get; set; } 59 | 60 | /** 61 | * Comment 62 | */ 63 | public string com { get; set; } 64 | 65 | /** 66 | * Renamed filename 67 | * Unix timestamp + milliseconds 68 | */ 69 | public int64 tim { get; set; } 70 | 71 | /** 72 | * Original filename 73 | */ 74 | public string? filename { get; set; } 75 | 76 | /** 77 | * File extension 78 | */ 79 | public string? ext { get; set; } 80 | 81 | /** 82 | * File size 83 | */ 84 | public uint fsize { get; set; } 85 | 86 | /** 87 | * Image width 88 | */ 89 | public uint w { get; set; } 90 | 91 | /** 92 | * Image height 93 | */ 94 | public uint h { get; set; } 95 | 96 | /** 97 | * Thumbnail width 98 | */ 99 | public uint tn_w { get; set; } 100 | 101 | /** 102 | * Thumbnail height 103 | */ 104 | public uint tn_h { get; set; } 105 | 106 | /** 107 | * File MD5 108 | * 24 chars, base64 109 | */ 110 | public string? md5 { get; set; } 111 | 112 | /** 113 | * File deleted? 114 | */ 115 | public uint filedeleted { get; set; } 116 | 117 | /** 118 | * Spoiler image? 119 | */ 120 | public uint spoiler { get; set; } 121 | 122 | /** 123 | * Custom spoilers? 124 | * 125 | * 1-99 126 | */ 127 | public uint custom_spoiler { get; set; } 128 | 129 | // begin vaccine stuff 130 | public bool isOP { get { return resto == 0; } } 131 | 132 | public weak Thread? thread = null; 133 | 134 | private string _board; 135 | public string board { 136 | get { return thread != null ? thread.board : _board; } 137 | set { _board = value; } 138 | } 139 | 140 | public Gdk.Pixbuf? pixbuf { get; private set; default = null; } 141 | 142 | public delegate void UseDownloadedPixbuf (Gdk.Pixbuf buf); 143 | 144 | public Cancellable get_thumbnail (UseDownloadedPixbuf cb) 145 | requires (filename != null) 146 | { 147 | var url = "https://i.4cdn.org/%s/%llds.jpg".printf (board, tim); 148 | var cancel = new Cancellable (); 149 | if (pixbuf == null) 150 | FourChan.download_image.begin (url, cancel, (obj, res) => { 151 | pixbuf = FourChan.download_image.end (res).get_static_image (); 152 | if (!cancel.is_cancelled () && pixbuf != null) 153 | cb ((!) pixbuf); 154 | }); 155 | else 156 | cb ((!) pixbuf); 157 | return cancel; 158 | } 159 | 160 | public uint nreplies { 161 | get { 162 | var quote = ">>%lld".printf (no); 163 | uint n = 0; 164 | foreach (var p in thread.posts) 165 | if (p.com != null && p.com.contains (quote)) 166 | ++n; 167 | return n; 168 | } 169 | } 170 | 171 | public bool visible { get; set; default = true; } 172 | } 173 | 174 | public class ThreadOP : Post { 175 | /** 176 | * Stickied thread? 177 | */ 178 | public uint sticky { get; set; } 179 | 180 | /** 181 | * Closed thread? 182 | */ 183 | public uint closed { get; set; } 184 | 185 | /** 186 | * Archived thread? 187 | */ 188 | public uint archived { get; set; } 189 | 190 | /** 191 | * Subject 192 | */ 193 | public string sub { get; set; } 194 | 195 | /** 196 | * Thread URL slug 197 | */ 198 | public string semantic_url { get; set; } 199 | 200 | /** 201 | * Thread tag 202 | */ 203 | public string tag { get; set; } 204 | 205 | /** 206 | * Number of total replies 207 | */ 208 | public uint replies { get; set; } 209 | 210 | /** 211 | * Number of total images 212 | */ 213 | public uint images { get; set; } 214 | 215 | /** 216 | * Bump limit met? 217 | */ 218 | public uint bumplimit { get; set; } 219 | 220 | /** 221 | * Image limit met? 222 | */ 223 | public uint imagelimit { get; set; } 224 | 225 | /** 226 | * Unique IPs participated in this thread 227 | */ 228 | public uint unique_ips { get; set; } 229 | 230 | /** 231 | * Last time modified. Includes replies, deletions, 232 | * sticky/closed changes. 233 | */ 234 | public uint last_modified { get; set; } 235 | 236 | /* not from JSON */ 237 | public uint bump_order { get; set; } 238 | 239 | /* fix for GtkInspector crash */ 240 | public new uint nreplies { 241 | get { return (int)replies; } 242 | } 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /data/ui/catalog-item.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 142 | 143 | -------------------------------------------------------------------------------- /src/media/MediaStore.vala: -------------------------------------------------------------------------------- 1 | public class Vaccine.MediaStore : Object, Gtk.TreeModel { 2 | public Thread thread { construct; get; } 3 | public List previews = new List (); 4 | private int stamp = 0; 5 | 6 | public MediaStore (Thread t) { 7 | Object (thread: t); 8 | 9 | t.posts.foreach (post => { 10 | if (post.filename != null) { 11 | var media = MediaPreview.from_post (this, post); 12 | if (media != null) { 13 | previews.append (media); 14 | ++stamp; 15 | } 16 | } 17 | return true; 18 | }); 19 | 20 | t.items_changed.connect (update); 21 | } 22 | 23 | ~MediaStore () { 24 | debug ("MediaStore dtor"); 25 | // cancel all downloads 26 | foreach (var preview in previews) 27 | preview.cancel_thumbnail_download.cancel (); 28 | } 29 | 30 | private void update (uint position, uint removed, uint added) { 31 | unowned List current = previews.first (); 32 | 33 | for (uint p = 0; p < thread.posts.size; ++p) 34 | if (thread.posts [(int)p].filename != null && MediaPreview.is_supported (thread.posts [(int)p].ext)) { 35 | if (current != null && thread.posts [(int)p].no == current.data.id) 36 | current = current.next; 37 | else { 38 | if (current != null) { // remove 39 | current = current.prev; 40 | previews.remove_link (current.next); 41 | --stamp; 42 | var iter = Gtk.TreeIter () { 43 | stamp = this.stamp, 44 | user_data = current.next 45 | }; 46 | var path = get_path (iter); 47 | this.row_deleted (path); 48 | } else 49 | current = previews.last (); // insert at front 50 | var media = MediaPreview.from_post (this, thread.posts [(int)p]); 51 | current.append (media); 52 | ++stamp; 53 | current = current.next; 54 | var iter = Gtk.TreeIter () { 55 | stamp = this.stamp, 56 | user_data = current 57 | }; 58 | var path = get_path (iter); 59 | this.row_inserted (path, iter); 60 | } 61 | } 62 | } 63 | 64 | /** 65 | * Get property type by index: 66 | * 0 = id (int64) 67 | * 1 = thumbnail (Gdk.Pixbuf) 68 | * 2 = filename (string) 69 | */ 70 | public Type get_column_type (int index) { 71 | switch (index) { 72 | case 0: 73 | return typeof (int64); 74 | case 1: 75 | return typeof (Gdk.Pixbuf); 76 | case 2: 77 | return typeof (string); 78 | default: 79 | return Type.INVALID; 80 | } 81 | } 82 | 83 | public Gtk.TreeModelFlags get_flags () { 84 | return 0; 85 | } 86 | 87 | // return an invalid iter 88 | private bool invalid_iter (out Gtk.TreeIter iter) { 89 | iter = Gtk.TreeIter () { stamp = -1 }; 90 | return false; 91 | } 92 | 93 | public void get_value (Gtk.TreeIter iter, int column, out Value val) { 94 | assert (iter.stamp == stamp); 95 | 96 | MediaPreview preview = ((List) iter.user_data).data; 97 | 98 | switch (column) { 99 | case 0: // id 100 | val = Value (typeof (int64)); 101 | val.set_int64 (preview.id); 102 | break; 103 | case 1: // pixbuf 104 | val = Value (typeof (Gdk.Pixbuf)); 105 | val.set_object (preview.thumbnail); 106 | break; 107 | case 2: // filename 108 | val = Value (typeof (string)); 109 | val.set_string (preview.filename); 110 | break; 111 | default: 112 | val = Value (Type.INVALID); 113 | break; 114 | } 115 | } 116 | 117 | public bool get_iter (out Gtk.TreeIter iter, Gtk.TreePath path) { 118 | // (path depth of 1 = flat tree) 119 | if (path.get_depth () != 1 || thread.posts.size == 0) 120 | return invalid_iter (out iter); 121 | 122 | iter = Gtk.TreeIter () { 123 | stamp = this.stamp, 124 | user_data = previews.nth (path.get_indices ()[0]) // save List link 125 | }; 126 | return true; 127 | } 128 | 129 | public int get_n_columns () { return 3; } 130 | 131 | public Gtk.TreePath? get_path (Gtk.TreeIter iter) { 132 | assert (iter.stamp == stamp); 133 | 134 | Gtk.TreePath path = new Gtk.TreePath (); 135 | path.append_index (previews.position ((List) iter.user_data)); 136 | return path; 137 | } 138 | 139 | public int iter_n_children (Gtk.TreeIter? iter) { 140 | assert (iter == null || iter.stamp == stamp); 141 | // iter == null (points to start of list) 142 | // iter != null (iter points to node with 0 child nodes) 143 | return iter == null ? thread.posts.size : 0; 144 | } 145 | 146 | public bool iter_next (ref Gtk.TreeIter iter) { 147 | assert (iter.stamp == stamp); 148 | 149 | unowned List link = (List) iter.user_data; 150 | if (link.next == null) 151 | return false; 152 | 153 | iter.user_data = link.next; 154 | return true; 155 | } 156 | 157 | public bool iter_previous (ref Gtk.TreeIter iter) { 158 | assert (iter.stamp == stamp); 159 | 160 | unowned List link = (List) iter.user_data; 161 | if (link.prev == null) 162 | return false; 163 | 164 | iter.user_data = link.prev; 165 | return true; 166 | } 167 | 168 | /* 169 | * Set iter to point to the nth child 170 | * parent should be null (if not, then we do nothing, since this is not a tree) 171 | */ 172 | public bool iter_nth_child (out Gtk.TreeIter iter, Gtk.TreeIter? parent, int n) { 173 | assert (parent == null || parent.stamp == stamp); 174 | 175 | // set iter to nth child in list 176 | if (parent == null && n < previews.length ()) { 177 | iter = Gtk.TreeIter () { 178 | stamp = this.stamp, 179 | user_data = previews.nth (n) // save link 180 | }; 181 | return true; 182 | } 183 | 184 | // otherwise, return invalid iter 185 | return invalid_iter (out iter); 186 | } 187 | 188 | public bool iter_children (out Gtk.TreeIter iter, Gtk.TreeIter? parent) { 189 | assert (parent == null || parent.stamp == stamp); 190 | return invalid_iter (out iter); // not a tree 191 | } 192 | 193 | public bool iter_has_child (Gtk.TreeIter iter) { 194 | assert (iter.stamp == stamp); 195 | return false; // not a tree 196 | } 197 | 198 | public bool iter_parent (out Gtk.TreeIter iter, Gtk.TreeIter child) { 199 | assert (child.stamp == stamp); 200 | return invalid_iter (out iter); // not a tree 201 | } 202 | 203 | public bool get_path_and_iter (MediaPreview preview, out Gtk.TreePath path, out Gtk.TreeIter iter) { 204 | for (unowned List link = previews.first (); 205 | link != null; link = link.next) { 206 | if (link.data == preview) { 207 | iter = Gtk.TreeIter () { 208 | stamp = this.stamp, 209 | user_data = link 210 | }; 211 | path = this.get_path (iter); 212 | return true; 213 | } 214 | } 215 | invalid_iter (out iter); 216 | path = null; 217 | return false; 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/view/MediaView.vala: -------------------------------------------------------------------------------- 1 | [GtkTemplate (ui = "/org/vaccine/app/media-view.ui")] 2 | public class Vaccine.MediaView : Gtk.Window { 3 | // [GtkChild] private unowned Gtk.HeaderBar headerbar; 4 | 5 | // headerbar buttons 6 | // [GtkChild] private unowned Gtk.Button btn_prev; 7 | // [GtkChild] private unowned Gtk.Button btn_next; 8 | [GtkChild] private unowned Gtk.ToggleButton btn_gallery; 9 | // [GtkChild] private unowned Gtk.Button btn_download; 10 | [GtkChild] private unowned Gtk.Button btn_present; 11 | [GtkChild] private unowned Gtk.Button btn_reverse_search; 12 | 13 | // view and containers 14 | [GtkChild] private unowned Gtk.Stack stack; 15 | [GtkChild] private unowned Gtk.Alignment loading_view; 16 | [GtkChild] private unowned Gtk.ProgressBar download_progress; 17 | [GtkChild] private unowned Gtk.Box gallery_view; 18 | [GtkChild] private unowned Gtk.IconView gallery_icons; 19 | 20 | // usable widget 21 | [GtkChild] private unowned Gtk.DrawingArea image_view; 22 | 23 | // must be filled with VideoPreviewWidget 24 | [GtkChild] private unowned Gtk.Box video_holder; 25 | private VideoPreviewWidget video_view; 26 | 27 | // custom data 28 | private Gtk.ApplicationWindow parent_window; 29 | public Thread thread { construct; get; } 30 | private MediaStore store; 31 | private unowned List current_media; 32 | private unowned Gtk.Widget last_widget; 33 | 34 | private bool is_fullscreen = false; 35 | 36 | private uint? pulse_id = null; 37 | private uint? media_onready_id = null; 38 | 39 | public MediaView (Gtk.ApplicationWindow window, Post post) { 40 | Object (thread: post.thread); 41 | assert (post.filename != null); 42 | 43 | parent_window = window; 44 | 45 | gallery_icons.pixbuf_column = 1; 46 | gallery_icons.text_column = 2; 47 | 48 | store = new MediaStore ((!) (post.thread)); 49 | gallery_icons.model = store; 50 | gallery_icons.item_activated.connect (path => { 51 | Gtk.TreeIter iter; 52 | if (store.get_iter (out iter, path)) { 53 | if (stack.visible_child == gallery_view) 54 | btn_gallery.active = false; 55 | show_media ((List) iter.user_data); 56 | } 57 | }); 58 | 59 | // add VideoPreviewWidget to box 60 | video_view = new VideoPreviewWidget (); 61 | video_holder.pack_start (video_view); 62 | video_holder.show_all (); 63 | 64 | current_media = store.previews.first (); 65 | while (current_media.next != null && current_media.data.id != post.no) 66 | current_media = current_media.next; 67 | last_widget = loading_view; 68 | title = current_media.data.filename; 69 | show_media (current_media, true); 70 | set_transient_for (window); 71 | } 72 | 73 | ~MediaView () { 74 | if (current_media != null) 75 | current_media.data.stop_with_widget (); 76 | if (pulse_id != null) 77 | Source.remove ((!) pulse_id); 78 | if (media_onready_id != null) 79 | Source.remove ((!) media_onready_id); 80 | debug ("MediaView dtor"); 81 | } 82 | 83 | private void show_media (List next_media, bool initial = false) { 84 | if (!initial) 85 | current_media.data.stop_with_widget (); 86 | current_media = next_media; 87 | btn_reverse_search.sensitive = current_media.data is ImagePreview; 88 | if (!current_media.data.loaded) 89 | stack.visible_child = loading_view; 90 | if (pulse_id != null) { 91 | Source.remove ((!) pulse_id); 92 | pulse_id = null; 93 | } 94 | if (media_onready_id != null) { 95 | Source.remove ((!) media_onready_id); 96 | media_onready_id = null; 97 | } 98 | pulse_id = Timeout.add (300, () => { 99 | download_progress.pulse (); 100 | return Source.CONTINUE; 101 | }); 102 | title = "%s (%d of %u)".printf (current_media.data.filename, store.previews.position (current_media) + 1, store.previews.length ()); 103 | if (current_media.data is ImagePreview) 104 | current_media.data.init_with_widget (image_view); 105 | else if (current_media.data is VideoPreview) 106 | current_media.data.init_with_widget (video_view); 107 | media_onready_id = Idle.add (() => { 108 | if (!current_media.data.loaded) 109 | return Source.CONTINUE; 110 | // stop loading 111 | Source.remove (pulse_id); 112 | pulse_id = null; 113 | download_progress.set_fraction (1); 114 | media_onready_id = null; 115 | 116 | if (current_media.data is ImagePreview) 117 | last_widget = image_view; 118 | else if (current_media.data is VideoPreview) { 119 | last_widget = video_holder; 120 | } else 121 | error ("failed !!!"); 122 | if (stack.visible_child != gallery_view) 123 | stack.visible_child = last_widget; 124 | return Source.REMOVE; 125 | }); 126 | } 127 | 128 | [GtkCallback] 129 | private void show_prev_media () { 130 | if (current_media.prev == null) 131 | show_media (store.previews.last ()); 132 | else 133 | show_media (current_media.prev); 134 | } 135 | 136 | [GtkCallback] 137 | private void show_next_media () { 138 | if (current_media.next == null) 139 | show_media (store.previews.first ()); 140 | else 141 | show_media (current_media.next); 142 | } 143 | 144 | [GtkCallback] 145 | private void download_file () { 146 | var chooser = new Gtk.FileChooserDialog ("Save File", this, 147 | Gtk.FileChooserAction.SAVE, 148 | "_Cancel", Gtk.ResponseType.CANCEL, 149 | "_Save", Gtk.ResponseType.ACCEPT); 150 | chooser.set_current_name (current_media.data.filename); 151 | var filter = new Gtk.FileFilter (); 152 | filter.set_filter_name (current_media.data.extension); 153 | filter.add_pattern ("*" + current_media.data.extension); 154 | chooser.add_filter (filter); 155 | if (chooser.run () == Gtk.ResponseType.ACCEPT) { 156 | string fname = chooser.get_filename (); 157 | current_media.data.save_as.begin (fname, (obj, res) => { 158 | Notification notif; 159 | try { 160 | current_media.data.save_as.end (res); 161 | notif = new Notification ("Finished downloading"); 162 | notif.set_body (@"Saved file to $fname"); 163 | } catch (Error e) { 164 | debug (@"error: $(e.message)"); 165 | notif = new Notification ("Error saving file"); 166 | notif.set_body (e.message); 167 | } 168 | Application.get_default ().send_notification (null, notif); 169 | }); 170 | } 171 | chooser.destroy (); 172 | } 173 | 174 | [GtkCallback] 175 | private void toggle_gallery () { 176 | if (btn_gallery.active) 177 | stack.visible_child = gallery_view; 178 | else 179 | stack.visible_child = last_widget; 180 | } 181 | 182 | [GtkCallback] 183 | private void reverse_image_search () { 184 | MediaPreview preview = current_media.data; 185 | AppInfo.launch_default_for_uri ("https://www.google.com/searchbyimage?&image_url=%s".printf (preview.url), null); 186 | } 187 | 188 | [GtkCallback] 189 | private void toggle_fullscreen () { 190 | if (is_fullscreen) { 191 | this.unfullscreen (); 192 | this.modal = true; 193 | } else { 194 | this.modal = false; 195 | this.fullscreen (); 196 | } 197 | is_fullscreen = !is_fullscreen; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /data/ui/post-list-row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 174 | 175 | -------------------------------------------------------------------------------- /data/ui/preferences-view.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 171 | 172 | 200 173 | 50 174 | 1 175 | 10 176 | 177 | 178 | -------------------------------------------------------------------------------- /data/ui/video-preview-widget.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 191 | 192 | -------------------------------------------------------------------------------- /src/media/VideoPreview.vala: -------------------------------------------------------------------------------- 1 | public class Vaccine.VideoPreview : MediaPreview { 2 | private Gst.Element video_source; // playbin 3 | 4 | private string src_plugin { set; public get; default = "playbin"; } 5 | 6 | private bool _loaded = false; 7 | public override bool loaded { get { return _loaded; } } 8 | 9 | // hold reference to video preview widget 10 | private VideoPreviewWidget? preview_widget; 11 | 12 | private Gtk.Adjustment? adjustment; 13 | 14 | // time in ns 15 | private string convert_clocktime (uint64 time) { 16 | uint64 seconds = time / Gst.SECOND; 17 | uint64 min = seconds / 60; 18 | 19 | return @"%$(uint64.FORMAT):%02$(uint64.FORMAT)".printf (min, seconds % 60); 20 | } 21 | 22 | public VideoPreview (MediaStore media_store, Post post) 23 | requires (post.filename != null && post.ext != null) 24 | { 25 | base (media_store, post); 26 | video_source = Gst.ElementFactory.make (src_plugin, "video_source"); 27 | if (video_source == null) { 28 | error (@"could not create plugin $src_plugin"); 29 | } 30 | } 31 | 32 | ~VideoPreview () { 33 | if (preview_widget != null) 34 | stop_with_widget (); 35 | debug ("VideoPreview dtor"); 36 | } 37 | 38 | private weak Binding? bind_position; 39 | private weak Binding? bind_duration; 40 | private weak Binding? bind_is_playing; 41 | private weak Binding? bind_repeat; 42 | 43 | public override void init_with_widget (Gtk.Widget widget) 44 | requires (widget is VideoPreviewWidget) 45 | { 46 | preview_widget = widget as VideoPreviewWidget; 47 | 48 | terminate = false; 49 | // set the sink element 50 | video_source["video-sink"] = preview_widget.video_sink; 51 | // set the URI 52 | video_source["uri"] = url; 53 | 54 | // create adjustment 55 | adjustment = new Gtk.Adjustment (0, 0, duration, 1, 0, 0); 56 | preview_widget.progress_scale.adjustment = adjustment; 57 | 58 | // bind properties: 59 | bind_position = bind_property ("position", preview_widget.progress_text_start, 60 | "label", BindingFlags.DEFAULT, (binding, srcval, ref targetval) => { 61 | targetval.set_string (convert_clocktime (srcval.get_int64 ())); 62 | return true; 63 | }); 64 | bind_duration = bind_property ("duration", preview_widget.progress_text_end, 65 | "label", BindingFlags.DEFAULT, (binding, srcval, ref targetval) => { 66 | targetval.set_string (convert_clocktime (srcval.get_uint64 ())); 67 | return true; 68 | }); 69 | bind_property ("position", adjustment, "value", BindingFlags.BIDIRECTIONAL, null, 70 | (binding, srcval, ref targetval) => { // adjustment.value -> position 71 | return video_source.seek_simple (Gst.Format.TIME, 72 | Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE, 73 | (int64) srcval.get_double ()); 74 | }); 75 | bind_property ("duration", adjustment, "upper", BindingFlags.DEFAULT); 76 | bind_is_playing = bind_property ("playing", preview_widget.btn_play_img, 77 | "icon-name", BindingFlags.DEFAULT, (binding, srcval, ref targetval) => { 78 | if (srcval.get_boolean ()) // playing 79 | targetval = "media-playback-pause-symbolic"; 80 | else 81 | targetval = "media-playback-start-symbolic"; 82 | return true; 83 | }); 84 | bind_repeat = bind_property ("repeat", preview_widget.toggle_repeat, 85 | "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); 86 | 87 | // start playing 88 | if (video_source.set_state (Gst.State.PLAYING) == Gst.StateChangeReturn.FAILURE) 89 | debug ("failed to start playing"); 90 | 91 | // add callback 92 | preview_widget.btn_play.clicked.connect (toggle_play); 93 | 94 | // listen to the bus 95 | video_source.get_bus ().add_watch (Priority.DEFAULT, handle_message); 96 | 97 | // set up monitor 98 | Idle.add (monitor_bus); 99 | } 100 | 101 | public override void stop_with_widget () 102 | requires (preview_widget != null) 103 | { 104 | terminate = true; 105 | video_source["video-sink"] = null; 106 | video_source.set_state (Gst.State.NULL); 107 | 108 | // unbind all properties 109 | bind_position.unbind (); 110 | bind_position = null; 111 | bind_duration.unbind (); 112 | bind_duration = null; 113 | bind_is_playing.unbind (); 114 | bind_is_playing = null; 115 | bind_repeat.unbind (); 116 | bind_repeat = null; 117 | 118 | adjustment = null; 119 | 120 | // disconnect widget event handlers 121 | preview_widget.btn_play.clicked.disconnect (toggle_play); 122 | 123 | // preview_widget.progress_scale.adjustment = null; 124 | preview_widget = null; 125 | } 126 | 127 | // if video is playing 128 | public bool playing { get; private set; } 129 | 130 | // if seeking is enabled 131 | private bool _seek_enabled = false; 132 | public bool seek_enabled { get { return _seek_enabled; } } 133 | 134 | // position in video 135 | public int64 position { get; set; default = -1; } 136 | 137 | // length of video 138 | public uint64 duration { get; private set; default = Gst.CLOCK_TIME_NONE; } 139 | 140 | public bool terminate { get; private set; } 141 | 142 | // if stream has ended 143 | public bool end_of_stream { get; private set; } 144 | 145 | // repeat the video 146 | public bool repeat { get; set; default = true; } 147 | 148 | // handle messages from the bus 149 | private bool handle_message (Gst.Bus bus, Gst.Message msg) { 150 | if (terminate) 151 | return Source.REMOVE; 152 | switch (msg.type) { 153 | case Gst.MessageType.ERROR: 154 | GLib.Error err; 155 | string debug_info; 156 | 157 | msg.parse_error (out err, out debug_info); 158 | stdout.printf ("Error received from element %s: %s\n", msg.src.name, err.message); 159 | stdout.printf ("Debugging information: %s\n", debug_info != null ? debug_info : "none"); 160 | break; 161 | case Gst.MessageType.EOS: 162 | debug ("End-of-stream reached"); 163 | end_of_stream = true; 164 | if (repeat) { 165 | if (video_source.seek_simple (Gst.Format.TIME, 166 | Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, 167 | 0)) 168 | end_of_stream = false; 169 | else 170 | debug ("failed to seek to beginning"); 171 | } else 172 | video_source.set_state (Gst.State.PAUSED); 173 | break; 174 | case Gst.MessageType.DURATION_CHANGED: 175 | duration = Gst.CLOCK_TIME_NONE; 176 | break; 177 | case Gst.MessageType.STATE_CHANGED: 178 | Gst.State old_state; 179 | Gst.State new_state; 180 | Gst.State pending_state; 181 | 182 | msg.parse_state_changed (out old_state, out new_state, out pending_state); 183 | if (msg.src == video_source) { 184 | debug ("Pipeline state changed from %s to %s", 185 | Gst.Element.state_get_name (old_state), 186 | Gst.Element.state_get_name (new_state)); 187 | if (new_state == Gst.State.READY) 188 | _loaded = true; 189 | playing = (new_state == Gst.State.PLAYING); 190 | 191 | if (playing) { 192 | Gst.Query query = new Gst.Query.seeking (Gst.Format.TIME); 193 | int64 start; 194 | int64 end; 195 | 196 | if (video_source.query (query)) { 197 | query.parse_seeking (null, out _seek_enabled, out start, out end); 198 | if (seek_enabled) 199 | debug (@"seeking is enabled from $start to $end"); 200 | else 201 | debug ("seeking is disabled"); 202 | } else 203 | debug ("seeking query failed"); 204 | end_of_stream = false; 205 | } 206 | } 207 | break; 208 | default: 209 | // do nothing 210 | break; 211 | } 212 | return terminate ? Source.REMOVE : Source.CONTINUE; 213 | } 214 | 215 | // update information from the video 216 | private bool monitor_bus () { 217 | if (playing) { 218 | Gst.Format fmt = Gst.Format.TIME; 219 | int64 current = -1; 220 | Gst.ClockTime stream_length = Gst.CLOCK_TIME_NONE; 221 | 222 | // get position 223 | if (!video_source.query_position (fmt, out current)) 224 | debug ("could not query media position"); 225 | else 226 | position = current; 227 | 228 | // get stream duration 229 | if (duration == Gst.CLOCK_TIME_NONE) { 230 | if (!video_source.query_duration (fmt, out stream_length)) 231 | debug ("could not query media duration"); 232 | else 233 | duration = stream_length; 234 | } 235 | 236 | } 237 | return terminate ? Source.REMOVE : Source.CONTINUE; 238 | } 239 | 240 | private void toggle_play () { 241 | Gst.State new_state = playing ? Gst.State.PAUSED : Gst.State.PLAYING; 242 | 243 | if (new_state == Gst.State.PLAYING && end_of_stream) { 244 | if (!video_source.seek_simple (Gst.Format.TIME, 245 | Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, 0)) 246 | debug ("failed to reset stream"); 247 | } 248 | Gst.StateChangeReturn ret = video_source.set_state (new_state); 249 | if (ret == Gst.StateChangeReturn.FAILURE) 250 | debug ("Unable to change state"); 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/view/MainWindow.vala: -------------------------------------------------------------------------------- 1 | [GtkTemplate (ui = "/org/vaccine/app/main-window.ui")] 2 | public class Vaccine.MainWindow : Gtk.ApplicationWindow { 3 | [GtkChild] private unowned Gtk.HeaderBar headerbar; 4 | [GtkChild] private unowned Gtk.SearchEntry searchentry; 5 | 6 | [GtkChild] private unowned Gtk.Button choose_board_button; 7 | [GtkChild] private unowned Gtk.MenuButton board_sort_button; 8 | [GtkChild] private unowned Gtk.ToggleButton show_search_bar_button; 9 | [GtkChild] private unowned Gtk.Button open_in_browser_button; 10 | [GtkChild] private unowned Gtk.Button refresh_button; 11 | 12 | // fullscreen headerbar controls 13 | [GtkChild] private unowned Gtk.HeaderBar fs_headerbar; 14 | [GtkChild] private unowned Gtk.ToggleButton fs_choose_board_button; 15 | [GtkChild] private unowned Gtk.ToggleButton fs_show_search_bar_button; 16 | [GtkChild] private unowned Gtk.Button fs_open_in_browser_button; 17 | [GtkChild] private unowned Gtk.Button fs_refresh_button; 18 | [GtkChild] private unowned Gtk.Revealer fs_revealer; 19 | 20 | [GtkChild] private unowned Gtk.SearchBar searchbar; 21 | [GtkChild] private unowned Gtk.Stack content_stack; 22 | [GtkChild] private unowned Gtk.Notebook notebook; 23 | [GtkChild] private unowned Gtk.Label no_content_description; 24 | 25 | // board chooser 26 | [GtkChild] private unowned Gtk.Popover popover; 27 | [GtkChild] private unowned Gtk.ListBox listbox; 28 | [GtkChild] private unowned Gtk.SearchEntry board_search; 29 | 30 | private CatalogWidget catalog; 31 | 32 | const ActionEntry[] shortcuts = { 33 | { "close_tab", close_tab }, 34 | { "toggle_search", toggle_search }, 35 | { "next_tab", next_tab }, 36 | { "prev_tab", prev_tab }, 37 | { "fullscreen", toggle_fullscreen }, 38 | }; 39 | 40 | void close_tab () { 41 | var page = notebook.get_nth_page (notebook.page); 42 | if (page != catalog) 43 | page.destroy (); 44 | } 45 | 46 | void toggle_search () { 47 | if (!searchbar.search_mode_enabled) { 48 | searchbar.search_mode_enabled = true; 49 | searchentry.grab_focus_without_selecting (); 50 | } else { 51 | searchbar.search_mode_enabled = false; 52 | } 53 | } 54 | 55 | void next_tab () { 56 | notebook.page = (notebook.page+1) % notebook.get_n_pages (); 57 | } 58 | 59 | void prev_tab () { 60 | notebook.page = (notebook.page-1) % notebook.get_n_pages (); 61 | } 62 | 63 | private bool _is_fullscreen = false; 64 | private bool is_fullscreen { 65 | get { return _is_fullscreen; } 66 | set { 67 | _is_fullscreen = value; 68 | if (_is_fullscreen) { 69 | this.fullscreen (); 70 | popover.relative_to = fs_choose_board_button; 71 | } else { 72 | this.unfullscreen (); 73 | popover.relative_to = choose_board_button; 74 | fs_revealer.reveal_child = false; 75 | } 76 | } 77 | } 78 | 79 | void toggle_fullscreen () { 80 | is_fullscreen = !is_fullscreen; 81 | } 82 | 83 | const ActionEntry[] catalog_sort_actions = { 84 | { "sort_bump_order", sort_bump_order }, 85 | { "sort_last_reply", sort_last_reply }, 86 | { "sort_creation_date", sort_creation_date }, 87 | { "sort_reply_count", sort_reply_count } 88 | }; 89 | 90 | public MainWindow (Gtk.Application app) { 91 | Object (application: app); 92 | 93 | add_action_entries (shortcuts, this); 94 | 95 | app.set_accels_for_action ("app.quit", {"Q"}); 96 | app.set_accels_for_action ("win.close_tab", {"W"}); 97 | app.set_accels_for_action ("win.toggle_search", {"F"}); 98 | app.set_accels_for_action ("win.next_tab", {"Tab"}); 99 | app.set_accels_for_action ("win.prev_tab", {"Tab"}); 100 | app.set_accels_for_action ("win.fullscreen", {"F11"}); 101 | 102 | Variant geom = App.settings.get_value ("win-geom"); 103 | int x, y, width, height; 104 | geom.get ("(iiii)", out x, out y, out width, out height); 105 | move (x, y); 106 | set_default_size (width, height); 107 | 108 | // meme magic: 109 | const string[] no_content_texts = { 110 | "Select a board to begin", 111 | "Select a board to waste time on", 112 | "Select a board to shitpost about animu", 113 | "Start wasting time", 114 | "Get out of your mom's basement" 115 | }; 116 | 117 | no_content_description.label = no_content_texts [Random.int_range (0, no_content_texts.length)]; 118 | 119 | App.settings.changed["filter-nsfw-content"].connect (listbox.invalidate_filter); 120 | board_search.changed.connect (listbox.invalidate_filter); 121 | 122 | FourChan.get_boards.begin ((obj, res) => { 123 | var boards = FourChan.get_boards.end (res); 124 | listbox.foreach (w => w.destroy ()); 125 | foreach (Board b in boards) { 126 | var row = new Gtk.Label ("/%s/ - %s".printf (b.board, b.title)); 127 | row.name = b.board; 128 | row.margin = 6; 129 | row.halign = Gtk.Align.START; 130 | row.set_data ("nsfw", b.ws_board == 0); 131 | listbox.add (row); 132 | } 133 | listbox.show_all (); 134 | }); 135 | 136 | listbox.set_filter_func (row => { 137 | var label = row.get_child () as Gtk.Label; 138 | if (App.settings.get_boolean ("filter-nsfw-content") && label.get_data ("nsfw")) 139 | return false; 140 | if (board_search.text == "") 141 | return true; 142 | return label.name.contains (board_search.text); 143 | }); 144 | 145 | notebook.page_added.connect ((w, p) => 146 | notebook.show_tabs = (notebook.get_n_pages() > 1)); 147 | notebook.page_removed.connect ((w, p) => 148 | notebook.show_tabs = (notebook.get_n_pages() > 1)); 149 | 150 | show_search_bar_button.bind_property ("active", searchbar, "search-mode-enabled", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); 151 | fs_show_search_bar_button.bind_property ("active", searchbar, "search-mode-enabled", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); 152 | 153 | fs_open_in_browser_button.bind_property ("sensitive", open_in_browser_button, "sensitive", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); 154 | fs_refresh_button.bind_property ("sensitive", refresh_button, "sensitive", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); 155 | fs_choose_board_button.bind_property ("label", choose_board_button, "label", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); 156 | 157 | notebook.notify["page"].connect ((obj, pspec) => { 158 | NotebookPage page = notebook.get_nth_page (notebook.page) as NotebookPage; 159 | headerbar.title = page.name; 160 | fs_headerbar.title = page.name; 161 | 162 | if (page.search_text != null && page.search_text != "") { 163 | searchbar.search_mode_enabled = true; 164 | searchentry.text = page.search_text; 165 | } else { 166 | searchbar.search_mode_enabled = false; 167 | } 168 | }); 169 | 170 | listbox.row_selected.connect (row => { 171 | if (row != null) { // why is it null? 172 | var child = row.get_child () as Gtk.Label; 173 | FourChan.board = child.name; 174 | 175 | open_in_browser_button.sensitive = false; 176 | refresh_button.sensitive = false; 177 | choose_board_button.label = child.label; 178 | 179 | popover.visible = false; 180 | board_search.text = ""; 181 | 182 | content_stack.visible_child = notebook; 183 | notebook.page = notebook.page_num (catalog); 184 | } 185 | }); 186 | 187 | catalog = new CatalogWidget (); 188 | add_page (catalog, false, false); 189 | 190 | FourChan.catalog.downloaded.connect ((o, board, threads) => { 191 | catalog.clear (); 192 | int order = 0; 193 | foreach (Page page in threads) 194 | foreach (ThreadOP t in page.threads) { 195 | t.bump_order = order; 196 | catalog.add (this, t); 197 | ++order; 198 | } 199 | open_in_browser_button.sensitive = true; 200 | refresh_button.sensitive = true; 201 | }); 202 | 203 | SimpleActionGroup group = new SimpleActionGroup (); 204 | group.add_action_entries (catalog_sort_actions, this); 205 | board_sort_button.insert_action_group ("catalog", group); 206 | 207 | var menu = new Gtk.Builder.from_resource ("/org/vaccine/app/gtk/menus.ui") 208 | .get_object ("catalog-sort-menu") as Menu; 209 | board_sort_button.set_menu_model (menu); 210 | 211 | this.set_help_overlay (new ShortcutsWindow ()); 212 | this.show_all (); 213 | } 214 | 215 | public override bool delete_event (Gdk.EventAny ev) { 216 | int x, y, width, height; 217 | get_position (out x, out y); 218 | get_size (out width, out height); 219 | App.settings.set ("win-geom", "(iiii)", x, y, width, height); 220 | return false; 221 | } 222 | 223 | public void show_thread (int64 no, Gdk.Pixbuf op_thumbnail) { 224 | var panelview = new PanelView (); 225 | var threadpane = new ThreadPane (FourChan.board, no, op_thumbnail); 226 | var thread = new Thread (FourChan.board, no); 227 | threadpane.set_model (thread); 228 | 229 | panelview.name = "Loading…"; 230 | FourChan.dl_thread.begin (thread, (obj, res) => { 231 | FourChan.dl_thread.end (res); 232 | panelview.name = thread.get_title (); 233 | }); 234 | 235 | panelview.add (threadpane); 236 | add_page (panelview); 237 | } 238 | 239 | private void add_page (NotebookPage page, bool closeable = true, bool reorderable = true) { 240 | var tab = new Tab (notebook, page, closeable); 241 | int i = notebook.append_page (page, tab); 242 | notebook.child_set (page, "reorderable", reorderable); 243 | notebook.set_current_page (i); 244 | 245 | page.notify["name"].connect (() => { 246 | notebook.notify_property ("page"); 247 | }); 248 | } 249 | 250 | private void sort_bump_order () { 251 | catalog.layout.set_sort_func((child1,child2) => { 252 | var thread1 = (child1.get_child () as CatalogItem).op; 253 | var thread2 = (child2.get_child () as CatalogItem).op; 254 | return (int)(thread1.bump_order - thread2.bump_order); 255 | }); 256 | } 257 | 258 | private void sort_creation_date () { 259 | catalog.layout.set_sort_func((child1,child2) => { 260 | var thread1 = (child1.get_child () as CatalogItem).op; 261 | var thread2 = (child2.get_child () as CatalogItem).op; 262 | return (int)(thread2.time - thread1.time); 263 | }); 264 | } 265 | 266 | private void sort_last_reply () { 267 | catalog.layout.set_sort_func((child1,child2) => { 268 | var thread1 = (child1.get_child () as CatalogItem).op; 269 | var thread2 = (child2.get_child () as CatalogItem).op; 270 | return (int)(thread2.last_modified - thread1.last_modified); 271 | }); 272 | } 273 | 274 | private void sort_reply_count () { 275 | catalog.layout.set_sort_func((child1,child2) => { 276 | var thread1 = (child1.get_child () as CatalogItem).op; 277 | var thread2 = (child2.get_child () as CatalogItem).op; 278 | return (int)(thread2.replies - thread1.replies); 279 | }); 280 | } 281 | 282 | [GtkCallback] 283 | private void open_in_browser (Gtk.Button button) { 284 | var current = notebook.get_nth_page (notebook.page); 285 | assert (current is NotebookPage); 286 | ((NotebookPage) current).open_in_browser (); 287 | } 288 | 289 | [GtkCallback] 290 | private void search_entry_changed (Gtk.Editable entry) { 291 | var current = notebook.get_nth_page (notebook.page); 292 | var text = ((Gtk.Entry) entry).text; 293 | assert (current is NotebookPage); 294 | ((NotebookPage) current).search_text = text; 295 | } 296 | 297 | [GtkCallback] 298 | private void refresh (Gtk.Button button) { 299 | var current = notebook.get_nth_page (notebook.page); 300 | assert (current is NotebookPage); 301 | ((NotebookPage) current).refresh (); 302 | } 303 | 304 | [GtkCallback] 305 | private void restore (Gtk.Button button) { 306 | is_fullscreen = false; 307 | } 308 | 309 | private bool in_fs_controls = false; 310 | 311 | [GtkCallback] 312 | private bool fs_controls_enter (Gdk.EventCrossing event) { 313 | if (!is_fullscreen) 314 | return Gdk.EVENT_PROPAGATE; 315 | fs_revealer.reveal_child = true; 316 | in_fs_controls = true; 317 | return Gdk.EVENT_STOP; 318 | } 319 | 320 | [GtkCallback] 321 | private bool fs_controls_leave (Gdk.EventCrossing event) { 322 | in_fs_controls = false; 323 | if (!fs_choose_board_button.active) 324 | fs_revealer.reveal_child = false; 325 | return Gdk.EVENT_PROPAGATE; 326 | } 327 | 328 | [GtkCallback] 329 | private void fs_choose_board_button_cb () { 330 | fs_revealer.reveal_child = fs_choose_board_button.active || in_fs_controls; 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /data/ui/media-view.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 321 | 322 | -------------------------------------------------------------------------------- /data/ui/main-window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 373 | 374 | False 375 | True 376 | 6 377 | choose_board_button 378 | 379 | 380 | True 381 | False 382 | vertical 383 | 8 384 | 385 | 386 | True 387 | True 388 | 389 | 390 | False 391 | True 392 | 0 393 | 394 | 395 | 396 | 397 | True 398 | False 399 | never 400 | in 401 | 402 | 403 | True 404 | False 405 | 406 | 407 | True 408 | False 409 | 410 | 411 | 412 | 413 | 414 | 415 | True 416 | True 417 | 1 418 | 419 | 420 | 421 | 422 | 423 | 424 | --------------------------------------------------------------------------------