mirror of
https://github.com/diamondburned/cchat-gtk.git
synced 2024-09-16 15:09:10 +00:00
Added attachments support
This commit is contained in:
parent
3f06e53e1d
commit
e35837ee2b
17
PLAN.md
Normal file
17
PLAN.md
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
## Sidebar refactoring
|
||||||
|
|
||||||
|
Maybe put services separately in the left sidebar like so:
|
||||||
|
|
||||||
|
![](https://miro.medium.com/max/1600/1*DSH66RN5DA5UQdZ2xE2I-g.png)
|
||||||
|
|
||||||
|
## Behavioral changes
|
||||||
|
|
||||||
|
Top-level server loads can probably lazy-load, but independent servers can
|
||||||
|
probably be all loaded at once. This might not be a good idea for guild folders.
|
||||||
|
|
||||||
|
cchat-gtk should also store what's expanded into a config. This is pretty
|
||||||
|
trivial to do.
|
||||||
|
|
||||||
|
## Spellcheck
|
||||||
|
|
||||||
|
Write a Golang gspell binding and use that.
|
9
go.mod
9
go.mod
|
@ -4,13 +4,16 @@ go 1.14
|
||||||
|
|
||||||
replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d
|
replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d
|
||||||
|
|
||||||
|
replace github.com/diamondburned/cchat-discord => ../cchat-discord/
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Xuanwo/go-locale v0.2.0
|
github.com/Xuanwo/go-locale v0.2.0
|
||||||
github.com/alecthomas/chroma v0.7.3
|
github.com/alecthomas/chroma v0.7.3
|
||||||
github.com/diamondburned/cchat v0.0.42
|
github.com/diamondburned/cchat v0.0.43
|
||||||
github.com/diamondburned/cchat-discord v0.0.0-20200709041349-1e137df6de2c
|
github.com/diamondburned/cchat-discord v0.0.0-20200709041349-1e137df6de2c
|
||||||
github.com/diamondburned/cchat-mock v0.0.0-20200704044009-f587c4904aa3
|
github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b
|
||||||
github.com/diamondburned/imgutil v0.0.0-20200708012333-53c9e45dd28b
|
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972
|
||||||
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/goodsign/monday v1.0.0
|
github.com/goodsign/monday v1.0.0
|
||||||
github.com/gotk3/gotk3 v0.4.1-0.20200524052254-cb2aa31c6194
|
github.com/gotk3/gotk3 v0.4.1-0.20200524052254-cb2aa31c6194
|
||||||
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79
|
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79
|
||||||
|
|
6
go.sum
6
go.sum
|
@ -54,6 +54,8 @@ github.com/diamondburned/cchat v0.0.41 h1:6y32s2wWTiDw4hWN/Gna6ay3uUrRAW5V8Cj0/x
|
||||||
github.com/diamondburned/cchat v0.0.41/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
|
github.com/diamondburned/cchat v0.0.41/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
|
||||||
github.com/diamondburned/cchat v0.0.42 h1:FVMLy9hOTxKju8OWDBIStrekbgTHCaH8+GVnV4LOByg=
|
github.com/diamondburned/cchat v0.0.42 h1:FVMLy9hOTxKju8OWDBIStrekbgTHCaH8+GVnV4LOByg=
|
||||||
github.com/diamondburned/cchat v0.0.42/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
|
github.com/diamondburned/cchat v0.0.42/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
|
||||||
|
github.com/diamondburned/cchat v0.0.43 h1:HetAujSaUSdnQgAUZgprNLARjf/MSWXpCfWdvX2wOCU=
|
||||||
|
github.com/diamondburned/cchat v0.0.43/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
|
||||||
github.com/diamondburned/cchat-discord v0.0.0-20200703190659-fbf95b9b6c03 h1:F5TL7GPRU/D4ldVkS0haY3SiHPtf1Kby/4nbYpm//MQ=
|
github.com/diamondburned/cchat-discord v0.0.0-20200703190659-fbf95b9b6c03 h1:F5TL7GPRU/D4ldVkS0haY3SiHPtf1Kby/4nbYpm//MQ=
|
||||||
github.com/diamondburned/cchat-discord v0.0.0-20200703190659-fbf95b9b6c03/go.mod h1:p0X6QUH0mxK8yEW0+a4QA77ClAmoxz8CvgbnobMtWQA=
|
github.com/diamondburned/cchat-discord v0.0.0-20200703190659-fbf95b9b6c03/go.mod h1:p0X6QUH0mxK8yEW0+a4QA77ClAmoxz8CvgbnobMtWQA=
|
||||||
github.com/diamondburned/cchat-discord v0.0.0-20200708083530-d0e43cc63b03 h1:Xx4ioFTurT6qTxzTL8QlsH3E5VskLxHPJ8RwmaKhObA=
|
github.com/diamondburned/cchat-discord v0.0.0-20200708083530-d0e43cc63b03 h1:Xx4ioFTurT6qTxzTL8QlsH3E5VskLxHPJ8RwmaKhObA=
|
||||||
|
@ -66,12 +68,16 @@ github.com/diamondburned/cchat-discord v0.0.0-20200709041349-1e137df6de2c h1:4F7
|
||||||
github.com/diamondburned/cchat-discord v0.0.0-20200709041349-1e137df6de2c/go.mod h1:QHPtnxNrnMFCYB/b9kUP93D30Kf3AuGmkM91tScIpB8=
|
github.com/diamondburned/cchat-discord v0.0.0-20200709041349-1e137df6de2c/go.mod h1:QHPtnxNrnMFCYB/b9kUP93D30Kf3AuGmkM91tScIpB8=
|
||||||
github.com/diamondburned/cchat-mock v0.0.0-20200704044009-f587c4904aa3 h1:xr07/2cwINyrMqh92pQQJVDfQqG0u6gHAK+ZcGfpSew=
|
github.com/diamondburned/cchat-mock v0.0.0-20200704044009-f587c4904aa3 h1:xr07/2cwINyrMqh92pQQJVDfQqG0u6gHAK+ZcGfpSew=
|
||||||
github.com/diamondburned/cchat-mock v0.0.0-20200704044009-f587c4904aa3/go.mod h1:SRu3OOeggELFr2Wd3/+SpYV1eNcvSk2LBhM70NOZSG8=
|
github.com/diamondburned/cchat-mock v0.0.0-20200704044009-f587c4904aa3/go.mod h1:SRu3OOeggELFr2Wd3/+SpYV1eNcvSk2LBhM70NOZSG8=
|
||||||
|
github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b h1:sq0MXjJc3yAOZvuolRxOpKQNvpMLyTmsECxQqdYgF5E=
|
||||||
|
github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b/go.mod h1:+bAf0m2o5qH54DmYJ/lR1HeITV53ol0JaoKyFFx3m3E=
|
||||||
github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d h1:Ha/I6PMKi+B4hpWclwlXj0tUMehR7Q0TNxPczzBwzPI=
|
github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d h1:Ha/I6PMKi+B4hpWclwlXj0tUMehR7Q0TNxPczzBwzPI=
|
||||||
github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
|
github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
|
||||||
github.com/diamondburned/imgutil v0.0.0-20200704034004-40dbfc732516 h1:6j4oZahbNdVhSEInRfeYbgDpx1FXDfJy6CcUVyWOuVY=
|
github.com/diamondburned/imgutil v0.0.0-20200704034004-40dbfc732516 h1:6j4oZahbNdVhSEInRfeYbgDpx1FXDfJy6CcUVyWOuVY=
|
||||||
github.com/diamondburned/imgutil v0.0.0-20200704034004-40dbfc732516/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ=
|
github.com/diamondburned/imgutil v0.0.0-20200704034004-40dbfc732516/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ=
|
||||||
github.com/diamondburned/imgutil v0.0.0-20200708012333-53c9e45dd28b h1:iYKHGvWzNFBIRTSY8Pd5g301YDGWMfs3fh1VS0iBSj0=
|
github.com/diamondburned/imgutil v0.0.0-20200708012333-53c9e45dd28b h1:iYKHGvWzNFBIRTSY8Pd5g301YDGWMfs3fh1VS0iBSj0=
|
||||||
github.com/diamondburned/imgutil v0.0.0-20200708012333-53c9e45dd28b/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ=
|
github.com/diamondburned/imgutil v0.0.0-20200708012333-53c9e45dd28b/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ=
|
||||||
|
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972 h1:OWxllHbUptXzDias6YI4MM0R3o50q8MfhkkwVIlfiNo=
|
||||||
|
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ=
|
||||||
github.com/diamondburned/ningen v0.1.1-0.20200621014632-6babb812b249 h1:yP7kJ+xCGpDz6XbcfACJcju4SH1XDPwlrvbofz3lP8I=
|
github.com/diamondburned/ningen v0.1.1-0.20200621014632-6babb812b249 h1:yP7kJ+xCGpDz6XbcfACJcju4SH1XDPwlrvbofz3lP8I=
|
||||||
github.com/diamondburned/ningen v0.1.1-0.20200621014632-6babb812b249/go.mod h1:xW9hpBZsGi8KpAh10TyP+YQlYBo+Xc+2w4TR6N0951A=
|
github.com/diamondburned/ningen v0.1.1-0.20200621014632-6babb812b249/go.mod h1:xW9hpBZsGi8KpAh10TyP+YQlYBo+Xc+2w4TR6N0951A=
|
||||||
github.com/diamondburned/ningen v0.1.1-0.20200708090333-227e90d19851 h1:xf1aLPnwK/Yn2z7dBIgQROSVOEc2wtivgnnwBItdEVM=
|
github.com/diamondburned/ningen v0.1.1-0.20200708090333-227e90d19851 h1:xf1aLPnwK/Yn2z7dBIgQROSVOEc2wtivgnnwBItdEVM=
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
package gts
|
package gts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/diamondburned/cchat-gtk/internal/log"
|
"github.com/diamondburned/cchat-gtk/internal/log"
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
"github.com/gotk3/gotk3/gdk"
|
"github.com/gotk3/gotk3/gdk"
|
||||||
"github.com/gotk3/gotk3/glib"
|
"github.com/gotk3/gotk3/glib"
|
||||||
"github.com/gotk3/gotk3/gtk"
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
@ -21,6 +24,9 @@ var App struct {
|
||||||
Header *gtk.HeaderBar
|
Header *gtk.HeaderBar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clipboard is initialized on init().
|
||||||
|
var Clipboard *gtk.Clipboard
|
||||||
|
|
||||||
// NewModalDialog returns a new modal dialog that's transient for the main
|
// NewModalDialog returns a new modal dialog that's transient for the main
|
||||||
// window.
|
// window.
|
||||||
func NewModalDialog() (*gtk.Dialog, error) {
|
func NewModalDialog() (*gtk.Dialog, error) {
|
||||||
|
@ -56,15 +62,19 @@ func AddAppAction(name string, call func()) {
|
||||||
App.AddAction(action)
|
App.AddAction(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddWindowAction(name string, call func()) {
|
// Commented because this is not a good function to use. Components should use
|
||||||
action := glib.SimpleActionNew(name, nil)
|
// AddAppAction instead.
|
||||||
action.Connect("activate", call)
|
|
||||||
App.Window.AddAction(action)
|
// func AddWindowAction(name string, call func()) {
|
||||||
}
|
// action := glib.SimpleActionNew(name, nil)
|
||||||
|
// action.Connect("activate", call)
|
||||||
|
// App.Window.AddAction(action)
|
||||||
|
// }
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
gtk.Init(&Args)
|
gtk.Init(&Args)
|
||||||
App.Application, _ = gtk.ApplicationNew(AppID, 0)
|
App.Application, _ = gtk.ApplicationNew(AppID, 0)
|
||||||
|
Clipboard, _ = gtk.ClipboardGet(gdk.SELECTION_CLIPBOARD)
|
||||||
}
|
}
|
||||||
|
|
||||||
type WindowHeaderer interface {
|
type WindowHeaderer interface {
|
||||||
|
@ -176,3 +186,81 @@ func EventIsRightClick(ev *gdk.Event) bool {
|
||||||
keyev := gdk.EventButtonNewFromEvent(ev)
|
keyev := gdk.EventButtonNewFromEvent(ev)
|
||||||
return keyev.Type() == gdk.EVENT_BUTTON_PRESS && keyev.Button() == gdk.BUTTON_SECONDARY
|
return keyev.Type() == gdk.EVENT_BUTTON_PRESS && keyev.Button() == gdk.BUTTON_SECONDARY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RenderPixbuf(img image.Image) *gdk.Pixbuf {
|
||||||
|
var nrgba *image.NRGBA
|
||||||
|
if n, ok := img.(*image.NRGBA); ok {
|
||||||
|
nrgba = n
|
||||||
|
} else {
|
||||||
|
nrgba = imaging.Clone(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
pix, err := gdk.PixbufNewFromData(
|
||||||
|
nrgba.Pix, gdk.COLORSPACE_RGB,
|
||||||
|
true, // NRGBA has alpha.
|
||||||
|
8, // 8-bit aka 1-byte per sample.
|
||||||
|
nrgba.Rect.Dx(),
|
||||||
|
nrgba.Rect.Dy(), // We already know the image size.
|
||||||
|
nrgba.Stride,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to create pixbuf from *NRGBA: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return pix
|
||||||
|
}
|
||||||
|
|
||||||
|
func SpawnUploader(dirpath string, callback func(absolutePaths []string)) {
|
||||||
|
dialog, _ := gtk.FileChooserDialogNewWith2Buttons(
|
||||||
|
"Upload File", App.Window,
|
||||||
|
gtk.FILE_CHOOSER_ACTION_OPEN,
|
||||||
|
"Cancel", gtk.RESPONSE_CANCEL,
|
||||||
|
"Upload", gtk.RESPONSE_ACCEPT,
|
||||||
|
)
|
||||||
|
|
||||||
|
BindPreviewer(dialog)
|
||||||
|
|
||||||
|
if dirpath == "" {
|
||||||
|
p, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
p = glib.GetUserDataDir()
|
||||||
|
}
|
||||||
|
dirpath = p
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.SetLocalOnly(false)
|
||||||
|
dialog.SetCurrentFolder(dirpath)
|
||||||
|
dialog.SetSelectMultiple(true)
|
||||||
|
|
||||||
|
defer dialog.Close()
|
||||||
|
|
||||||
|
if res := dialog.Run(); res != gtk.RESPONSE_ACCEPT {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
names, _ := dialog.GetFilenames()
|
||||||
|
callback(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindPreviewer binds the file chooser dialog with a previewer.
|
||||||
|
func BindPreviewer(fc *gtk.FileChooserDialog) {
|
||||||
|
img, _ := gtk.ImageNew()
|
||||||
|
|
||||||
|
fc.SetPreviewWidget(img)
|
||||||
|
fc.Connect("update-preview",
|
||||||
|
func(fc *gtk.FileChooserDialog, img *gtk.Image) {
|
||||||
|
file := fc.GetPreviewFilename()
|
||||||
|
|
||||||
|
b, err := gdk.PixbufNewFromFileAtScale(file, 256, 256, true)
|
||||||
|
if err != nil {
|
||||||
|
fc.SetPreviewWidgetActive(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
img.SetFromPixbuf(b)
|
||||||
|
fc.SetPreviewWidgetActive(true)
|
||||||
|
},
|
||||||
|
img,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -15,15 +15,11 @@ import (
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
var dskcached *http.Client
|
var basePath = filepath.Join(os.TempDir(), "cchat-gtk-sabotaging-the-desktop-experience")
|
||||||
|
|
||||||
func init() {
|
var dskcached = http.Client{
|
||||||
var basePath = filepath.Join(os.TempDir(), "cchat-gtk-pridemonth")
|
Timeout: 15 * time.Second,
|
||||||
|
Transport: httpcache.NewTransport(
|
||||||
http.DefaultClient.Timeout = 15 * time.Second
|
|
||||||
|
|
||||||
dskcached = &(*http.DefaultClient)
|
|
||||||
dskcached.Transport = httpcache.NewTransport(
|
|
||||||
diskcache.NewWithDiskv(diskv.New(diskv.Options{
|
diskcache.NewWithDiskv(diskv.New(diskv.Options{
|
||||||
BasePath: basePath,
|
BasePath: basePath,
|
||||||
TempDir: filepath.Join(basePath, "tmp"),
|
TempDir: filepath.Join(basePath, "tmp"),
|
||||||
|
@ -32,11 +28,7 @@ func init() {
|
||||||
Compression: diskv.NewZlibCompressionLevel(2),
|
Compression: diskv.NewZlibCompressionLevel(2),
|
||||||
CacheSizeMax: 25 * 1024 * 1024, // 25 MiB in memory
|
CacheSizeMax: 25 * 1024 * 1024, // 25 MiB in memory
|
||||||
})),
|
})),
|
||||||
)
|
),
|
||||||
}
|
|
||||||
|
|
||||||
func secs(dura time.Duration) int64 {
|
|
||||||
return int64(dura / time.Second)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func AsyncStreamUncached(url string, fn func(r io.Reader)) {
|
func AsyncStreamUncached(url string, fn func(r io.Reader)) {
|
||||||
|
|
|
@ -10,7 +10,6 @@ import (
|
||||||
"github.com/diamondburned/imgutil"
|
"github.com/diamondburned/imgutil"
|
||||||
"github.com/gotk3/gotk3/gdk"
|
"github.com/gotk3/gotk3/gdk"
|
||||||
"github.com/gotk3/gotk3/glib"
|
"github.com/gotk3/gotk3/glib"
|
||||||
"github.com/gotk3/gotk3/gtk"
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -18,9 +17,6 @@ type ImageContainer interface {
|
||||||
SetFromPixbuf(*gdk.Pixbuf)
|
SetFromPixbuf(*gdk.Pixbuf)
|
||||||
SetFromAnimation(*gdk.PixbufAnimation)
|
SetFromAnimation(*gdk.PixbufAnimation)
|
||||||
Connect(string, interface{}, ...interface{}) (glib.SignalHandle, error)
|
Connect(string, interface{}, ...interface{}) (glib.SignalHandle, error)
|
||||||
|
|
||||||
// for internal use
|
|
||||||
pbgetter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImageContainerSizer interface {
|
type ImageContainerSizer interface {
|
||||||
|
@ -83,14 +79,6 @@ func AsyncImageSized(img ImageContainerSizer, url string, w, h int, procs ...img
|
||||||
go syncImage(ctx, l, url, procs, gif)
|
go syncImage(ctx, l, url, procs, gif)
|
||||||
}
|
}
|
||||||
|
|
||||||
type pbgetter interface {
|
|
||||||
GetPixbuf() *gdk.Pixbuf
|
|
||||||
GetAnimation() *gdk.PixbufAnimation
|
|
||||||
GetStorageType() gtk.ImageType
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ pbgetter = (*gtk.Image)(nil)
|
|
||||||
|
|
||||||
func connectDestroyer(img ImageContainer, cancel func()) {
|
func connectDestroyer(img ImageContainer, cancel func()) {
|
||||||
img.Connect("destroy", func(img ImageContainer) {
|
img.Connect("destroy", func(img ImageContainer) {
|
||||||
cancel()
|
cancel()
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package humanize
|
package humanize
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/goodsign/monday"
|
"github.com/goodsign/monday"
|
||||||
|
@ -61,3 +62,9 @@ func timeAgo(t time.Time, truncs []truncator) string {
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error returns a short error string.
|
||||||
|
func Error(err error) string {
|
||||||
|
parts := strings.Split(err.Error(), ":")
|
||||||
|
return strings.TrimSpace(parts[len(parts)-1])
|
||||||
|
}
|
||||||
|
|
|
@ -6,22 +6,27 @@ import (
|
||||||
"github.com/gotk3/gotk3/gtk"
|
"github.com/gotk3/gotk3/gtk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ShowModal(body gtk.IWidget, title, button string, callback func()) {
|
type Modal struct {
|
||||||
NewModal(body, title, title, callback).Show()
|
*gtk.Dialog
|
||||||
|
Cancel *gtk.Button
|
||||||
|
Action *gtk.Button
|
||||||
|
Header *gtk.HeaderBar
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModal(body gtk.IWidget, title, button string, callback func()) *gtk.Dialog {
|
func ShowModal(body gtk.IWidget, title, button string, clicked func(m *Modal)) {
|
||||||
cancel, _ := gtk.ButtonNew()
|
NewModal(body, title, title, clicked).Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModal(body gtk.IWidget, title, button string, clicked func(m *Modal)) *Modal {
|
||||||
|
cancel, _ := gtk.ButtonNewWithMnemonic("_Cancel")
|
||||||
cancel.Show()
|
cancel.Show()
|
||||||
cancel.SetHAlign(gtk.ALIGN_START)
|
cancel.SetHAlign(gtk.ALIGN_START)
|
||||||
cancel.SetRelief(gtk.RELIEF_NONE)
|
cancel.SetRelief(gtk.RELIEF_NONE)
|
||||||
cancel.SetLabel("Cancel")
|
|
||||||
|
|
||||||
action, _ := gtk.ButtonNew()
|
action, _ := gtk.ButtonNewWithMnemonic(button)
|
||||||
action.Show()
|
action.Show()
|
||||||
action.SetHAlign(gtk.ALIGN_END)
|
action.SetHAlign(gtk.ALIGN_END)
|
||||||
action.SetRelief(gtk.RELIEF_NONE)
|
action.SetRelief(gtk.RELIEF_NONE)
|
||||||
action.SetLabel(button)
|
|
||||||
|
|
||||||
header, _ := gtk.HeaderBarNew()
|
header, _ := gtk.HeaderBarNew()
|
||||||
header.Show()
|
header.Show()
|
||||||
|
@ -32,11 +37,17 @@ func NewModal(body gtk.IWidget, title, button string, callback func()) *gtk.Dial
|
||||||
header.PackEnd(action)
|
header.PackEnd(action)
|
||||||
|
|
||||||
dialog := newCSD(body, header)
|
dialog := newCSD(body, header)
|
||||||
|
modald := &Modal{
|
||||||
|
dialog,
|
||||||
|
cancel,
|
||||||
|
action,
|
||||||
|
header,
|
||||||
|
}
|
||||||
|
|
||||||
cancel.Connect("clicked", dialog.Destroy)
|
cancel.Connect("clicked", dialog.Destroy)
|
||||||
action.Connect("clicked", callback)
|
action.Connect("clicked", func() { clicked(modald) })
|
||||||
|
|
||||||
return dialog
|
return modald
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCSD(body, header gtk.IWidget) *gtk.Dialog {
|
func NewCSD(body, header gtk.IWidget) *gtk.Dialog {
|
||||||
|
|
|
@ -108,26 +108,28 @@ func PromptOpen(uri string) {
|
||||||
// Style the label.
|
// Style the label.
|
||||||
primitives.AttachCSS(l, warnLabelCSS)
|
primitives.AttachCSS(l, warnLabelCSS)
|
||||||
|
|
||||||
open := func() {
|
open := func(m *dialog.Modal) {
|
||||||
|
// Close the dialog.
|
||||||
|
m.Destroy()
|
||||||
|
// Open the link.
|
||||||
if err := open.Start(uri); err != nil {
|
if err := open.Start(uri); err != nil {
|
||||||
log.Error(errors.Wrap(err, "Failed to open URL after confirm"))
|
log.Error(errors.Wrap(err, "Failed to open URL after confirm"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prompt the user if they want to open the URL.
|
// Prompt the user if they want to open the URL.
|
||||||
dlg := dialog.NewModal(l, "Caution", "Open", open)
|
dlg := dialog.NewModal(l, "Caution", "_Open", open)
|
||||||
dlg.SetSizeRequest(350, 100)
|
dlg.SetSizeRequest(350, 100)
|
||||||
|
|
||||||
|
// Style the button to have a color.
|
||||||
|
primitives.SuggestAction(dlg.Action)
|
||||||
|
|
||||||
// Add a class to the dialog to allow theming.
|
// Add a class to the dialog to allow theming.
|
||||||
primitives.AddClass(dlg, "url-warning")
|
primitives.AddClass(dlg, "url-warning")
|
||||||
|
|
||||||
// On link click, close the dialog.
|
// On link click, close the dialog, open the link ourselves, then return.
|
||||||
l.Connect("activate-link", func(l *gtk.Label, uri string) bool {
|
l.Connect("activate-link", func(l *gtk.Label, uri string) bool {
|
||||||
// Close the dialog.
|
open(dlg)
|
||||||
dlg.Destroy()
|
|
||||||
// Open the link anyway.
|
|
||||||
open()
|
|
||||||
// Return true since we handled the event.
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container"
|
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
|
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/messages/message"
|
"github.com/diamondburned/cchat-gtk/internal/ui/messages/message"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
|
||||||
"github.com/gotk3/gotk3/gtk"
|
"github.com/gotk3/gotk3/gtk"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -33,10 +32,6 @@ func NewMessage(msg cchat.MessageCreate) Message {
|
||||||
msgc := message.NewContainer(msg)
|
msgc := message.NewContainer(msg)
|
||||||
message.FillContainer(msgc, msg)
|
message.FillContainer(msgc, msg)
|
||||||
|
|
||||||
primitives.AddClass(msgc.Timestamp, "compact-timestamp")
|
|
||||||
primitives.AddClass(msgc.Username, "compact-username")
|
|
||||||
primitives.AddClass(msgc.Content, "compact-content")
|
|
||||||
|
|
||||||
return Message{msgc}
|
return Message{msgc}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -32,7 +32,7 @@ func WrapCollapsedMessage(gc *message.GenericContainer) *CollapsedMessage {
|
||||||
gc.Timestamp.SetMarginStart(container.ColumnSpacing * 2)
|
gc.Timestamp.SetMarginStart(container.ColumnSpacing * 2)
|
||||||
|
|
||||||
// Set Content's padding accordingly to FullMessage's main box.
|
// Set Content's padding accordingly to FullMessage's main box.
|
||||||
gc.Content.SetMarginEnd(container.ColumnSpacing * 2)
|
gc.Content.ToWidget().SetMarginEnd(container.ColumnSpacing * 2)
|
||||||
|
|
||||||
return &CollapsedMessage{
|
return &CollapsedMessage{
|
||||||
GenericContainer: gc,
|
GenericContainer: gc,
|
||||||
|
|
345
internal/ui/messages/input/attachment/attachment.go
Normal file
345
internal/ui/messages/input/attachment/attachment.go
Normal file
|
@ -0,0 +1,345 @@
|
||||||
|
package attachment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/diamondburned/cchat"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/gts"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/log"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
"github.com/gotk3/gotk3/gdk"
|
||||||
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pngEncoder = png.Encoder{
|
||||||
|
CompressionLevel: png.BestCompression,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileIconSize = 72
|
||||||
|
|
||||||
|
// File represents a middle format that can be used to create a
|
||||||
|
// MessageAttachment.
|
||||||
|
type File struct {
|
||||||
|
Prog *Progress
|
||||||
|
Name string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFile creates a new attachment file with a progress state.
|
||||||
|
func NewFile(name string, size int64, open Open) File {
|
||||||
|
return File{
|
||||||
|
Prog: NewProgress(NewReusableReader(open), size),
|
||||||
|
Name: name,
|
||||||
|
Size: size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AsAttachment turns File into a MessageAttachment. This method will always
|
||||||
|
// make a new MessageAttachment and will never return an old one.
|
||||||
|
//
|
||||||
|
// The reason being MessageAttachment should never be reused, as it hides the
|
||||||
|
// fact that the io.Reader is reusable.
|
||||||
|
func (f *File) AsAttachment() cchat.MessageAttachment {
|
||||||
|
return cchat.MessageAttachment{
|
||||||
|
Name: f.Name,
|
||||||
|
Reader: f.Prog,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Container struct {
|
||||||
|
*gtk.Revealer
|
||||||
|
Scroll *gtk.ScrolledWindow
|
||||||
|
Box *gtk.Box
|
||||||
|
|
||||||
|
// states
|
||||||
|
files []File
|
||||||
|
items map[string]gtk.IWidget
|
||||||
|
}
|
||||||
|
|
||||||
|
var attachmentsCSS = primitives.PrepareCSS(`
|
||||||
|
.attachments { padding: 5px; padding-bottom: 0 }
|
||||||
|
`)
|
||||||
|
|
||||||
|
func New() *Container {
|
||||||
|
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
|
||||||
|
box.Show()
|
||||||
|
|
||||||
|
primitives.AddClass(box, "attachments")
|
||||||
|
primitives.AttachCSS(box, attachmentsCSS)
|
||||||
|
|
||||||
|
scr, _ := gtk.ScrolledWindowNew(nil, nil)
|
||||||
|
scr.SetPolicy(gtk.POLICY_EXTERNAL, gtk.POLICY_NEVER)
|
||||||
|
scr.SetProperty("kinetic-scrolling", false)
|
||||||
|
scr.Add(box)
|
||||||
|
scr.Show()
|
||||||
|
|
||||||
|
// Scroll left/right when the wheel goes up/down.
|
||||||
|
scr.Connect("scroll-event", func(s *gtk.ScrolledWindow, ev *gdk.Event) bool {
|
||||||
|
// Magic thing I found out while print-debugging. DeltaY shows the same
|
||||||
|
// offset whether you scroll with Shift or not, which makes sense.
|
||||||
|
// DeltaX is always 0.
|
||||||
|
|
||||||
|
var adj = scr.GetHAdjustment()
|
||||||
|
|
||||||
|
switch ev := gdk.EventScrollNewFromEvent(ev); ev.DeltaY() {
|
||||||
|
case 1:
|
||||||
|
adj.SetValue(adj.GetValue() + adj.GetStepIncrement())
|
||||||
|
case -1:
|
||||||
|
adj.SetValue(adj.GetValue() - adj.GetStepIncrement())
|
||||||
|
default:
|
||||||
|
// Not handled.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handled.
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
rev, _ := gtk.RevealerNew()
|
||||||
|
rev.SetRevealChild(false)
|
||||||
|
rev.Add(scr)
|
||||||
|
|
||||||
|
return &Container{
|
||||||
|
Revealer: rev,
|
||||||
|
Scroll: scr,
|
||||||
|
Box: box,
|
||||||
|
items: map[string]gtk.IWidget{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMarginStart sets the inner margin of the attachments carousel.
|
||||||
|
func (c *Container) SetMarginStart(margin int) {
|
||||||
|
c.Box.SetMarginStart(margin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files returns the list of attachments
|
||||||
|
func (c *Container) Files() []File {
|
||||||
|
return c.files
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset does NOT close files.
|
||||||
|
func (c *Container) Reset() {
|
||||||
|
// Reset states. We do not touch the old files slice, as other callers may
|
||||||
|
// be referencing and using it.
|
||||||
|
c.files = nil
|
||||||
|
|
||||||
|
// Clear all items.
|
||||||
|
for _, item := range c.items {
|
||||||
|
c.Box.Remove(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the map.
|
||||||
|
c.items = map[string]gtk.IWidget{}
|
||||||
|
|
||||||
|
// Hide the window.
|
||||||
|
c.SetRevealChild(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFiles is used for the file chooser's callback.
|
||||||
|
func (c *Container) AddFiles(paths []string) {
|
||||||
|
for _, path := range paths {
|
||||||
|
if err := c.AddFile(path); err != nil {
|
||||||
|
log.Error(errors.Wrap(err, "Failed to add file"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFile is used for the file picker.
|
||||||
|
func (c *Container) AddFile(path string) error {
|
||||||
|
// Check the file and get the size.
|
||||||
|
s, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Failed to stat file")
|
||||||
|
}
|
||||||
|
|
||||||
|
var filename = c.append(
|
||||||
|
filepath.Base(path), s.Size(),
|
||||||
|
func() (io.ReadCloser, error) { return os.Open(path) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Maybe try making a preview. A nil image is fine, so we can skip the error
|
||||||
|
// check.
|
||||||
|
// TODO: add a filesize check
|
||||||
|
i, _ := imaging.Open(path, imaging.AutoOrientation(true))
|
||||||
|
c.addPreview(filename, i)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPixbuf is used for adding pixbufs from the clipboard.
|
||||||
|
func (c *Container) AddPixbuf(pb *gdk.Pixbuf) error {
|
||||||
|
// Pixbuf's colorspace is only RGB. This is indicated with
|
||||||
|
// GDK_COLORSPACE_RGB.
|
||||||
|
if pb.GetColorspace() != gdk.COLORSPACE_RGB {
|
||||||
|
return errors.New("Pixbuf has unsupported colorspace")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert that the pixbuf has alpha, as we're using RGBA.
|
||||||
|
if !pb.GetHasAlpha() {
|
||||||
|
return errors.New("Pixbuf has no alpha channel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert that there are 4 channels: red, green, blue and alpha.
|
||||||
|
if pb.GetNChannels() != 4 {
|
||||||
|
return errors.New("Pixbuf has unexpected channel count")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert that there are 8 bits in a channel/sample.
|
||||||
|
if pb.GetBitsPerSample() != 8 {
|
||||||
|
return errors.New("Pixbuf has unexpected bits per sample")
|
||||||
|
}
|
||||||
|
|
||||||
|
var img = &image.NRGBA{
|
||||||
|
Pix: pb.GetPixels(),
|
||||||
|
Stride: pb.GetRowstride(),
|
||||||
|
Rect: image.Rect(0, 0, pb.GetWidth(), pb.GetHeight()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the image in memory.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
if err := pngEncoder.Encode(&buf, img); err != nil {
|
||||||
|
return errors.Wrap(err, "Failed to encode PNG")
|
||||||
|
}
|
||||||
|
|
||||||
|
var filename = c.append(
|
||||||
|
fmt.Sprintf("clipboard_%d.png", len(c.files)+1), int64(buf.Len()),
|
||||||
|
func() (io.ReadCloser, error) {
|
||||||
|
return ioutil.NopCloser(bytes.NewReader(buf.Bytes())), nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
c.addPreview(filename, img)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- internal methods --
|
||||||
|
|
||||||
|
// append guarantees there's no collision. It returns the unique filename.
|
||||||
|
func (c *Container) append(name string, sz int64, open Open) string {
|
||||||
|
// Show the preview window.
|
||||||
|
c.SetRevealChild(true)
|
||||||
|
|
||||||
|
// Guarantee that the filename will never collide.
|
||||||
|
for _, file := range c.files {
|
||||||
|
if file.Name == name {
|
||||||
|
// Hopefully this works? I'm not sure. But this will keep prepending
|
||||||
|
// an underscore.
|
||||||
|
name = "_" + name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.files = append(c.files, NewFile(name, sz, open))
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) remove(name string) {
|
||||||
|
for i, file := range c.files {
|
||||||
|
if file.Name == name {
|
||||||
|
c.files = append(c.files[:i], c.files[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if w, ok := c.items[name]; ok {
|
||||||
|
c.Box.Remove(w)
|
||||||
|
delete(c.items, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse the container if there's nothing.
|
||||||
|
if len(c.items) == 0 {
|
||||||
|
c.SetRevealChild(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewCSS = primitives.PrepareCSS(`
|
||||||
|
.attachment-preview {
|
||||||
|
background-color: alpha(@theme_fg_color, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
var deleteAttBtnCSS = primitives.PrepareCSS(`
|
||||||
|
.delete-attachment {
|
||||||
|
/* Remove styling from the Gtk themes */
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
/* Add our own styling */
|
||||||
|
border-radius: 999px 999px;
|
||||||
|
transition: linear 100ms all;
|
||||||
|
background-color: alpha(@theme_bg_color, 0.50);
|
||||||
|
}
|
||||||
|
.delete-attachment:hover {
|
||||||
|
background-color: alpha(red, 0.5);
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
func (c *Container) addPreview(name string, src image.Image) {
|
||||||
|
// Make a fallback image first.
|
||||||
|
gimg, _ := gtk.ImageNew()
|
||||||
|
primitives.SetImageIcon(gimg, "image-x-generic-symbolic", FileIconSize/3)
|
||||||
|
gimg.SetSizeRequest(FileIconSize, FileIconSize)
|
||||||
|
gimg.SetVAlign(gtk.ALIGN_CENTER)
|
||||||
|
gimg.SetHAlign(gtk.ALIGN_CENTER)
|
||||||
|
gimg.SetTooltipText(name)
|
||||||
|
gimg.Show()
|
||||||
|
primitives.AddClass(gimg, "attachment-preview")
|
||||||
|
primitives.AttachCSS(gimg, previewCSS)
|
||||||
|
|
||||||
|
// Determine if we could generate an image preview.
|
||||||
|
if src != nil {
|
||||||
|
// Get the minimum dimension.
|
||||||
|
var w, h = minsize(src.Bounds().Dx(), src.Bounds().Dy(), FileIconSize)
|
||||||
|
|
||||||
|
var img *image.NRGBA
|
||||||
|
// Downscale the image.
|
||||||
|
img = imaging.Resize(src, w, h, imaging.Lanczos)
|
||||||
|
|
||||||
|
// Crop to a square.
|
||||||
|
img = imaging.CropCenter(img, FileIconSize, FileIconSize)
|
||||||
|
|
||||||
|
// Copy the image to a pixbuf.
|
||||||
|
gimg.SetFromPixbuf(gts.RenderPixbuf(img))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BLOAT!!! Make an overlay of an event box that, when hovered, will show
|
||||||
|
// something that allows closing the image.
|
||||||
|
del, _ := gtk.ButtonNewFromIconName("window-close", gtk.ICON_SIZE_DIALOG)
|
||||||
|
del.SetVAlign(gtk.ALIGN_CENTER)
|
||||||
|
del.SetHAlign(gtk.ALIGN_CENTER)
|
||||||
|
del.SetTooltipText("Remove " + name)
|
||||||
|
del.Connect("clicked", func() { c.remove(name) })
|
||||||
|
del.Show()
|
||||||
|
primitives.AddClass(del, "delete-attachment")
|
||||||
|
primitives.AttachCSS(del, deleteAttBtnCSS)
|
||||||
|
|
||||||
|
ovl, _ := gtk.OverlayNew()
|
||||||
|
ovl.SetSizeRequest(FileIconSize, FileIconSize)
|
||||||
|
ovl.Add(gimg)
|
||||||
|
ovl.AddOverlay(del)
|
||||||
|
ovl.Show()
|
||||||
|
|
||||||
|
c.items[name] = ovl
|
||||||
|
c.Box.PackStart(ovl, false, false, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func minsize(w, h, maxsz int) (int, int) {
|
||||||
|
if w < h {
|
||||||
|
// return the scaled width as max
|
||||||
|
// h*max/w is the same as h/w*max but with more accuracy
|
||||||
|
return maxsz, h * maxsz / w
|
||||||
|
}
|
||||||
|
|
||||||
|
return w * maxsz / h, maxsz
|
||||||
|
}
|
116
internal/ui/messages/input/attachment/progress.go
Normal file
116
internal/ui/messages/input/attachment/progress.go
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
package attachment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/gts"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||||
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
"github.com/gotk3/gotk3/pango"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MessageUploader struct {
|
||||||
|
*gtk.Grid
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMessageUploader creates a new MessageUploader. It returns nil if there are
|
||||||
|
// no files.
|
||||||
|
func NewMessageUploader(files []File) *MessageUploader {
|
||||||
|
m := &MessageUploader{}
|
||||||
|
|
||||||
|
m.Grid, _ = gtk.GridNew()
|
||||||
|
m.Grid.SetHExpand(true)
|
||||||
|
m.Grid.SetColumnSpacing(4)
|
||||||
|
m.Grid.SetRowSpacing(2)
|
||||||
|
m.Grid.SetRowHomogeneous(true)
|
||||||
|
|
||||||
|
primitives.AddClass(m.Grid, "upload-progress")
|
||||||
|
|
||||||
|
for i, file := range files {
|
||||||
|
var pbar = NewProgressBar(file)
|
||||||
|
|
||||||
|
m.Grid.Attach(pbar.Name, 0, i, 1, 1)
|
||||||
|
m.Grid.Attach(pbar.PBar, 1, i, 1, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressBar struct {
|
||||||
|
PBar *gtk.ProgressBar
|
||||||
|
Name *gtk.Label
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProgressBar(file File) *ProgressBar {
|
||||||
|
bar, _ := gtk.ProgressBarNew()
|
||||||
|
bar.SetVAlign(gtk.ALIGN_CENTER)
|
||||||
|
bar.Show()
|
||||||
|
|
||||||
|
name, _ := gtk.LabelNew(file.Name)
|
||||||
|
name.SetMaxWidthChars(45)
|
||||||
|
name.SetSingleLineMode(true)
|
||||||
|
name.SetEllipsize(pango.ELLIPSIZE_MIDDLE)
|
||||||
|
name.SetXAlign(1)
|
||||||
|
name.Show()
|
||||||
|
|
||||||
|
// Override the upload read callback.
|
||||||
|
file.Prog.u = func(fraction float64) {
|
||||||
|
gts.ExecAsync(func() {
|
||||||
|
if fraction == -1 {
|
||||||
|
// Pulse the bar around, as we don't know the total bytes.
|
||||||
|
bar.Pulse()
|
||||||
|
} else {
|
||||||
|
// We know the progress, so use the percentage.
|
||||||
|
bar.SetFraction(fraction)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ProgressBar{bar, name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress wraps around a ReadCloser and implements a progress state for a
|
||||||
|
// reader.
|
||||||
|
type Progress struct {
|
||||||
|
u func(float64) // read callback, arg is percentage
|
||||||
|
r io.Reader
|
||||||
|
s float64 // total, const
|
||||||
|
n uint64 // cumulative
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProgress creates a new upload progress state.
|
||||||
|
func NewProgress(r io.Reader, size int64) *Progress {
|
||||||
|
return &Progress{
|
||||||
|
r: r,
|
||||||
|
s: float64(size),
|
||||||
|
n: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// frac returns the current percentage, or -1 is there is no total.
|
||||||
|
func (p *Progress) frac() float64 {
|
||||||
|
if p.s > 0 {
|
||||||
|
return float64(p.n) / p.s
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Progress) Read(b []byte) (int, error) {
|
||||||
|
// Read and cumulate total bytes read if there are no errors or if the error
|
||||||
|
// is not fatal (EOF).
|
||||||
|
n, err := p.r.Read(b)
|
||||||
|
if err == nil || errors.Is(err, io.EOF) {
|
||||||
|
p.n += uint64(n)
|
||||||
|
} else {
|
||||||
|
// If we have an unexpected error, then we should reset the bytes read
|
||||||
|
// to 0.
|
||||||
|
p.n = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.u != nil {
|
||||||
|
p.u(p.frac())
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
42
internal/ui/messages/input/attachment/reusable_reader.go
Normal file
42
internal/ui/messages/input/attachment/reusable_reader.go
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
package attachment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Open = func() (io.ReadCloser, error)
|
||||||
|
|
||||||
|
// ReusableReader provides an API which allows a reader to be used multiple
|
||||||
|
// times. It is NOT thread-safe to use.
|
||||||
|
type ReusableReader struct {
|
||||||
|
open func() (io.ReadCloser, error)
|
||||||
|
src io.ReadCloser
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ io.Reader = (*ReusableReader)(nil)
|
||||||
|
|
||||||
|
// NewReusableReader creates a new reader that is reusable after a read failure
|
||||||
|
// or a close. The given open() callback MUST be reproducible.
|
||||||
|
func NewReusableReader(open Open) *ReusableReader {
|
||||||
|
return &ReusableReader{open, nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ReusableReader) Read(b []byte) (int, error) {
|
||||||
|
if r.src == nil {
|
||||||
|
o, err := r.open()
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "Failed to open reader")
|
||||||
|
}
|
||||||
|
r.src = o
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := r.src.Read(b)
|
||||||
|
if err != nil { // err could be EOF or anything unexpected
|
||||||
|
r.src.Close()
|
||||||
|
r.src = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
|
@ -2,7 +2,9 @@ package input
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/diamondburned/cchat"
|
"github.com/diamondburned/cchat"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/gts"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/log"
|
"github.com/diamondburned/cchat-gtk/internal/log"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/attachment"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/completion"
|
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/completion"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/username"
|
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/username"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||||
|
@ -23,19 +25,35 @@ type InputView struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
var textCSS = primitives.PrepareCSS(`
|
var textCSS = primitives.PrepareCSS(`
|
||||||
|
.message-input {
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.message-input, .message-input * {
|
.message-input, .message-input * {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-input * {
|
.message-input * {
|
||||||
background-color: @theme_base_color;
|
background-color: @theme_base_color;
|
||||||
|
transition: linear 50ms background-color;
|
||||||
|
|
||||||
|
/* Legacy styling
|
||||||
border: 1px solid alpha(@theme_fg_color, 0.2);
|
border: 1px solid alpha(@theme_fg_color, 0.2);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: linear 50ms border-color;
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-input:focus * {
|
.message-input:focus * {
|
||||||
|
background-color: mix(
|
||||||
|
@theme_base_color,
|
||||||
|
@theme_selected_bg_color,
|
||||||
|
0.15
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Legacy styling
|
||||||
border-color: @theme_selected_bg_color;
|
border-color: @theme_selected_bg_color;
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
@ -74,12 +92,18 @@ func (v *InputView) SetSender(session cchat.Session, sender cchat.ServerMessageS
|
||||||
}
|
}
|
||||||
|
|
||||||
type Field struct {
|
type Field struct {
|
||||||
|
// Box contains the field box and the attachment container.
|
||||||
*gtk.Box
|
*gtk.Box
|
||||||
|
Attachments *attachment.Container
|
||||||
|
|
||||||
|
// FieldBox contains the username container and the input field. It spans
|
||||||
|
// horizontally.
|
||||||
|
FieldBox *gtk.Box
|
||||||
Username *username.Container
|
Username *username.Container
|
||||||
|
|
||||||
TextScroll *gtk.ScrolledWindow
|
TextScroll *gtk.ScrolledWindow
|
||||||
text *gtk.TextView
|
text *gtk.TextView // const
|
||||||
buffer *gtk.TextBuffer
|
buffer *gtk.TextBuffer // const
|
||||||
|
|
||||||
UserID string
|
UserID string
|
||||||
Sender cchat.ServerMessageSender
|
Sender cchat.ServerMessageSender
|
||||||
|
@ -87,60 +111,80 @@ type Field struct {
|
||||||
|
|
||||||
ctrl Controller
|
ctrl Controller
|
||||||
|
|
||||||
// editing state
|
// states
|
||||||
editingID string // never empty
|
editingID string // never empty
|
||||||
|
sendings []PresendMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
var scrollinputCSS = primitives.PrepareCSS(`
|
var inputFieldCSS = primitives.PrepareCSS(`
|
||||||
.scrolled-input {
|
.input-field { margin: 3px 5px }
|
||||||
margin: 5px;
|
|
||||||
}
|
|
||||||
`)
|
`)
|
||||||
|
|
||||||
func NewField(text *gtk.TextView, ctrl Controller) *Field {
|
func NewField(text *gtk.TextView, ctrl Controller) *Field {
|
||||||
username := username.NewContainer()
|
field := &Field{text: text, ctrl: ctrl}
|
||||||
username.Show()
|
field.buffer, _ = text.GetBuffer()
|
||||||
|
|
||||||
buf, _ := text.GetBuffer()
|
field.Username = username.NewContainer()
|
||||||
|
field.Username.Show()
|
||||||
|
|
||||||
sw := scrollinput.NewV(text, 150)
|
field.TextScroll = scrollinput.NewV(text, 150)
|
||||||
sw.Show()
|
field.TextScroll.Show()
|
||||||
|
primitives.AddClass(field.TextScroll, "scrolled-input")
|
||||||
|
|
||||||
primitives.AddClass(sw, "scrolled-input")
|
attach, _ := gtk.ButtonNewFromIconName("mail-attachment-symbolic", gtk.ICON_SIZE_BUTTON)
|
||||||
primitives.AttachCSS(sw, scrollinputCSS)
|
attach.SetRelief(gtk.RELIEF_NONE)
|
||||||
|
attach.Show()
|
||||||
|
primitives.AddClass(attach, "attach-button")
|
||||||
|
|
||||||
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
|
send, _ := gtk.ButtonNewFromIconName("mail-send-symbolic", gtk.ICON_SIZE_BUTTON)
|
||||||
box.PackStart(username, false, false, 0)
|
send.SetRelief(gtk.RELIEF_NONE)
|
||||||
box.PackStart(sw, true, true, 0)
|
send.Show()
|
||||||
box.Show()
|
primitives.AddClass(send, "send-button")
|
||||||
|
|
||||||
field := &Field{
|
// Keep this number the same as size-allocate below -------v
|
||||||
Box: box,
|
field.FieldBox, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
|
||||||
Username: username,
|
field.FieldBox.PackStart(field.Username, false, false, 0)
|
||||||
// typing: typing,
|
field.FieldBox.PackStart(attach, false, false, 0)
|
||||||
TextScroll: sw,
|
field.FieldBox.PackStart(field.TextScroll, true, true, 0)
|
||||||
text: text,
|
field.FieldBox.PackStart(send, false, false, 0)
|
||||||
buffer: buf,
|
field.FieldBox.Show()
|
||||||
ctrl: ctrl,
|
primitives.AddClass(field.FieldBox, "input-field")
|
||||||
}
|
primitives.AttachCSS(field.FieldBox, inputFieldCSS)
|
||||||
|
|
||||||
text.SetFocusHAdjustment(sw.GetHAdjustment())
|
field.Attachments = attachment.New()
|
||||||
text.SetFocusVAdjustment(sw.GetVAdjustment())
|
field.Attachments.Show()
|
||||||
|
|
||||||
|
field.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
|
||||||
|
field.Box.PackStart(field.Attachments, false, false, 0)
|
||||||
|
field.Box.PackStart(field.FieldBox, false, false, 0)
|
||||||
|
field.Box.Show()
|
||||||
|
|
||||||
|
text.SetFocusHAdjustment(field.TextScroll.GetHAdjustment())
|
||||||
|
text.SetFocusVAdjustment(field.TextScroll.GetVAdjustment())
|
||||||
|
// Bind text events.
|
||||||
text.Connect("key-press-event", field.keyDown)
|
text.Connect("key-press-event", field.keyDown)
|
||||||
|
// Bind the send button.
|
||||||
|
send.Connect("clicked", field.sendInput)
|
||||||
|
// Bind the attach button.
|
||||||
|
attach.Connect("clicked", func() { gts.SpawnUploader("", field.Attachments.AddFiles) })
|
||||||
|
|
||||||
// // Connect to the field's revealer. On resize, we want the autocompleter to
|
// Connect to the field's revealer. On resize, we want the attachments
|
||||||
// // have the right padding too.
|
// carousel to have the same padding too.
|
||||||
// f.username.Connect("size-allocate", func(w gtk.IWidget) {
|
field.Username.Connect("size-allocate", func(w gtk.IWidget) {
|
||||||
// // Set the autocompleter's left margin to be the same.
|
// Calculate the left width: from the left of the message box to the
|
||||||
// c.SetMarginStart(w.ToWidget().GetAllocatedWidth())
|
// right of the attach button, covering the username container.
|
||||||
// })
|
var leftWidth = 5*2 + attach.GetAllocatedWidth() + w.ToWidget().GetAllocatedWidth()
|
||||||
|
// Set the autocompleter's left margin to be the same.
|
||||||
|
field.Attachments.SetMarginStart(leftWidth)
|
||||||
|
})
|
||||||
|
|
||||||
return field
|
return field
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset prepares the field before SetSender() is called.
|
// Reset prepares the field before SetSender() is called.
|
||||||
func (f *Field) Reset() {
|
func (f *Field) Reset() {
|
||||||
// Paranoia.
|
// Paranoia. The View should already change to a different stack, but we're
|
||||||
|
// doing this just in case.
|
||||||
f.text.SetSensitive(false)
|
f.text.SetSensitive(false)
|
||||||
|
|
||||||
f.UserID = ""
|
f.UserID = ""
|
||||||
|
@ -149,7 +193,7 @@ func (f *Field) Reset() {
|
||||||
f.Username.Reset()
|
f.Username.Reset()
|
||||||
|
|
||||||
// reset the input
|
// reset the input
|
||||||
f.buffer.Delete(f.buffer.GetBounds())
|
f.clearText()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSender changes the sender of the input field. If nil, the input will be
|
// SetSender changes the sender of the input field. If nil, the input will be
|
||||||
|
@ -213,21 +257,10 @@ func (f *Field) StopEditing() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// yankText cuts the text from the input field and returns it.
|
// clearText resets the input field
|
||||||
func (f *Field) yankText() string {
|
|
||||||
start, end := f.buffer.GetBounds()
|
|
||||||
|
|
||||||
text, _ := f.buffer.GetText(start, end, false)
|
|
||||||
if text != "" {
|
|
||||||
f.buffer.Delete(start, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
// clearText wipes the input field
|
|
||||||
func (f *Field) clearText() {
|
func (f *Field) clearText() {
|
||||||
f.buffer.Delete(f.buffer.GetBounds())
|
f.buffer.Delete(f.buffer.GetBounds())
|
||||||
|
f.Attachments.Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// getText returns the text from the input, but it doesn't cut it.
|
// getText returns the text from the input, but it doesn't cut it.
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
package input
|
package input
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/gts"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/log"
|
||||||
"github.com/gotk3/gotk3/gdk"
|
"github.com/gotk3/gotk3/gdk"
|
||||||
"github.com/gotk3/gotk3/gtk"
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
const shiftMask = uint(gdk.SHIFT_MASK)
|
const shiftMask = uint(gdk.SHIFT_MASK)
|
||||||
|
@ -21,9 +24,9 @@ func convEvent(ev *gdk.Event) (key, mask uint) {
|
||||||
func (f *Field) keyDown(tv *gtk.TextView, ev *gdk.Event) bool {
|
func (f *Field) keyDown(tv *gtk.TextView, ev *gdk.Event) bool {
|
||||||
var key, mask = convEvent(ev)
|
var key, mask = convEvent(ev)
|
||||||
|
|
||||||
switch key {
|
switch {
|
||||||
// If Enter is pressed.
|
// If Enter is pressed.
|
||||||
case gdk.KEY_Return:
|
case key == gdk.KEY_Return:
|
||||||
// If Shift is being held, insert a new line.
|
// If Shift is being held, insert a new line.
|
||||||
if bithas(mask, shiftMask) {
|
if bithas(mask, shiftMask) {
|
||||||
f.buffer.InsertAtCursor("\n")
|
f.buffer.InsertAtCursor("\n")
|
||||||
|
@ -36,7 +39,7 @@ func (f *Field) keyDown(tv *gtk.TextView, ev *gdk.Event) bool {
|
||||||
|
|
||||||
// If Arrow Up is pressed, then we might want to edit the latest message if
|
// If Arrow Up is pressed, then we might want to edit the latest message if
|
||||||
// any.
|
// any.
|
||||||
case gdk.KEY_Up:
|
case key == gdk.KEY_Up:
|
||||||
// Do we have input? If we do, then we shouldn't touch it.
|
// Do we have input? If we do, then we shouldn't touch it.
|
||||||
if f.textLen() > 0 {
|
if f.textLen() > 0 {
|
||||||
return false
|
return false
|
||||||
|
@ -63,11 +66,33 @@ func (f *Field) keyDown(tv *gtk.TextView, ev *gdk.Event) bool {
|
||||||
return true
|
return true
|
||||||
|
|
||||||
// There are multiple things to do here when we press the Escape key.
|
// There are multiple things to do here when we press the Escape key.
|
||||||
case gdk.KEY_Escape:
|
case key == gdk.KEY_Escape:
|
||||||
// First, we'd want to cancel editing if we have one.
|
// First, we'd want to cancel editing if we have one.
|
||||||
if f.editingID != "" {
|
if f.editingID != "" {
|
||||||
return f.StopEditing() // always returns true
|
return f.StopEditing() // always returns true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Second... Nothing yet?
|
||||||
|
|
||||||
|
// Ctrl+V is paste.
|
||||||
|
case key == gdk.KEY_v && bithas(mask, cntrlMask):
|
||||||
|
// Is there an image in the clipboard?
|
||||||
|
if !gts.Clipboard.WaitIsImageAvailable() {
|
||||||
|
// No.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Yes.
|
||||||
|
|
||||||
|
p, err := gts.Clipboard.WaitForImage()
|
||||||
|
if err != nil {
|
||||||
|
log.Error(errors.Wrap(err, "Failed to get image from clipboard"))
|
||||||
|
return true // interrupt as technically valid
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Attachments.AddPixbuf(p); err != nil {
|
||||||
|
log.Error(errors.Wrap(err, "Failed to add image to attachment list"))
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passthrough.
|
// Passthrough.
|
||||||
|
|
|
@ -10,11 +10,13 @@ import (
|
||||||
"github.com/diamondburned/cchat"
|
"github.com/diamondburned/cchat"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/gts"
|
"github.com/diamondburned/cchat-gtk/internal/gts"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/log"
|
"github.com/diamondburned/cchat-gtk/internal/log"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/attachment"
|
||||||
"github.com/diamondburned/cchat/text"
|
"github.com/diamondburned/cchat/text"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/twmb/murmur3"
|
"github.com/twmb/murmur3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// globalID used for atomically generating nonces.
|
||||||
var globalID uint64
|
var globalID uint64
|
||||||
|
|
||||||
// generateNonce creates a nonce that should prevent collision. This function
|
// generateNonce creates a nonce that should prevent collision. This function
|
||||||
|
@ -38,10 +40,8 @@ func (f *Field) sendInput() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var text = f.yankText()
|
// Get the input text.
|
||||||
if text == "" {
|
var text = f.getText()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Are we editing anything?
|
// Are we editing anything?
|
||||||
if id := f.editingID; f.Editable(id) && id != "" {
|
if id := f.editingID; f.Editable(id) && id != "" {
|
||||||
|
@ -55,6 +55,14 @@ func (f *Field) sendInput() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the attachments.
|
||||||
|
var attachments = f.Attachments.Files()
|
||||||
|
|
||||||
|
// Don't send if the message is empty.
|
||||||
|
if text == "" && len(attachments) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
f.SendMessage(SendMessageData{
|
f.SendMessage(SendMessageData{
|
||||||
time: time.Now().UTC(),
|
time: time.Now().UTC(),
|
||||||
content: text,
|
content: text,
|
||||||
|
@ -62,7 +70,11 @@ func (f *Field) sendInput() {
|
||||||
authorID: f.UserID,
|
authorID: f.UserID,
|
||||||
authorURL: f.Username.GetIconURL(),
|
authorURL: f.Username.GetIconURL(),
|
||||||
nonce: f.generateNonce(),
|
nonce: f.generateNonce(),
|
||||||
|
files: attachments,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Clear the input field after sending.
|
||||||
|
f.clearText()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Field) SendMessage(data PresendMessage) {
|
func (f *Field) SendMessage(data PresendMessage) {
|
||||||
|
@ -84,16 +96,21 @@ type SendMessageData struct {
|
||||||
authorID string
|
authorID string
|
||||||
authorURL string // avatar
|
authorURL string // avatar
|
||||||
nonce string
|
nonce string
|
||||||
|
files []attachment.File
|
||||||
}
|
}
|
||||||
|
|
||||||
type PresendMessage interface {
|
type PresendMessage interface {
|
||||||
cchat.MessageHeader // returns nonce and time
|
cchat.MessageHeader // returns nonce and time
|
||||||
cchat.SendableMessage
|
cchat.SendableMessage
|
||||||
cchat.MessageNonce
|
cchat.MessageNonce
|
||||||
|
cchat.SendableMessageAttachments
|
||||||
|
|
||||||
|
// These methods are reserved for internal use.
|
||||||
|
|
||||||
Author() text.Rich
|
Author() text.Rich
|
||||||
AuthorID() string
|
AuthorID() string
|
||||||
AuthorAvatarURL() string // may be empty
|
AuthorAvatarURL() string // may be empty
|
||||||
|
Files() []attachment.File
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ PresendMessage = (*SendMessageData)(nil)
|
var _ PresendMessage = (*SendMessageData)(nil)
|
||||||
|
@ -126,3 +143,15 @@ func (s SendMessageData) AuthorAvatarURL() string {
|
||||||
func (s SendMessageData) Nonce() string {
|
func (s SendMessageData) Nonce() string {
|
||||||
return s.nonce
|
return s.nonce
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s SendMessageData) Files() []attachment.File {
|
||||||
|
return s.files
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SendMessageData) Attachments() []cchat.MessageAttachment {
|
||||||
|
var attachments = make([]cchat.MessageAttachment, len(s.files))
|
||||||
|
for i, file := range s.files {
|
||||||
|
attachments[i] = file.AsAttachment()
|
||||||
|
}
|
||||||
|
return attachments
|
||||||
|
}
|
|
@ -37,9 +37,7 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
var usernameCSS = primitives.PrepareCSS(`
|
var usernameCSS = primitives.PrepareCSS(`
|
||||||
.username-view {
|
.username-view { margin: 0 5px }
|
||||||
margin: 8px 10px;
|
|
||||||
}
|
|
||||||
`)
|
`)
|
||||||
|
|
||||||
func NewContainer() *Container {
|
func NewContainer() *Container {
|
||||||
|
@ -54,7 +52,6 @@ func NewContainer() *Container {
|
||||||
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
|
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
|
||||||
box.PackStart(avatar, false, false, 0)
|
box.PackStart(avatar, false, false, 0)
|
||||||
box.PackStart(label, false, false, 0)
|
box.PackStart(label, false, false, 0)
|
||||||
box.SetVAlign(gtk.ALIGN_START)
|
|
||||||
box.Show()
|
box.Show()
|
||||||
|
|
||||||
primitives.AddClass(box, "username-view")
|
primitives.AddClass(box, "username-view")
|
||||||
|
|
|
@ -54,7 +54,10 @@ type GenericContainer struct {
|
||||||
|
|
||||||
Timestamp *gtk.Label
|
Timestamp *gtk.Label
|
||||||
Username *gtk.Label
|
Username *gtk.Label
|
||||||
Content *gtk.Label
|
Content gtk.IWidget // conceal widget implementation
|
||||||
|
|
||||||
|
contentBox *gtk.Box // basically what is in Content
|
||||||
|
ContentBody *gtk.Label
|
||||||
|
|
||||||
MenuItems []menu.Item
|
MenuItems []menu.Item
|
||||||
}
|
}
|
||||||
|
@ -100,28 +103,33 @@ func NewEmptyContainer() *GenericContainer {
|
||||||
user.SetVAlign(gtk.ALIGN_START)
|
user.SetVAlign(gtk.ALIGN_START)
|
||||||
user.Show()
|
user.Show()
|
||||||
|
|
||||||
content, _ := gtk.LabelNew("")
|
ctbody, _ := gtk.LabelNew("")
|
||||||
content.SetLineWrap(true)
|
ctbody.SetLineWrap(true)
|
||||||
content.SetLineWrapMode(pango.WRAP_WORD_CHAR)
|
ctbody.SetLineWrapMode(pango.WRAP_WORD_CHAR)
|
||||||
content.SetXAlign(0) // left align
|
ctbody.SetXAlign(0) // left align
|
||||||
content.SetSelectable(true)
|
ctbody.SetSelectable(true)
|
||||||
content.Show()
|
ctbody.Show()
|
||||||
|
|
||||||
|
// Wrap the content label inside a content box.
|
||||||
|
ctbox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||||
|
ctbox.PackStart(ctbody, false, false, 0)
|
||||||
|
ctbox.Show()
|
||||||
|
|
||||||
// Causes bugs with selections.
|
// Causes bugs with selections.
|
||||||
|
|
||||||
// content.Connect("grab-notify", func(l *gtk.Label, grabbed bool) {
|
// ctbody.Connect("grab-notify", func(l *gtk.Label, grabbed bool) {
|
||||||
// if grabbed {
|
// if grabbed {
|
||||||
// // Hack to stop the label from selecting everything after being
|
// // Hack to stop the label from selecting everything after being
|
||||||
// // refocused.
|
// // refocused.
|
||||||
// content.SetSelectable(false)
|
// ctbody.SetSelectable(false)
|
||||||
// gts.ExecAsync(func() { content.SetSelectable(true) })
|
// gts.ExecAsync(func() { ctbody.SetSelectable(true) })
|
||||||
// }
|
// }
|
||||||
// })
|
// })
|
||||||
|
|
||||||
// Add CSS classes.
|
// Add CSS classes.
|
||||||
primitives.AddClass(ts, "message-time")
|
primitives.AddClass(ts, "message-time")
|
||||||
primitives.AddClass(user, "message-author")
|
primitives.AddClass(user, "message-author")
|
||||||
primitives.AddClass(content, "message-content")
|
primitives.AddClass(ctbody, "message-content")
|
||||||
|
|
||||||
// Attach the timestamp CSS.
|
// Attach the timestamp CSS.
|
||||||
primitives.AttachCSS(ts, timestampCSS)
|
primitives.AttachCSS(ts, timestampCSS)
|
||||||
|
@ -129,17 +137,20 @@ func NewEmptyContainer() *GenericContainer {
|
||||||
gc := &GenericContainer{
|
gc := &GenericContainer{
|
||||||
Timestamp: ts,
|
Timestamp: ts,
|
||||||
Username: user,
|
Username: user,
|
||||||
Content: content,
|
Content: ctbox,
|
||||||
|
contentBox: ctbox,
|
||||||
|
ContentBody: ctbody,
|
||||||
}
|
}
|
||||||
|
|
||||||
gc.Content.Connect("populate-popup", func(l *gtk.Label, m *gtk.Menu) {
|
// Bind the custom popup menu to the content label.
|
||||||
|
gc.ContentBody.Connect("populate-popup", func(l *gtk.Label, m *gtk.Menu) {
|
||||||
menu.MenuSeparator(m)
|
menu.MenuSeparator(m)
|
||||||
menu.MenuItems(m, gc.MenuItems)
|
menu.MenuItems(m, gc.MenuItems)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Make up for the lack of inline images with an image popover that's shown
|
// Make up for the lack of inline images with an image popover that's shown
|
||||||
// when links are clicked.
|
// when links are clicked.
|
||||||
imgview.BindTooltip(gc.Content)
|
imgview.BindTooltip(gc.ContentBody)
|
||||||
|
|
||||||
return gc
|
return gc
|
||||||
}
|
}
|
||||||
|
@ -190,7 +201,7 @@ func (m *GenericContainer) UpdateContent(content text.Rich, edited bool) {
|
||||||
markup += " " + rich.Small("(edited)")
|
markup += " " + rich.Small("(edited)")
|
||||||
}
|
}
|
||||||
|
|
||||||
m.Content.SetMarkup(markup)
|
m.ContentBody.SetMarkup(markup)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AttachMenu connects signal handlers to handle a list of menu items from
|
// AttachMenu connects signal handlers to handle a list of menu items from
|
||||||
|
|
|
@ -1,9 +1,18 @@
|
||||||
package message
|
package message
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/humanize"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
|
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/attachment"
|
||||||
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
"github.com/gotk3/gotk3/pango"
|
||||||
|
)
|
||||||
|
|
||||||
|
var EmptyContentPlaceholder = fmt.Sprintf(
|
||||||
|
`<span alpha="25%%">%s</span>`, html.EscapeString("<empty>"),
|
||||||
)
|
)
|
||||||
|
|
||||||
type PresendContainer interface {
|
type PresendContainer interface {
|
||||||
|
@ -16,7 +25,10 @@ type PresendContainer interface {
|
||||||
// implemented for stateful mutability of the generic message container.
|
// implemented for stateful mutability of the generic message container.
|
||||||
type GenericPresendContainer struct {
|
type GenericPresendContainer struct {
|
||||||
*GenericContainer
|
*GenericContainer
|
||||||
sendString string // to be cleared on SetDone()
|
|
||||||
|
// states; to be cleared on SetDone()
|
||||||
|
presend input.PresendMessage
|
||||||
|
uploads *attachment.MessageUploader
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ PresendContainer = (*GenericPresendContainer)(nil)
|
var _ PresendContainer = (*GenericPresendContainer)(nil)
|
||||||
|
@ -33,7 +45,9 @@ func WrapPresendContainer(c *GenericContainer, msg input.PresendMessage) *Generi
|
||||||
|
|
||||||
p := &GenericPresendContainer{
|
p := &GenericPresendContainer{
|
||||||
GenericContainer: c,
|
GenericContainer: c,
|
||||||
sendString: msg.Content(),
|
|
||||||
|
presend: msg,
|
||||||
|
uploads: attachment.NewMessageUploader(msg.Files()),
|
||||||
}
|
}
|
||||||
p.SetLoading()
|
p.SetLoading()
|
||||||
|
|
||||||
|
@ -41,26 +55,85 @@ func WrapPresendContainer(c *GenericContainer, msg input.PresendMessage) *Generi
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *GenericPresendContainer) SetSensitive(sensitive bool) {
|
func (m *GenericPresendContainer) SetSensitive(sensitive bool) {
|
||||||
m.Content.SetSensitive(sensitive)
|
m.contentBox.SetSensitive(sensitive)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *GenericPresendContainer) SetDone(id string) {
|
func (m *GenericPresendContainer) SetDone(id string) {
|
||||||
|
// Apply the received ID.
|
||||||
m.id = id
|
m.id = id
|
||||||
|
// Set the sensitivity from false in SetLoading back to true.
|
||||||
m.SetSensitive(true)
|
m.SetSensitive(true)
|
||||||
m.sendString = ""
|
// Reset the state to be normal. Especially setting presend to nil should
|
||||||
m.Content.SetTooltipText("")
|
// free it from memory.
|
||||||
|
m.presend = nil
|
||||||
|
m.uploads = nil
|
||||||
|
m.contentBox.SetTooltipText("")
|
||||||
|
|
||||||
|
// Remove everything in the content box.
|
||||||
|
m.clearBox()
|
||||||
|
|
||||||
|
// Re-add the content label.
|
||||||
|
m.contentBox.Add(m.ContentBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *GenericPresendContainer) SetLoading() {
|
func (m *GenericPresendContainer) SetLoading() {
|
||||||
m.SetSensitive(false)
|
m.SetSensitive(false)
|
||||||
m.Content.SetText(m.sendString)
|
m.contentBox.SetTooltipText("")
|
||||||
m.Content.SetTooltipText("")
|
|
||||||
|
|
||||||
// m.CBuffer.SetText(m.sendString)
|
// Clear everything inside the content container.
|
||||||
|
m.clearBox()
|
||||||
|
|
||||||
|
// Add the content label.
|
||||||
|
m.contentBox.Add(m.ContentBody)
|
||||||
|
|
||||||
|
// Add the attachment progress box back in, if any.
|
||||||
|
if m.uploads != nil {
|
||||||
|
m.uploads.Show() // show the bars
|
||||||
|
m.contentBox.Add(m.uploads)
|
||||||
|
}
|
||||||
|
|
||||||
|
if content := m.presend.Content(); content != "" {
|
||||||
|
m.ContentBody.SetText(content)
|
||||||
|
} else {
|
||||||
|
// Use a placeholder content if the actual content is empty.
|
||||||
|
m.ContentBody.SetMarkup(EmptyContentPlaceholder)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *GenericPresendContainer) SetSentError(err error) {
|
func (m *GenericPresendContainer) SetSentError(err error) {
|
||||||
m.SetSensitive(true) // allow events incl right clicks
|
m.SetSensitive(true) // allow events incl right clicks
|
||||||
m.Content.SetMarkup(`<span color="red">` + html.EscapeString(m.sendString) + `</span>`)
|
m.contentBox.SetTooltipText(err.Error())
|
||||||
m.Content.SetTooltipText(err.Error())
|
|
||||||
|
// Remove everything again.
|
||||||
|
m.clearBox()
|
||||||
|
|
||||||
|
// Re-add the label.
|
||||||
|
m.contentBox.Add(m.ContentBody)
|
||||||
|
|
||||||
|
// Style the label appropriately by making it red.
|
||||||
|
var content = html.EscapeString(m.presend.Content())
|
||||||
|
if content == "" {
|
||||||
|
content = EmptyContentPlaceholder
|
||||||
|
}
|
||||||
|
m.ContentBody.SetMarkup(fmt.Sprintf(`<span color="red">%s</span>`, content))
|
||||||
|
|
||||||
|
// Add a smaller label indicating an error.
|
||||||
|
errl, _ := gtk.LabelNew("")
|
||||||
|
errl.SetXAlign(0)
|
||||||
|
errl.SetLineWrap(true)
|
||||||
|
errl.SetLineWrapMode(pango.WRAP_WORD_CHAR)
|
||||||
|
errl.SetMarkup(fmt.Sprintf(
|
||||||
|
`<span size="small" color="red"><b>Error:</b> %s</span>`,
|
||||||
|
html.EscapeString(humanize.Error(err)),
|
||||||
|
))
|
||||||
|
|
||||||
|
errl.Show()
|
||||||
|
m.contentBox.Add(errl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearBox clears everything inside the content container.
|
||||||
|
func (m *GenericPresendContainer) clearBox() {
|
||||||
|
m.contentBox.GetChildren().Foreach(func(v interface{}) {
|
||||||
|
m.contentBox.Remove(v.(gtk.IWidget))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -83,6 +83,17 @@ func AddClass(styleCtx StyleContexter, classes ...string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StyleContextFocuser interface {
|
||||||
|
StyleContexter
|
||||||
|
GrabFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuggestAction styles the element to have the suggeested action class.
|
||||||
|
func SuggestAction(styleCtx StyleContextFocuser) {
|
||||||
|
AddClass(styleCtx, "suggested-action")
|
||||||
|
styleCtx.GrabFocus()
|
||||||
|
}
|
||||||
|
|
||||||
type Bin interface {
|
type Bin interface {
|
||||||
GetChild() (gtk.IWidget, error)
|
GetChild() (gtk.IWidget, error)
|
||||||
}
|
}
|
||||||
|
@ -241,11 +252,7 @@ func PrepareCSS(css string) *gtk.CssProvider {
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
type StyleContextGetter interface {
|
func AttachCSS(ctx StyleContexter, prov *gtk.CssProvider) {
|
||||||
GetStyleContext() (*gtk.StyleContext, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func AttachCSS(ctx StyleContextGetter, prov *gtk.CssProvider) {
|
|
||||||
s, _ := ctx.GetStyleContext()
|
s, _ := ctx.GetStyleContext()
|
||||||
s.AddProvider(prov, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
s.AddProvider(prov, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Dialog struct {
|
type Dialog struct {
|
||||||
*gtk.Dialog
|
*dialog.Modal
|
||||||
Auther cchat.Authenticator
|
Auther cchat.Authenticator
|
||||||
onAuth func(cchat.Session)
|
onAuth func(cchat.Session)
|
||||||
|
|
||||||
|
@ -58,8 +58,8 @@ func NewDialog(name text.Rich, auther cchat.Authenticator, auth func(cchat.Sessi
|
||||||
body: box,
|
body: box,
|
||||||
label: label,
|
label: label,
|
||||||
}
|
}
|
||||||
d.Dialog = dialog.NewModal(stack, "Log in to "+name.Content, "Log in", d.ok)
|
d.Modal = dialog.NewModal(stack, "Log in to "+name.Content, "Log in", d.ok)
|
||||||
d.Dialog.SetDefaultSize(400, 300)
|
d.Modal.SetDefaultSize(400, 300)
|
||||||
d.spin(nil)
|
d.spin(nil)
|
||||||
d.Show()
|
d.Show()
|
||||||
|
|
||||||
|
@ -93,7 +93,7 @@ func (d *Dialog) spin(err error) {
|
||||||
d.body.Add(d.request)
|
d.body.Add(d.request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dialog) ok() {
|
func (d *Dialog) ok(m *dialog.Modal) {
|
||||||
// Disable the buttons.
|
// Disable the buttons.
|
||||||
d.Dialog.SetSensitive(false)
|
d.Dialog.SetSensitive(false)
|
||||||
|
|
||||||
|
|
|
@ -77,6 +77,7 @@ func newRow(parent breadcrumb.Breadcrumber, name text.Rich, ctrl Controller) *Ro
|
||||||
// Make a commander button that's hidden by default in case.
|
// Make a commander button that's hidden by default in case.
|
||||||
cmdbtn, _ := gtk.ButtonNewFromIconName("utilities-terminal-symbolic", gtk.ICON_SIZE_BUTTON)
|
cmdbtn, _ := gtk.ButtonNewFromIconName("utilities-terminal-symbolic", gtk.ICON_SIZE_BUTTON)
|
||||||
buttonoverlay.Take(srow.Button, cmdbtn, server.IconSize)
|
buttonoverlay.Take(srow.Button, cmdbtn, server.IconSize)
|
||||||
|
primitives.AddClass(cmdbtn, "command-button")
|
||||||
|
|
||||||
row := &Row{
|
row := &Row{
|
||||||
Row: srow,
|
Row: srow,
|
||||||
|
|
Loading…
Reference in a new issue