# widgets.py # # Copyright 2020 Morgan McMillian # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import sys import gi import os import pnutpy import logging import requests import timeago import datetime from dateutil.tz import tzlocal gi.require_version('GdkPixbuf', '2.0') gi.require_version('Gdk', '3.0') gi.require_version('Gtk', '3.0') from gi.repository import GObject, GdkPixbuf, Gdk, Gtk, Gio, GLib gi.require_version('Handy', '1') from gi.repository import Handy class ComposeWindow(Handy.Window): def __init__(self): super().__init__(modal=True) self.set_default_size(500, 300) self.set_type_hint(Gdk.WindowTypeHint.DIALOG) self.reply_to = None box = Gtk.Box(orientation='vertical') header = Handy.HeaderBar(show_close_button=False) cancel_button = Gtk.Button(label='Cancel') cancel_button.connect('clicked', self.cancel_post) post_button = Gtk.Button(label='Post') post_button.connect('clicked', self.send_post) header.set_title('New Post') header.pack_start(cancel_button) header.pack_end(post_button) scroller = Gtk.ScrolledWindow(halign='fill') textarea = Gtk.TextView( left_margin=8, right_margin=8, top_margin=8, bottom_margin=8 ) textarea.set_wrap_mode(Gtk.WrapMode.WORD) scroller.add(textarea) self.buffer = textarea.get_buffer() self.max_length = 256 actionbar = Gtk.ActionBar() self.counter_label = Gtk.Label() self.counter_label.set_text(str(self.max_length)) actionbar.pack_end(self.counter_label) self.buffer.connect('changed', self.validate) box.pack_start(header, False, False, 0) box.pack_start(scroller, True, True, 0) box.pack_end(actionbar, False, False, 0) self.add(box) self.show_all() textarea.grab_focus() self.connect('key-release-event', self.on_key_release) self.connect('key-press-event', self.on_key_press) def on_key_press(self, widget, ev, data=None): ctrl = (ev.state & Gdk.ModifierType.CONTROL_MASK) if ctrl and ev.keyval == Gdk.KEY_Return: self.send_post(None) def on_key_release(self, widget, ev, data=None): if ev.keyval == Gdk.KEY_Escape: self.close() def validate(self, widget): self.counter = self.max_length - self.buffer.get_char_count() self.counter_label.set_text(str(self.counter)) def set_post(self, text): self.buffer.set_text(text, -1) def set_reply_to(self, reply_to): self.reply_to = reply_to def send_post(self, button): start = self.buffer.get_start_iter() end = self.buffer.get_end_iter() text = self.buffer.get_text(start, end, False) data = {'text': text} if self.reply_to is not None: data['reply_to'] = self.reply_to pnutpy.api.create_post(data=data) self.close() def cancel_post(self, button): self.close() class LoginPage(Gtk.Box): __gsignals__ = { 'login': (GObject.SIGNAL_RUN_FIRST, None, (str,)) } def __init__(self): super().__init__(orientation='vertical') self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) client_id = "1PiUzxfX_CQxKvtz93lUzPX9-FMtz-va" redirect_uri = "urn:ietf:wg:oauth:2.0:oob" scope = "basic,stream,write_post,follow,presence,messages:io.pnut.core.chat,messages:io.pnut.core.pm,files:squeak.thrrgilag,polls" uri = "https://pnut.io/oauth/authenticate" uri += "?client_id=" + client_id uri += "&redirect_uri=" + redirect_uri uri += "&scope=" + scope uri += "&response_type=token" self.login_button = Gtk.LinkButton.new_with_label(uri, "Log In to pnut.io") self.login_button.connect("clicked", self.prompt_code) self.set_center_widget(self.login_button) def prompt_code(self, button): self.remove(self.login_button) label = Gtk.Label() label.set_markup('Enter authorization code') self.code = Gtk.Entry() paste_button = Gtk.Button(label="Paste from clipboard") paste_button.connect("clicked", self.paste_code) cancel_button = Gtk.Button(label="Cancel") cancel_button.connect("clicked", self.cancel_login) confirm_button = Gtk.Button(label="Confirm") confirm_button.connect("clicked", self.confirm_login) lbox = Gtk.Box(orientation='horizontal') lbox.pack_start(cancel_button, True, True, 0) lbox.pack_start(confirm_button, True, True, 0) vbox = Gtk.Box(orientation='vertical') vbox.pack_start(label, False, False, 10) vbox.pack_start(self.code, False, False, 10) vbox.pack_start(paste_button, False, False, 10) vbox.add(lbox) hbox = Gtk.Box(orientation='horizontal') hbox.set_center_widget(vbox) self.set_center_widget(hbox) self.show_all() def paste_code(self, button): text = self.clipboard.wait_for_text() if text is not None: self.code.set_text(text) def cancel_login(self, button): # TODO: something actually useful here logging.debug("uh cancel i guess") def confirm_login(self, button): code = self.code.get_text() self.emit('login', code) class Timeline(Gtk.Box): __gsignals__ = { 'refresh': (GObject.SIGNAL_RUN_FIRST, None, ()), 'top': (GObject.SIGNAL_RUN_FIRST, None, ()), 'reply': (GObject.SIGNAL_RUN_FIRST, None, (int,str,)) } def __init__(self, stream): super().__init__(orientation='vertical') self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) self.max_id = 0 self.min_id = 0 self.scroller = Gtk.ScrolledWindow( halign='fill', kinetic_scrolling=True ) self.view = Gtk.ListBox( selection_mode=Gtk.SelectionMode.NONE ) self.scroller.add(self.view) self.pack_start(self.scroller, True, True, 0) self.stream = stream self.load_timeline() self.view.connect('button-press-event', self.on_button_pressed) self.scroller.connect('edge-reached', self.on_edge_reached) action_group = Gio.SimpleActionGroup() action = Gio.SimpleAction.new('reply', None) action.connect('activate', self.on_reply) action_group.add_action(action) action = Gio.SimpleAction.new('replyall', None) action.connect('activate', self.on_reply_all) action_group.add_action(action) action = Gio.SimpleAction.new('bookmark', None) action.connect('activate', self.on_bookmark) action_group.add_action(action) action = Gio.SimpleAction.new('repost', None) action.connect('activate', self.on_repost) action_group.add_action(action) action = Gio.SimpleAction.new('quote', None) action.connect('activate', self.on_quote) action_group.add_action(action) action = Gio.SimpleAction.new('copy', None) action.connect('activate', self.on_copy) action_group.add_action(action) action = Gio.SimpleAction.new('copylink', None) action.connect('activate', self.on_copy_link) action_group.add_action(action) action = Gio.SimpleAction.new('openlink', None) action.connect('activate', self.on_open_link) action_group.add_action(action) self.insert_action_group('win', action_group) builder = Gtk.Builder.new_from_resource("/dev/thrrgilag/squeak/menu.ui") self.menu = builder.get_object("post-menu") def load_timeline(self, older=False): params = { 'include_post_raw': 1 } if older: params['before_id'] = self.min_id if self.stream == 'unified': posts, meta = pnutpy.api.users_post_streams_unified(**params) elif self.stream == 'mentions': posts, meta = pnutpy.api.users_mentioned_posts('me', **params) elif self.stream == 'bookmarks': posts, meta = pnutpy.api.users_bookmarked_posts('me', **params) else: posts, meta = pnutpy.api.posts_streams_global(**params) self.max_id = meta.max_id self.min_id = meta.min_id for item in posts: if 'is_deleted' in item: continue postitem = PostItem(item) postitem.connect('menu-pressed', self.show_menu) postitem.connect('reply-pressed', self.on_reply_all_btn) postitem.connect('repost-pressed', self.on_repost_btn) postitem.connect('bookmark-pressed', self.on_bookmark_btn) self.view.add(postitem) self.show_all() def do_refresh(self): self.do_top() rows = self.view.get_children() for item in rows: self.view.remove(item) self.load_timeline() self.show_all() def do_top(self): vadjustment = self.scroller.get_vadjustment() vadjustment.set_value(0) def on_edge_reached(self, widget, pos): if pos == Gtk.PositionType.BOTTOM: self.load_timeline(True) def on_button_pressed(self, widget, event): if event.type == Gdk.EventType.BUTTON_PRESS and event.button == 3: pass def show_menu(self, widget, index): self.post_index = index self.post_data = widget.post popover = Gtk.Popover() popover.set_position(Gtk.PositionType.BOTTOM) popover.set_relative_to(widget.menu_button) popover.bind_model(self.menu, None) popover.popup() def on_reply(self, action, param): replyuser = "@" + self.post_data.user.username + " " self.emit('reply', self.post_data.id, replyuser) def on_reply_btn(self, widget): replyuser = "@" + widget.post.user.username + " " self.emit('reply', widget.post.id, replyuser) def on_reply_all(self, action, param): replyuser = "@" + self.post_data.user.username + " " for mention in self.post_data.content.entities.mentions: replyuser += "@" + mention.text + " " self.emit('reply', self.post_data.id, replyuser) def on_reply_all_btn(self, widget): replyuser = "@" + widget.post.user.username + " " for mention in widget.post.content.entities.mentions: replyuser += "@" + mention.text + " " self.emit('reply', widget.post.id, replyuser) def on_bookmark(self, action, param): if self.post_data.you_bookmarked: pnutpy.api.unbookmark_post(self.post_data.id) else: pnutpy.api.bookmark_post(self.post_data.id) def on_bookmark_btn(self, widget): if widget.post.you_bookmarked: pnutpy.api.unbookmark_post(widget.post.id) else: pnutpy.api.bookmark_post(widget.post.id) def on_repost(self, action, param): if self.post_data.you_bookmarked: pnutpy.api.unrepost_post(self.post_data.id) else: pnutpy.api.repost_post(self.post_data.id) def on_repost_btn(self, widget): if widget.post.you_bookmarked: pnutpy.api.unrepost_post(widget.post.id) else: pnutpy.api.repost_post(widget.post.id) def on_quote(self, action, param): quote = " >> @" + self.post_data.user.username + ": " quote += self.post_data.content.text logging.debug(quote) self.emit('reply', self.post_data.id, quote) def on_copy(self, action, param): self.clipboard.set_text(self.post_data.content.text, -1) def on_copy_link(self, action, param): post_url = f"https://posts.pnut.io/{self.post_data.id}" self.clipboard.set_text(post_url, -1) def on_open_link(self, action, param): logging.debug("open_link") post_url = f"https://posts.pnut.io/{self.post_data.id}" opened = Gtk.show_uri_on_window(None, post_url, Gdk.CURRENT_TIME) logging.debug(opened) class PostItem(Gtk.ListBoxRow): __gsignals__ = { 'menu-pressed': (GObject.SIGNAL_RUN_FIRST, None, (int,)), 'reply-pressed': (GObject.SIGNAL_RUN_FIRST, None, ()), 'repost-pressed': (GObject.SIGNAL_RUN_FIRST, None, ()), 'bookmark-pressed': (GObject.SIGNAL_RUN_FIRST, None, ()) } # TODO: # fix rending link entities or something like that? def __init__(self, post): super(Gtk.ListBoxRow, self).__init__() if 'repost_of' in post: reposted_by = post.user post = post.repost_of else: reposted_by = None self.post = post # Avatar #self.avatar = Handy.Avatar(size=48, text=post.user.username, show_initials=True) #self.avatar.set_image_load_func(self.get_avatar, post.user.content.avatar_image) self.avatar = Gtk.Image.new_from_pixbuf(self.get_avatar(48, post.user.content.avatar_image)) # Source source_label = Gtk.Label(xalign=1) source_label.set_markup('via ' + post.source.name + '') self.username = Gtk.Label(label="@" + post.user.username, xalign=0) self.name = Gtk.Label(xalign=0) if 'name' in post.user: self.name.set_markup(f"{post.user.name}") # datetime now = datetime.datetime.now(tzlocal()) post_date_local = post.created_at.astimezone(tzlocal()) datetime_label = Gtk.Label(xalign=1) datetime_label.set_markup('' + timeago.format(post_date_local, now) + '') #datetime_label.set_markup('' + post_date_local.strftime("%Y-%m-%d %H:%M") + '') # post menu self.menu_button = Gtk.Button.new_from_icon_name('view-more-symbolic', 1) self.menu_button.connect('clicked', self.show_menu) # post actions reply_button = Gtk.Button.new_from_icon_name('mail-reply-sender-symbolic', 1) reply_button.connect('clicked', self.reply) repost_button = Gtk.Button.new_from_icon_name('media-playlist-repeat-symbolic', 1) repost_button.connect('clicked', self.repost) bookmark_button = Gtk.Button.new_from_icon_name('non-starred-symbolic', 1) bookmark_button.connect('clicked', self.bookmark) self.box = Gtk.Box(orientation='vertical') self.add(self.box) # name container self.name_box = Gtk.Box(orientation='vertical') self.name_box.pack_start(self.name, True, True, 0) self.name_box.pack_start(self.username, True, True, 0) # right side container self.r_box = Gtk.Box(orientation='vertical') self.rc_box = Gtk.Box(orientation='horizontal') self.r_box.pack_start(datetime_label, False, False, 0) self.r_box.pack_start(self.rc_box, False, False, 10) # header container self.h_box = Gtk.Box(orientation='horizontal') self.h_box.pack_start(self.avatar, False, False, 10) self.h_box.pack_start(self.name_box, False, False, 0) self.h_box.pack_end(self.r_box, False, False, 10) #self.h_box.pack_end(source_label, False, False, 10) #self.h_box.pack_end(datetime_label, False, False, 10) # content container self.c_box = Gtk.Box(orientation='vertical') self.t_box = Gtk.Box(orientation='horizontal') self.content = Gtk.Label(wrap=True, xalign=0) # TODO: parse content links if 'content' in post: self.content.set_text(post.content.text) self.t_box.pack_start(self.content, False, False, 10) self.c_box.pack_start(self.t_box, False, False, 10) if 'raw' in post: for raw in post.raw: # TODO: hide this under toggle? if raw.type == "nl.chimpnut.blog.post": longpost = raw.value if 'title' in longpost: lp_title = Gtk.Label(wrap=True) lp_title.set_markup(f"{longpost.title}") self.c_box.pack_start(lp_title, False, False, 10) lp_body = Gtk.Label(wrap=True) lp_body.set_text(longpost.body) self.c_box.pack_start(lp_body, False, False, 10) # TODO: open full photo in seperate window if raw.type == "io.pnut.core.oembed": oembed = raw.value if oembed.type == "photo": photo = Gtk.Image.new_from_pixbuf(self.get_oembed_thumb(oembed)) self.c_box.pack_end(photo, False, False, 10) if reposted_by is not None: reposted_label_text = f'reposted by @{reposted_by.username}' reposted_label = Gtk.Label(xalign=0) reposted_label.set_margin_left(10) reposted_label.set_margin_top(5) reposted_label.set_markup(reposted_label_text) self.c_box.pack_end(reposted_label, False, False, 0) # footer container self.f_box = Gtk.Box(orientation='horizontal') self.f_box.pack_end(self.menu_button, False, False, 5) self.f_box.pack_end(repost_button, False, False, 5) self.f_box.pack_end(bookmark_button, False, False, 5) self.f_box.pack_end(reply_button, False, False, 5) #self.f_box.pack_start(datetime_label, False, False, 5) self.f_box.pack_start(source_label, False, False, 10) # counters #pad = Gtk.Label(label="") #self.rc_box.pack_end(pad, False, False, 5) if post.id != int(post.thread_id): thread_icon = Gtk.Image.new_from_icon_name("user-available-symbolic", Gtk.IconSize.SMALL_TOOLBAR) self.rc_box.pack_end(thread_icon, False, False, 5) if post.counts.bookmarks > 0: if post.you_bookmarked: star_icon = Gtk.Image.new_from_icon_name("starred-symbolic", Gtk.IconSize.SMALL_TOOLBAR) else: star_icon = Gtk.Image.new_from_icon_name("non-starred-symbolic", Gtk.IconSize.SMALL_TOOLBAR) star_count = Gtk.Label(label=post.counts.bookmarks) self.rc_box.pack_end(star_count, False, False, 0) self.rc_box.pack_end(star_icon, False, False, 5) if post.counts.reposts > 0: repost_icon = Gtk.Image.new_from_icon_name("media-playlist-repeat-symbolic", Gtk.IconSize.SMALL_TOOLBAR) repost_count = Gtk.Label(label=post.counts.reposts) self.rc_box.pack_end(repost_count, False, False, 0) self.rc_box.pack_end(repost_icon, False, False, 5) #postid = Gtk.Label(label=post.id) #self.f_box.pack_start(postid, False, False, 5) self.box.pack_start(self.h_box, True, True, 10) self.box.pack_start(self.c_box, True, True, 10) self.box.pack_start(self.f_box, True, True, 10) def get_avatar(self, size, avatar): # TODO: age out cache cache_dir = os.path.join(GLib.get_user_cache_dir(), 'avatars') link_hash = avatar.link.rsplit('/', 1)[-1] file_hash = os.path.join(cache_dir, link_hash) if not os.path.exists(cache_dir): os.mkdir(cache_dir) if os.path.exists(file_hash): scaled_img = GdkPixbuf.Pixbuf.new_from_file(file_hash) else: r = requests.get(avatar.link, stream=True) loader = GdkPixbuf.PixbufLoader() loader.write(r.content) loader.close() pixbuf = loader.get_pixbuf() old_width = avatar.width old_height = avatar.height ratio = old_width / old_height new_height = size / ratio scaled_img = pixbuf.scale_simple(size,new_height,GdkPixbuf.InterpType.BILINEAR) scaled_img.savev(file_hash, 'jpeg', ["quality"], ["100"]) return scaled_img def get_oembed_thumb(self, oembed): if 'thumbnail_url' in oembed: url = oembed.thumbnail_url old_width = oembed.thumbnail_width old_height = oembed.thumbnail_height else: url = oembed.url old_width = oembed.width old_height = oembed.height ratio = old_width / old_height new_height = 256 / ratio r = requests.get(url) loader = GdkPixbuf.PixbufLoader() loader.write(r.content) loader.close() pixbuf = loader.get_pixbuf() return pixbuf.scale_simple(256,new_height,GdkPixbuf.InterpType.BILINEAR) def show_menu(self, widget): self.emit('menu-pressed', self.get_index()) def reply(self, widget): self.emit('reply-pressed') def repost(self, widget): self.emit('repost-pressed') def bookmark(self, widget): self.emit('bookmark-pressed')