Compare commits

44 Commits

Author SHA1 Message Date
3366dbc500 Remove non-existent variable 2025-06-29 19:18:36 +03:00
d1d08d1ce4 Update mautrix-go 2025-06-29 19:12:40 +03:00
bb3e02c4d9 Update mautrix-go for optional federation auth 2025-05-04 01:16:40 +03:00
4b96d23621 Add sticker-download-thumbnails for adding thumbnails to an existing pack 2025-03-25 20:06:48 +02:00
3ce380645d Update urls and asyncio calls 2025-03-25 20:06:19 +02:00
a8effa2efa Save and server thumbnails locally (#88)
Fixes #83
Fixes #86
Fixes #87
2025-03-25 19:46:43 +02:00
89d3aece04 Add parameter 2024-12-03 00:16:51 +02:00
266f98579a Update giphyproxy dependencies 2024-12-03 00:14:27 +02:00
0591df0f7e Add missing character 2024-12-03 00:12:43 +02:00
8cea4a46d0 Make giphyproxy destination configurable 2024-12-03 00:03:01 +02:00
333567f481 Convert gif width/height to numbers 2024-06-19 14:29:21 +03:00
125d057e44 Remove animated sticker pack check 2024-06-19 13:55:00 +03:00
e1038e7d1e Force proxying legacy federated downloads in giphy proxy 2024-06-19 12:30:14 +03:00
850668a9f6 Update giphyproxy /version response 2024-06-19 12:21:04 +03:00
804014f3b4 Add cats to giphy proxy 2024-06-19 12:02:52 +03:00
5da539ad84 Add MSC3916-compatible giphy media repo proxy 2024-06-19 11:51:29 +03:00
6332613e13 Don't try to use non-existent variables 2024-06-17 22:48:15 +03:00
dbc3a9fbb8 Don't prompt for giphy api key by default 2024-06-05 13:02:34 +03:00
47f17fde45 Add support for sending gifs via Giphy
Fixes #22
Closes #75

Co-authored-by: Nischay <hegdenischay@gmail.com>
2024-05-18 16:18:55 +03:00
f59406a47a Add missing parameter to getting sticker sets. Fixes #51 2022-11-15 12:58:38 +02:00
99ced8878a Merge remote-tracking branch 'salixor/feat/search-box' 2021-10-03 12:52:55 +03:00
046779d102 Update some metadata 2021-10-03 12:45:37 +03:00
ef844a0ff8 Update dependencies 2021-10-03 12:42:11 +03:00
502d91fc75 Merge remote-tracking branch 'p1gp1g/dev' 2021-10-03 11:50:47 +03:00
591137ccb3 Merge remote-tracking branch 'celogeek/fixes-ios-and-more' 2021-10-03 11:50:27 +03:00
f29c165357 Merge remote-tracking branch 'aWeinzierl/fix-encoding' 2021-10-03 11:49:53 +03:00
S1m
7939793351 Remove parseQuery and use params 2021-09-30 08:52:41 +02:00
S1m
e0d895f22a Check packfile protocole scheme + rm semicolons 2021-09-20 08:58:20 +02:00
S1m
5d3c7d1e2f Allow using external index.json and stickerpack 2021-09-19 17:35:28 +02:00
ec8eeeeaf5 Fix scrolling topbar on Firefox
(and possibly break it on other browsers)
2021-04-22 19:12:54 +03:00
57fde6fcad Assume https if homeserver URL doesn't have protocol 2021-04-22 19:12:54 +03:00
9443e79e97 fix display of packs with sticker-import --list 2021-02-07 17:52:55 +01:00
85813b22e5 add missing msgtype = m.sticker
On iOS the message is sent twice, with a duplicate event_id.
It cause error on logs, in different places (synapse, mautrix, ...)

It required to fix the already existing json or reimport the stickers.

The "packs/scalar*" example already include this field, and it works.
2021-02-07 17:50:50 +01:00
569d9815c6 remove redundant utf-8 setting 2021-01-28 00:06:58 +01:00
0f7b678f57 Use utf8-encoding whenever JSON is processed 2021-01-27 23:31:33 +01:00
b884a9c387 Set encoding to utf-8 when saving json file for stickerpack
Fixes UnicodeEncodeError with Windows 10 when trying to import sticker packs caused by the default encoding scheme in Windows
2021-01-27 22:09:15 +01:00
ba0096275c Update README.md 2021-01-24 13:14:59 +02:00
3916ade97b Merge pull request #26 from auscompgeek/patch-1
Show sticker body in hover tooltip
2021-01-20 23:58:00 +02:00
dab2420cd4 Merge pull request #28 from SpiritCroc/fix-import
Rename import.py, since import is a keyword
2021-01-20 23:57:28 +02:00
601d2acc32 Rename import.py, since import is a keyword
from sticker.import import cmd
                 ^
SyntaxError: invalid syntax

Fixes https://github.com/maunium/stickerpicker/issues/27.
2021-01-19 17:12:06 +01:00
21d4f5cce6 Show sticker body in hover tooltip 2020-12-30 19:56:31 +11:00
9350d5f27b Update sass style file 2020-12-17 09:31:29 +01:00
add27513fe Implement stickers search 2020-12-17 01:01:32 +01:00
66d5b90ea1 Re-format CSS for readability in PR 2020-12-17 01:00:22 +01:00
31 changed files with 1202 additions and 2300 deletions

15
.editorconfig Normal file
View File

@ -0,0 +1,15 @@
root = true
[*]
indent_style = tab
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{yaml,yml,sql,py,sass}]
indent_style = space
[*.sass]
indent_size = 2

13
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,13 @@
variables:
GOTOOLCHAIN: local
build giphy proxy docker:
image: docker:stable
stage: build
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker build -t $CI_REGISTRY_IMAGE/giphyproxy:latest giphyproxy
- docker push $CI_REGISTRY_IMAGE/giphyproxy:latest
only:
- master

View File

@ -11,8 +11,14 @@ For setup and usage instructions, please visit the [wiki](https://github.com/mau
* [Enabling the widget](https://github.com/maunium/stickerpicker/wiki/Enabling-the-widget)
* [Hosting on GitHub pages](https://github.com/maunium/stickerpicker/wiki/Hosting-on-GitHub-pages)
If you prefer video tutorials, [Brodie Robertson](https://www.youtube.com/c/BrodieRobertson) has made a great video on setting up the picker and creating some packs: https://youtu.be/Yz3H6KJTEI0.
## Comparison with other sticker pickers
* Scalar is the default integration manager in Element, which can't be self-hosted and only supports predefined sticker packs.
* [Dimension](https://github.com/turt2live/matrix-dimension) is an alternate integration manager. It can be self-hosted, but it's more difficult than Maunium sticker picker.
* Maunium sticker picker is just a sticker picker rather than a full integration manager. It's much simpler than integration managers, but currently has to be set up manually per-user.
| Feature | Scalar | Dimension | Maunium sticker picker |
|---------------------------------|--------|-----------|------------------------|
| Free software | ❌ | ✔️ | ✔️ |

16
giphyproxy/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM golang:1-alpine AS builder
RUN apk add --no-cache ca-certificates
WORKDIR /build/giphyproxy
COPY . /build/giphyproxy
ENV CGO_ENABLED=0
RUN go build -o /usr/bin/giphyproxy
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/bin/giphyproxy /usr/bin/giphyproxy
VOLUME /data
WORKDIR /data
CMD ["/usr/bin/giphyproxy"]

View File

@ -0,0 +1,21 @@
# The server name to use for the custom mxc:// URIs.
# This server name will effectively be a real Matrix server, it just won't implement anything other than media.
# You must either set up .well-known delegation from this domain to this program, or proxy the domain directly to this program.
server_name: giphy.example.com
# Optionally a custom .well-known response. This defaults to `server_name:443` if empty.
well_known_response:
# Matrix server signing key to make the federation tester pass, same format as synapse's .signing.key file.
# You can generate one using `giphyproxy -generate-key`.
server_key: CHANGE ME
# Should federation authentication be enforced?
# If true, requests to the /_matrix/federation/v1/media/download/... endpoint
# will check the Authorization: X-Matrix header before redirecting.
federation_auth: false
# Hostname where the proxy should listen on
hostname: 0.0.0.0
# Port where the proxy should listen on
port: 8008
# Redirect destination. This can be changed to serve a different format.
destination: https://i.giphy.com/%s.webp

28
giphyproxy/go.mod Normal file
View File

@ -0,0 +1,28 @@
module go.mau.fi/stickerpicker/giphyproxy
go 1.23.0
toolchain go1.24.4
require (
go.mau.fi/util v0.8.8
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.24.2-0.20250629161127-7a7d7f70ef92
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
)

54
giphyproxy/go.sum Normal file
View File

@ -0,0 +1,54 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
go.mau.fi/util v0.8.8 h1:OnuEEc/sIJFhnq4kFggiImUpcmnmL/xpvQMRu5Fiy5c=
go.mau.fi/util v0.8.8/go.mod h1:Y/kS3loxTEhy8Vill513EtPXr+CRDdae+Xj2BXXMy/c=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.24.2-0.20250629161127-7a7d7f70ef92 h1:j/QuGIVm5/KPlxMgbViphre7shSwnI7LIjNeiLDSnL4=
maunium.net/go/mautrix v0.24.2-0.20250629161127-7a7d7f70ef92/go.mod h1:Xy6o+pXmbqmgWsUWh15EQ1eozjC+k/VT/7kloByv9PI=

76
giphyproxy/main.go Normal file
View File

@ -0,0 +1,76 @@
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"context"
"flag"
"fmt"
"os"
"regexp"
"time"
"go.mau.fi/util/exerrors"
"gopkg.in/yaml.v3"
"maunium.net/go/mautrix/federation"
"maunium.net/go/mautrix/mediaproxy"
)
type Config struct {
mediaproxy.BasicConfig `yaml:",inline"`
mediaproxy.ServerConfig `yaml:",inline"`
Destination string `yaml:"destination"`
}
var configPath = flag.String("config", "config.yaml", "config file path")
var generateServerKey = flag.Bool("generate-key", false, "generate a new server key and exit")
var giphyIDRegex = regexp.MustCompile(`^[a-zA-Z0-9-_]+$`)
var destination = "https://i.giphy.com/%s.webp"
func main() {
flag.Parse()
if *generateServerKey {
fmt.Println(federation.GenerateSigningKey().SynapseString())
} else {
cfgFile := exerrors.Must(os.ReadFile(*configPath))
var cfg Config
exerrors.PanicIfNotNil(yaml.Unmarshal(cfgFile, &cfg))
mp := exerrors.Must(mediaproxy.NewFromConfig(cfg.BasicConfig, getMedia))
mp.KeyServer.Version.Name = "mautrix-go + maunium-stickerpicker giphy proxy"
if cfg.Destination != "" {
destination = cfg.Destination
}
exerrors.PanicIfNotNil(mp.Listen(cfg.ServerConfig))
}
}
func getMedia(_ context.Context, id string, _ map[string]string) (response mediaproxy.GetMediaResponse, err error) {
// This is not related to giphy, but random cats are always fun
if id == "cat" {
return &mediaproxy.GetMediaResponseURL{
URL: "https://cataas.com/cat",
ExpiresAt: time.Now(),
}, nil
}
if !giphyIDRegex.MatchString(id) {
return nil, mediaproxy.ErrInvalidMediaIDSyntax
}
return &mediaproxy.GetMediaResponseURL{
URL: fmt.Sprintf(destination, id),
}, nil
}

View File

@ -45,9 +45,11 @@ setuptools.setup(
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
],
entry_points={"console_scripts": [
"sticker-import=sticker.import:cmd",
"sticker-import=sticker.stickerimport:cmd",
"sticker-pack=sticker.pack:cmd",
"sticker-download-thumbnails=sticker.download_thumbnails:cmd",
]},
)

View File

@ -0,0 +1,58 @@
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
# Copyright (C) 2025 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from pathlib import Path
from typing import Dict
import argparse
import asyncio
import json
from aiohttp import ClientSession
from yarl import URL
from .lib import matrix, util
parser = argparse.ArgumentParser()
parser.add_argument("--config",
help="Path to JSON file with Matrix homeserver and access_token",
type=str, default="config.json", metavar="file")
parser.add_argument("path", help="Path to the sticker pack JSON file", type=str)
async def main(args: argparse.Namespace) -> None:
await matrix.load_config(args.config)
with util.open_utf8(args.path) as pack_file:
pack = json.load(pack_file)
print(f"Loaded existing pack meta from {args.path}")
stickers_data: Dict[str, bytes] = {}
async with ClientSession() as sess:
for sticker in pack["stickers"]:
dl_url = URL(matrix.homeserver_url) / "_matrix/client/v1/media/download" / sticker["url"].removeprefix("mxc://")
print("Downloading", sticker["url"])
async with sess.get(dl_url, headers={"Authorization": f"Bearer {matrix.access_token}"}) as resp:
resp.raise_for_status()
stickers_data[sticker["url"]] = await resp.read()
print("All stickers downloaded, generating thumbnails...")
util.add_thumbnails(pack["stickers"], stickers_data, Path(args.path).parent)
print("Done!")
def cmd():
asyncio.run(main(parser.parse_args()))
if __name__ == "__main__":
cmd()

View File

@ -42,6 +42,7 @@ if TYPE_CHECKING:
url: str
info: MediaInfo
id: str
msgtype: str
else:
MediaInfo = None
StickerInfo = None
@ -58,17 +59,19 @@ async def load_config(path: str) -> None:
print("Matrix config file not found. Please enter your homeserver and access token.")
homeserver_url = input("Homeserver URL: ")
access_token = input("Access token: ")
whoami_url = URL(homeserver_url) / "_matrix" / "client" / "r0" / "account" / "whoami"
whoami_url = URL(homeserver_url) / "_matrix" / "client" / "v3" / "account" / "whoami"
if whoami_url.scheme not in ("https", "http"):
whoami_url = whoami_url.with_scheme("https")
user_id = await whoami(whoami_url, access_token)
with open(path, "w") as config_file:
json.dump({
"homeserver": homeserver_url,
"user_id": user_id,
"access_token": access_token
"access_token": access_token,
}, config_file)
print(f"Wrote config to {path}")
upload_url = URL(homeserver_url) / "_matrix" / "media" / "r0" / "upload"
upload_url = URL(homeserver_url) / "_matrix" / "media" / "v3" / "upload"
async def whoami(url: URL, access_token: str) -> str:

View File

@ -13,35 +13,39 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from functools import partial
from io import BytesIO
import os.path
import json
from pathlib import Path
from typing import Dict, List
from PIL import Image
from . import matrix
open_utf8 = partial(open, encoding='UTF-8')
def convert_image(data: bytes) -> (bytes, int, int):
def convert_image(data: bytes, max_w=256, max_h=256) -> (bytes, int, int):
image: Image.Image = Image.open(BytesIO(data)).convert("RGBA")
new_file = BytesIO()
image.save(new_file, "png")
w, h = image.size
if w > 256 or h > 256:
if w > max_w or h > max_h:
# Set the width and height to lower values so clients wouldn't show them as huge images
if w > h:
h = int(h / (w / 256))
w = 256
h = int(h / (w / max_w))
w = max_w
else:
w = int(w / (h / 256))
h = 256
w = int(w / (h / max_h))
h = max_h
return new_file.getvalue(), w, h
def add_to_index(name: str, output_dir: str) -> None:
index_path = os.path.join(output_dir, "index.json")
try:
with open(index_path) as index_file:
with open_utf8(index_path) as index_file:
index_data = json.load(index_file)
except (FileNotFoundError, json.JSONDecodeError):
index_data = {"packs": []}
@ -49,7 +53,7 @@ def add_to_index(name: str, output_dir: str) -> None:
index_data["homeserver_url"] = matrix.homeserver_url
if name not in index_data["packs"]:
index_data["packs"].append(name)
with open(index_path, "w") as index_file:
with open_utf8(index_path, "w") as index_file:
json.dump(index_data, index_file, indent=" ")
print(f"Added {name} to {index_path}")
@ -74,4 +78,17 @@ def make_sticker(mxc: str, width: int, height: int, size: int,
"mimetype": "image/png",
},
},
"msgtype": "m.sticker",
}
def add_thumbnails(stickers: List[matrix.StickerInfo], stickers_data: Dict[str, bytes], output_dir: str) -> None:
thumbnails = Path(output_dir, "thumbnails")
thumbnails.mkdir(parents=True, exist_ok=True)
for sticker in stickers:
image_data, _, _ = convert_image(stickers_data[sticker["url"]], 128, 128)
name = sticker["url"].split("/")[-1]
thumbnail_path = thumbnails / name
thumbnail_path.write_bytes(image_data)

View File

@ -13,6 +13,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from pathlib import Path
from typing import Dict, Optional
from hashlib import sha256
import mimetypes
@ -93,7 +94,7 @@ async def main(args: argparse.Namespace) -> None:
dirname = os.path.basename(os.path.abspath(args.path))
meta_path = os.path.join(args.path, "pack.json")
try:
with open(meta_path) as pack_file:
with util.open_utf8(meta_path) as pack_file:
pack = json.load(pack_file)
print(f"Loaded existing pack meta from {meta_path}")
except FileNotFoundError:
@ -107,21 +108,25 @@ async def main(args: argparse.Namespace) -> None:
old_stickers = {sticker["id"]: sticker for sticker in pack["stickers"]}
pack["stickers"] = []
stickers_data: Dict[str, bytes] = {}
for file in sorted(os.listdir(args.path)):
sticker = await upload_sticker(file, args.path, old_stickers=old_stickers)
if sticker:
stickers_data[sticker["url"]] = Path(args.path, file).read_bytes()
pack["stickers"].append(sticker)
with open(meta_path, "w") as pack_file:
with util.open_utf8(meta_path, "w") as pack_file:
json.dump(pack, pack_file)
print(f"Wrote pack to {meta_path}")
if args.add_to_index:
picker_file_name = f"{pack['id']}.json"
picker_pack_path = os.path.join(args.add_to_index, picker_file_name)
with open(picker_pack_path, "w") as pack_file:
with util.open_utf8(picker_pack_path, "w") as pack_file:
json.dump(pack, pack_file)
print(f"Copied pack to {picker_pack_path}")
util.add_thumbnails(pack["stickers"], stickers_data, args.add_to_index)
util.add_to_index(picker_file_name, args.add_to_index)
@ -138,7 +143,7 @@ parser.add_argument("path", help="Path to the sticker pack directory", type=str)
def cmd():
asyncio.get_event_loop().run_until_complete(main(parser.parse_args()))
asyncio.run(main(parser.parse_args()))
if __name__ == "__main__":

View File

@ -19,12 +19,12 @@ import json
index_path = "../web/packs/index.json"
try:
with open(index_path) as index_file:
with util.open_utf8(index_path) as index_file:
index_data = json.load(index_file)
except (FileNotFoundError, json.JSONDecodeError):
index_data = {"packs": []}
with open(sys.argv[-1]) as file:
with util.open_utf8(sys.argv[-1]) as file:
data = json.load(file)
for pack in data["assets"]:
@ -45,12 +45,12 @@ for pack in data["assets"]:
}
filename = f"scalar-{pack['name'].replace(' ', '_')}.json"
pack_path = f"web/packs/{filename}"
with open(pack_path, "w") as pack_file:
with util.open_utf8(pack_path, "w") as pack_file:
json.dump(pack_data, pack_file)
print(f"Wrote {title} to {pack_path}")
if filename not in index_data["packs"]:
index_data["packs"].append(filename)
with open(index_path, "w") as index_file:
with util.open_utf8(index_path, "w") as index_file:
json.dump(index_data, index_file, indent=" ")
print(f"Updated {index_path}")

View File

@ -13,7 +13,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
from typing import Dict, Tuple
import argparse
import asyncio
import os.path
@ -29,7 +29,7 @@ from telethon.tl.types.messages import StickerSet as StickerSetFull
from .lib import matrix, util
async def reupload_document(client: TelegramClient, document: Document) -> matrix.StickerInfo:
async def reupload_document(client: TelegramClient, document: Document) -> Tuple[matrix.StickerInfo, bytes]:
print(f"Reuploading {document.id}", end="", flush=True)
data = await client.download_media(document, file=bytes)
print(".", end="", flush=True)
@ -37,7 +37,7 @@ async def reupload_document(client: TelegramClient, document: Document) -> matri
print(".", end="", flush=True)
mxc = await matrix.upload(data, "image/png", f"{document.id}.png")
print(".", flush=True)
return util.make_sticker(mxc, width, height, len(data))
return util.make_sticker(mxc, width, height, len(data)), data
def add_meta(document: Document, info: matrix.StickerInfo, pack: StickerSetFull) -> None:
@ -56,10 +56,6 @@ def add_meta(document: Document, info: matrix.StickerInfo, pack: StickerSetFull)
async def reupload_pack(client: TelegramClient, pack: StickerSetFull, output_dir: str) -> None:
if pack.set.animated:
print("Animated stickerpacks are currently not supported")
return
pack_path = os.path.join(output_dir, f"{pack.set.short_name}.json")
try:
os.mkdir(os.path.dirname(pack_path))
@ -71,7 +67,7 @@ async def reupload_pack(client: TelegramClient, pack: StickerSetFull, output_dir
already_uploaded = {}
try:
with open(pack_path) as pack_file:
with util.open_utf8(pack_path) as pack_file:
existing_pack = json.load(pack_file)
already_uploaded = {int(sticker["net.maunium.telegram.sticker"]["id"]): sticker
for sticker in existing_pack["stickers"]}
@ -79,15 +75,17 @@ async def reupload_pack(client: TelegramClient, pack: StickerSetFull, output_dir
except FileNotFoundError:
pass
stickers_data: Dict[str, bytes] = {}
reuploaded_documents: Dict[int, matrix.StickerInfo] = {}
for document in pack.documents:
try:
reuploaded_documents[document.id] = already_uploaded[document.id]
print(f"Skipped reuploading {document.id}")
except KeyError:
reuploaded_documents[document.id] = await reupload_document(client, document)
reuploaded_documents[document.id], data = await reupload_document(client, document)
# Always ensure the body and telegram metadata is correct
add_meta(document, reuploaded_documents[document.id], pack)
stickers_data[reuploaded_documents[document.id]["url"]] = data
for sticker in pack.packs:
if not sticker.emoticon:
@ -99,7 +97,7 @@ async def reupload_pack(client: TelegramClient, pack: StickerSetFull, output_dir
doc["body"] = sticker.emoticon
doc["net.maunium.telegram.sticker"]["emoticons"].append(sticker.emoticon)
with open(pack_path, "w") as pack_file:
with util.open_utf8(pack_path, "w") as pack_file:
json.dump({
"title": pack.set.title,
"id": f"tg-{pack.set.id}",
@ -111,6 +109,7 @@ async def reupload_pack(client: TelegramClient, pack: StickerSetFull, output_dir
}, pack_file, ensure_ascii=False)
print(f"Saved {pack.set.title} as {pack.set.short_name}.json")
util.add_thumbnails(list(reuploaded_documents.values()), stickers_data, output_dir)
util.add_to_index(os.path.basename(pack_path), output_dir)
@ -138,11 +137,12 @@ async def main(args: argparse.Namespace) -> None:
if args.list:
stickers: AllStickers = await client(GetAllStickersRequest(hash=0))
index = 1
width = len(str(stickers.sets))
width = len(str(len(stickers.sets)))
print("Your saved sticker packs:")
for saved_pack in stickers.sets:
print(f"{index:>{width}}. {saved_pack.title} "
f"(t.me/addstickers/{saved_pack.short_name})")
index += 1
elif args.pack[0]:
input_packs = []
for pack_url in args.pack[0]:
@ -152,7 +152,7 @@ async def main(args: argparse.Namespace) -> None:
return
input_packs.append(InputStickerSetShortName(short_name=match.group(1)))
for input_pack in input_packs:
pack: StickerSetFull = await client(GetStickerSetRequest(input_pack))
pack: StickerSetFull = await client(GetStickerSetRequest(input_pack, hash=0))
await reupload_pack(client, pack, args.output_dir)
else:
parser.print_help()
@ -161,7 +161,7 @@ async def main(args: argparse.Namespace) -> None:
def cmd() -> None:
asyncio.get_event_loop().run_until_complete(main(parser.parse_args()))
asyncio.run(main(parser.parse_args()))
if __name__ == "__main__":

23
web/esinstall.js Normal file
View File

@ -0,0 +1,23 @@
const { install, printStats } = require("esinstall")
install(
[{
specifier: "htm/preact",
all: false,
default: false,
namespace: false,
named: ["html", "render", "Component"],
}],
{
dest: "./lib",
sourceMap: false,
treeshake: true,
verbose: true,
}
).then(data => {
const oldPrefix = "web_modules/"
const newPrefix = "lib/"
const spaces = " ".repeat(oldPrefix.length - newPrefix.length)
console.log("Installation complete")
console.log(printStats(data.stats).replace(oldPrefix, newPrefix + spaces))
})

View File

@ -1,22 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no">
<title>Maunium sticker picker</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no">
<title>Maunium sticker picker</title>
<link rel="modulepreload" href="src/widget-api.js"/>
<link rel="modulepreload" href="src/frequently-used.js"/>
<link rel="modulepreload" href="src/spinner.js"/>
<link rel="modulepreload" href="lib/htm/preact.js"/>
<link rel="preload" href="packs/index.json" as="fetch" type="application/json" crossorigin/>
<link rel="modulepreload" href="src/widget-api.js"/>
<link rel="modulepreload" href="src/frequently-used.js"/>
<link rel="modulepreload" href="src/spinner.js"/>
<link rel="modulepreload" href="src/giphy.js"/>
<link rel="modulepreload" href="lib/htm/preact.js"/>
<link rel="preload" href="packs/index.json" as="fetch" type="application/json" crossorigin/>
<link rel="stylesheet" href="style/index.css"/>
<link rel="stylesheet" href="style/spinner.css"/>
<script src="src/index.js" type="module"></script>
<script nomodule>document.body.innerText = "This sticker picker requires modern JavaScript"</script>
<link rel="stylesheet" href="style/index.css"/>
<link rel="stylesheet" href="style/spinner.css"/>
<script src="src/index.js" type="module"></script>
<script nomodule>document.body.innerText = "This sticker picker requires modern JavaScript"</script>
</head>
<body>
<noscript>This sticker picker requires JavaScript</noscript>
<noscript>This sticker picker requires JavaScript</noscript>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -4,28 +4,16 @@
"description": "A fast and simple Matrix sticker picker widget",
"repository": "https://github.com/maunium/stickerpicker",
"author": "Tulir Asokan <tulir@maunium.net>",
"license": "MPL-2.0",
"license": "AGPL-3.0-or-later",
"private": true,
"scripts": {
"snowpack": "snowpack",
"sass": "node-sass -o style style/*.sass --output-style compressed"
},
"snowpack": {
"install": [
"htm/preact"
],
"installOptions": {
"sourceMap": false,
"dest": "lib",
"treeshake": true
}
"esinstall": "node ./esinstall.js",
"sass": "sass --no-source-map --style=compressed style/"
},
"dependencies": {
"htm": "^3.0.4",
"preact": "^10.4.8",
"snowpack": "^2.10.3"
},
"devDependencies": {
"node-sass": "^4.14.1"
"esinstall": "^1.1.7",
"htm": "^3.1.0",
"preact": "^10.5.14",
"sass": "^1.42.1"
}
}

55
web/res/giphy-dark.svg Normal file
View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="534"
width="427.20001"
viewBox="0 0 27.990145 35"
version="1.1"
id="svg24"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs28" />
<g
fill="none"
fill-rule="evenodd"
id="g22"
transform="translate(-0.02883895)">
<path
d="M 4,4 H 24 V 31 H 4 Z"
fill="#000000"
id="path2"
style="fill:#ffffff;fill-opacity:1" />
<g
fill-rule="nonzero"
id="g16">
<path
d="M 0,3 H 4 V 32 H 0 Z"
fill="#04ff8e"
id="path4" />
<path
d="m 24,11 h 4 v 21 h -4 z"
fill="#8e2eff"
id="path6" />
<path
d="m 0,31 h 28 v 4 H 0 Z"
fill="#00c5ff"
id="path8" />
<path
d="M 0,0 H 16 V 4 H 0 Z"
fill="#fff152"
id="path10" />
<path
d="M 24,8 V 4 H 20 V 0 H 16 V 12 H 28 V 8"
fill="#ff5b5b"
id="path12" />
<path
d="m 24,16 v -4 h 4"
fill="#551c99"
id="path14" />
</g>
<path
d="M 16,0 V 4 H 12"
fill="#999131"
id="path18" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

54
web/res/giphy-light.svg Normal file
View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="534"
width="427.20001"
viewBox="0 0 27.990145 35"
version="1.1"
id="svg24"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs28" />
<g
fill="none"
fill-rule="evenodd"
id="g22"
transform="translate(-0.02883895)">
<path
d="M 4,4 H 24 V 31 H 4 Z"
fill="#000000"
id="path2" />
<g
fill-rule="nonzero"
id="g16">
<path
d="M 0,3 H 4 V 32 H 0 Z"
fill="#04ff8e"
id="path4" />
<path
d="m 24,11 h 4 v 21 h -4 z"
fill="#8e2eff"
id="path6" />
<path
d="m 0,31 h 28 v 4 H 0 Z"
fill="#00c5ff"
id="path8" />
<path
d="M 0,0 H 16 V 4 H 0 Z"
fill="#fff152"
id="path10" />
<path
d="M 24,8 V 4 H 20 V 0 H 16 V 12 H 28 V 8"
fill="#ff5b5b"
id="path12" />
<path
d="m 24,16 v -4 h 4"
fill="#551c99"
id="path14" />
</g>
<path
d="M 16,0 V 4 H 12"
fill="#999131"
id="path18" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

1
web/res/search.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M9.145 18.29c-5.042 0-9.145-4.102-9.145-9.145s4.103-9.145 9.145-9.145 9.145 4.103 9.145 9.145-4.102 9.145-9.145 9.145zm0-15.167c-3.321 0-6.022 2.702-6.022 6.022s2.702 6.022 6.022 6.022 6.023-2.702 6.023-6.022-2.702-6.022-6.023-6.022zm9.263 12.443c-.817 1.176-1.852 2.188-3.046 2.981l5.452 5.453 3.014-3.013-5.42-5.421z"/></svg>

After

Width:  |  Height:  |  Size: 419 B

107
web/src/giphy.js Normal file
View File

@ -0,0 +1,107 @@
import {Component, html} from "../lib/htm/preact.js";
import * as widgetAPI from "./widget-api.js";
import {SearchBox} from "./search-box.js";
const GIPHY_SEARCH_DEBOUNCE = 1000
let GIPHY_API_KEY = "HQku8974Uq5MZn3MZns46kXn2R4GDm75"
let GIPHY_MXC_PREFIX = "mxc://giphy.mau.dev/"
export function giphyIsEnabled() {
return GIPHY_API_KEY !== ""
}
export function setGiphyAPIKey(apiKey, mxcPrefix) {
GIPHY_API_KEY = apiKey
if (mxcPrefix) {
GIPHY_MXC_PREFIX = mxcPrefix
}
}
export class GiphySearchTab extends Component {
constructor(props) {
super(props)
this.state = {
searchTerm: "",
gifs: [],
loading: false,
error: null,
}
this.handleGifClick = this.handleGifClick.bind(this)
this.searchKeyUp = this.searchKeyUp.bind(this)
this.updateGifSearchQuery = this.updateGifSearchQuery.bind(this)
this.searchTimeout = null
}
async makeGifSearchRequest() {
try {
const resp = await fetch(`https://api.giphy.com/v1/gifs/search?q=${this.state.searchTerm}&api_key=${GIPHY_API_KEY}`)
// TODO handle error responses properly?
const data = await resp.json()
if (data.data.length === 0) {
this.setState({gifs: [], error: "No results"})
} else {
this.setState({gifs: data.data, error: null})
}
} catch (error) {
this.setState({error})
}
}
componentWillUnmount() {
clearTimeout(this.searchTimeout)
}
searchKeyUp(event) {
if (event.key === "Enter") {
clearTimeout(this.searchTimeout)
this.makeGifSearchRequest()
}
}
updateGifSearchQuery(event) {
this.setState({searchTerm: event.target.value})
clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => this.makeGifSearchRequest(), GIPHY_SEARCH_DEBOUNCE)
}
handleGifClick(gif) {
widgetAPI.sendSticker({
"body": gif.title,
"info": {
"h": +gif.images.original.height,
"w": +gif.images.original.width,
"size": +gif.images.original.size,
"mimetype": "image/webp",
},
"msgtype": "m.image",
"url": GIPHY_MXC_PREFIX + gif.id,
"id": gif.id,
"filename": gif.id + ".webp",
})
}
render() {
// TODO display loading state?
return html`
<${SearchBox} onInput=${this.updateGifSearchQuery} onKeyUp=${this.searchKeyUp} value=${this.state.searchTerm} placeholder="Find GIFs"/>
<div class="pack-list">
<section class="stickerpack" id="pack-giphy">
<div class="error">
${this.state.error}
</div>
<div class="sticker-list">
${this.state.gifs.map((gif) => html`
<div class="sticker" onClick=${() => this.handleGifClick(gif)} data-gif-id=${gif.id}>
<img src=${gif.images.fixed_height.url} alt=${gif.title} class="visible" data=/>
</div>
`)}
</div>
<div class="footer powered-by-giphy">
<img src="./res/powered-by-giphy.png" alt="Powered by GIPHY"/>
</div>
</section>
</div>
`
}
}

View File

@ -13,36 +13,46 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { html, render, Component } from "../lib/htm/preact.js"
import { Spinner } from "./spinner.js"
import {html, render, Component} from "../lib/htm/preact.js"
import {Spinner} from "./spinner.js"
import {SearchBox} from "./search-box.js"
import {giphyIsEnabled, GiphySearchTab, setGiphyAPIKey} from "./giphy.js"
import * as widgetAPI from "./widget-api.js"
import * as frequent from "./frequently-used.js"
// The base URL for fetching packs. The app will first fetch ${PACK_BASE_URL}/index.json,
// then ${PACK_BASE_URL}/${packFile} for each packFile in the packs object of the index.json file.
const PACKS_BASE_URL = "packs"
// This is updated from packs/index.json
let HOMESERVER_URL = "https://matrix-client.matrix.org"
const makeThumbnailURL = mxc => `${HOMESERVER_URL}/_matrix/media/r0/thumbnail/${mxc.substr(6)}?height=128&width=128&method=scale`
let INDEX = `${PACKS_BASE_URL}/index.json`
const params = new URLSearchParams(document.location.search)
if (params.has('config')) {
INDEX = params.get("config")
}
const makeThumbnailURL = mxc => `${PACKS_BASE_URL}/thumbnails/${mxc.split("/").slice(-1)[0]}`
// We need to detect iOS webkit because it has a bug related to scrolling non-fixed divs
// This is also used to fix scrolling to sections on Element iOS
const isMobileSafari = navigator.userAgent.match(/(iPod|iPhone|iPad)/) && navigator.userAgent.match(/AppleWebKit/)
export const parseQuery = str => Object.fromEntries(
str.split("&")
.map(part => part.split("="))
.map(([key, value = ""]) => [key, value]))
const supportedThemes = ["light", "dark", "black"]
const defaultState = {
packs: [],
filtering: {
searchTerm: "",
packs: [],
},
}
class App extends Component {
constructor(props) {
super(props)
this.defaultTheme = parseQuery(location.search.substr(1)).theme
this.defaultTheme = params.get("theme")
this.state = {
packs: [],
viewingGifs: false,
packs: defaultState.packs,
loading: true,
error: null,
stickersPerRow: parseInt(localStorage.mauStickersPerRow || "4"),
@ -53,6 +63,7 @@ class App extends Component {
stickerIDs: frequent.get(),
stickers: [],
},
filtering: defaultState.filtering,
}
if (!supportedThemes.includes(this.state.theme)) {
this.state.theme = "light"
@ -65,6 +76,7 @@ class App extends Component {
this.imageObserver = null
this.packListRef = null
this.navRef = null
this.searchStickers = this.searchStickers.bind(this)
this.sendSticker = this.sendSticker.bind(this)
this.navScroll = this.navScroll.bind(this)
this.reloadPacks = this.reloadPacks.bind(this)
@ -89,6 +101,28 @@ class App extends Component {
localStorage.mauFrequentlyUsedStickerCache = JSON.stringify(stickers.map(sticker => [sticker.id, sticker]))
}
searchStickers(e) {
const sanitizeString = s => s.toLowerCase().trim()
const searchTerm = sanitizeString(e.target.value)
const allPacks = [this.state.frequentlyUsed, ...this.state.packs]
const packsWithFilteredStickers = allPacks.map(pack => ({
...pack,
stickers: pack.stickers.filter(sticker =>
sanitizeString(sticker.body).includes(searchTerm) ||
sanitizeString(sticker.id).includes(searchTerm)
),
}))
this.setState({
filtering: {
...this.state.filtering,
searchTerm,
packs: packsWithFilteredStickers.filter(({stickers}) => !!stickers.length),
},
})
}
setStickersPerRow(val) {
localStorage.mauStickersPerRow = val
document.documentElement.style.setProperty("--stickers-per-row", localStorage.mauStickersPerRow)
@ -101,23 +135,26 @@ class App extends Component {
setTheme(theme) {
if (theme === "default") {
delete localStorage.mauStickerThemeOverride
this.setState({ theme: this.defaultTheme })
this.setState({theme: this.defaultTheme})
} else {
localStorage.mauStickerThemeOverride = theme
this.setState({ theme: theme })
this.setState({theme: theme})
}
}
reloadPacks() {
this.imageObserver.disconnect()
this.sectionObserver.disconnect()
this.setState({ packs: [] })
this.setState({
packs: defaultState.packs,
filtering: defaultState.filtering,
})
this._loadPacks(true)
}
_loadPacks(disableCache = false) {
const cache = disableCache ? "no-cache" : undefined
fetch(`${PACKS_BASE_URL}/index.json`, { cache }).then(async indexRes => {
fetch(INDEX, {cache}).then(async indexRes => {
if (indexRes.status >= 400) {
this.setState({
loading: false,
@ -126,10 +163,17 @@ class App extends Component {
return
}
const indexData = await indexRes.json()
HOMESERVER_URL = indexData.homeserver_url || HOMESERVER_URL
if (indexData.giphy_api_key !== undefined) {
setGiphyAPIKey(indexData.giphy_api_key, indexData.giphy_mxc_prefix)
}
// TODO only load pack metadata when scrolled into view?
for (const packFile of indexData.packs) {
const packRes = await fetch(`${PACKS_BASE_URL}/${packFile}`, { cache })
let packRes
if (packFile.startsWith("https://") || packFile.startsWith("http://")) {
packRes = await fetch(packFile, {cache})
} else {
packRes = await fetch(`${PACKS_BASE_URL}/${packFile}`, {cache})
}
const packData = await packRes.json()
for (const sticker of packData.stickers) {
this.stickersByID.set(sticker.id, sticker)
@ -140,7 +184,7 @@ class App extends Component {
})
}
this.updateFrequentlyUsed()
}, error => this.setState({ loading: false, error }))
}, error => this.setState({loading: false, error}))
}
componentDidMount() {
@ -172,6 +216,9 @@ class App extends Component {
let maxXElem = null
for (const entry of intersections) {
const packID = entry.target.getAttribute("data-pack-id")
if (!packID) {
continue
}
const navElement = document.getElementById(`nav-${packID}`)
if (entry.isIntersecting) {
navElement.classList.add("visible")
@ -188,9 +235,9 @@ class App extends Component {
}
}
if (minXElem !== null) {
minXElem.scrollIntoView({ inline: "start" })
minXElem.scrollIntoView({inline: "start"})
} else if (maxXElem !== null) {
maxXElem.scrollIntoView({ inline: "end" })
maxXElem.scrollIntoView({inline: "end"})
}
}
@ -220,37 +267,72 @@ class App extends Component {
}
navScroll(evt) {
this.navRef.scrollLeft += evt.deltaY * 12
this.navRef.scrollLeft += evt.deltaY
}
render() {
const theme = `theme-${this.state.theme}`
const filterActive = !!this.state.filtering.searchTerm
const packs = filterActive
? this.state.filtering.packs
: [this.state.frequentlyUsed, ...this.state.packs]
if (this.state.loading) {
return html`<main class="spinner ${theme}"><${Spinner} size=${80} green /></main>`
return html`
<main class="spinner ${theme}">
<${Spinner} size=${80} green/>
</main>
`
} else if (this.state.error) {
return html`<main class="error ${theme}">
<h1>Failed to load packs</h1>
<p>${this.state.error}</p>
</main>`
return html`
<main class="error ${theme}">
<h1>Failed to load packs</h1>
<p>${this.state.error}</p>
</main>
`
} else if (this.state.packs.length === 0) {
return html`<main class="empty ${theme}"><h1>No packs found 😿</h1></main>`
return html`
<main class="empty ${theme}"><h1>No packs found 😿</h1></main>
`
}
return html`<main class="has-content ${theme}">
<nav onWheel=${this.navScroll} ref=${elem => this.navRef = elem}>
<${NavBarItem} pack=${this.state.frequentlyUsed} iconOverride="recent" />
${this.state.packs.map(pack => html`<${NavBarItem} id=${pack.id} pack=${pack}/>`)}
<${NavBarItem} pack=${{ id: "settings", title: "Settings" }} iconOverride="settings" />
</nav>
<div class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}" ref=${elem => this.packListRef = elem}>
<${Pack} pack=${this.state.frequentlyUsed} send=${this.sendSticker} />
${this.state.packs.map(pack => html`<${Pack} id=${pack.id} pack=${pack} send=${this.sendSticker} />`)}
<${Settings} app=${this}/>
</div>
</main>`
const onClickOverride = this.state.viewingGifs
? (evt, packID) => {
evt.preventDefault()
this.setState({viewingGifs: false}, () => {
scrollToSection(null, packID)
})
} : null
const switchToGiphy = () => this.setState({viewingGifs: true, filtering: defaultState.filtering})
return html`
<main class="has-content ${theme}">
<nav onWheel=${this.navScroll} ref=${elem => this.navRef = elem}>
${giphyIsEnabled() && html`
<${NavBarItem} pack=${{id: "giphy", title: "GIPHY"}} iconOverride="giphy" onClickOverride=${switchToGiphy} extraClass=${this.state.viewingGifs ? "visible" : ""}/>
`}
<${NavBarItem} pack=${this.state.frequentlyUsed} iconOverride="recent" onClickOverride=${onClickOverride}/>
${this.state.packs.map(pack => html`<${NavBarItem} id=${pack.id} pack=${pack} onClickOverride=${onClickOverride}/>`)}
<${NavBarItem} pack=${{id: "settings", title: "Settings"}} iconOverride="settings" onClickOverride=${onClickOverride}/>
</nav>
${this.state.viewingGifs ? html`
<${GiphySearchTab}/>
` : html`
<${SearchBox} onInput=${this.searchStickers} value=${this.state.filtering.searchTerm ?? ""}/>
<div class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}" ref=${(elem) => (this.packListRef = elem)}>
${filterActive && packs.length === 0
? html`<div class="search-empty"><h1>No stickers match your search</h1></div>`
: null}
${packs.map((pack) => html`<${Pack} id=${pack.id} pack=${pack} send=${this.sendSticker}/>`)}
<${Settings} app=${this}/>
</div>
`}
</main>`
}
}
const Settings = ({ app }) => html`
const Settings = ({app}) => html`
<section class="stickerpack settings" id="pack-settings" data-pack-id="settings">
<h1>Settings</h1>
<div class="settings-list">
@ -259,7 +341,7 @@ const Settings = ({ app }) => html`
<label for="stickers-per-row">Stickers per row: ${app.state.stickersPerRow}</label>
<input type="range" min=2 max=10 id="stickers-per-row" id="stickers-per-row"
value=${app.state.stickersPerRow}
onInput=${evt => app.setStickersPerRow(evt.target.value)} />
onInput=${evt => app.setStickersPerRow(evt.target.value)}/>
</div>
<div>
<label for="theme">Theme: </label>
@ -278,13 +360,15 @@ const Settings = ({ app }) => html`
// open the link in the browser instead of just scrolling there, so we need to scroll manually:
const scrollToSection = (evt, id) => {
const pack = document.getElementById(`pack-${id}`)
pack.scrollIntoView({ block: "start", behavior: "instant" })
evt.preventDefault()
if (pack) {
pack.scrollIntoView({block: "start", behavior: "instant"})
}
evt?.preventDefault()
}
const NavBarItem = ({ pack, iconOverride = null }) => html`
<a href="#pack-${pack.id}" id="nav-${pack.id}" data-pack-id=${pack.id} title=${pack.title}
onClick=${isMobileSafari ? (evt => scrollToSection(evt, pack.id)) : undefined}>
const NavBarItem = ({pack, iconOverride = null, onClickOverride = null, extraClass = null}) => html`
<a href="#pack-${pack.id}" id="nav-${pack.id}" data-pack-id=${pack.id} title=${pack.title} class="${extraClass}"
onClick=${onClickOverride ? (evt => onClickOverride(evt, pack.id)) : (isMobileSafari ? (evt => scrollToSection(evt, pack.id)) : undefined)}>
<div class="sticker">
${iconOverride ? html`
<span class="icon icon-${iconOverride}"/>
@ -296,7 +380,7 @@ const NavBarItem = ({ pack, iconOverride = null }) => html`
</a>
`
const Pack = ({ pack, send }) => html`
const Pack = ({pack, send}) => html`
<section class="stickerpack" id="pack-${pack.id}" data-pack-id=${pack.id}>
<h1>${pack.title}</h1>
<div class="sticker-list">
@ -307,10 +391,10 @@ const Pack = ({ pack, send }) => html`
</section>
`
const Sticker = ({ content, send }) => html`
const Sticker = ({content, send}) => html`
<div class="sticker" onClick=${send} data-sticker-id=${content.id}>
<img data-src=${makeThumbnailURL(content.url)} alt=${content.body} />
<img data-src=${makeThumbnailURL(content.url)} alt=${content.body} title=${content.body}/>
</div>
`
render(html`<${App} />`, document.body)
render(html`<${App}/>`, document.body)

26
web/src/search-box.js Normal file
View File

@ -0,0 +1,26 @@
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import {html} from "../lib/htm/preact.js"
export const SearchBox = ({onInput, onKeyUp, value, placeholder = 'Find stickers'}) => {
const component = html`
<div class="search-box">
<input type="text" placeholder=${placeholder} value=${value} onInput=${onInput} onKeyUp=${onKeyUp}/>
<span class="icon icon-search"/>
</div>
`
return component
}

View File

@ -60,8 +60,9 @@ export function sendSticker(content) {
const widgetData = {
...data,
description: content.body,
file: `${content.id}.png`,
file: content.filename ?? `${content.id}.png`,
}
delete widgetData.content.filename
// Element iOS explodes if there are extra fields present
delete widgetData.content["net.maunium.telegram.sticker"]

View File

@ -1 +1 @@
*{font-family:sans-serif}body{margin:0}h1{font-size:1rem}:root{--stickers-per-row: 4;--sticker-size: calc(100vw / var(--stickers-per-row))}main{color:var(--text-color)}main.spinner{margin-top:5rem}main.error,main.empty{margin:2rem}main.empty{text-align:center}main.has-content{position:fixed;top:0;left:0;right:0;bottom:0;display:grid;grid-template-rows:calc(12vw + 2px) auto}main.theme-light{--highlight-color: #eee;--text-color: black;background-color:white}main.theme-dark{--highlight-color: #444;--text-color: white;background-color:#22262e}main.theme-black{--highlight-color: #222;--text-color: white;background-color:black}.icon{width:100%;height:100%;background-color:var(--text-color);mask-size:contain;-webkit-mask-size:contain;mask-image:var(--icon-image);-webkit-mask-image:var(--icon-image)}.icon.icon-settings{--icon-image: url(../res/settings.svg)}.icon.icon-recent{--icon-image: url(../res/recent.svg)}nav{display:flex;overflow-x:auto}nav>a{border-bottom:2px solid transparent}nav>a.visible{border-bottom-color:green}nav>a>div.sticker{width:12vw;height:12vw}div.pack-list,nav{scrollbar-width:none}div.pack-list::-webkit-scrollbar,nav::-webkit-scrollbar{display:none}div.pack-list{overflow-y:auto}div.pack-list.ios-safari-hack{position:fixed;top:calc(12vw + 2px);bottom:0;left:0;right:0;-webkit-overflow-scrolling:touch}section.stickerpack{margin-top:.75rem}section.stickerpack>div.sticker-list{display:flex;flex-wrap:wrap}section.stickerpack>h1{margin:0 0 0 .75rem}div.sticker{display:flex;padding:4px;cursor:pointer;position:relative;width:var(--sticker-size);height:var(--sticker-size);box-sizing:border-box}div.sticker:hover{background-color:var(--highlight-color)}div.sticker>img{display:none;width:100%;object-fit:contain}div.sticker>img.visible{display:initial}div.sticker>.icon{width:70%;height:70%;margin:15%}div.settings-list{display:flex;flex-direction:column}div.settings-list>*{margin:.5rem}div.settings-list button{padding:.5rem;border-radius:.25rem}div.settings-list input{width:100%}
*{font-family:sans-serif}body{margin:0}h1{font-size:1rem}:root{--stickers-per-row: 4;--sticker-size: calc(100vw / var(--stickers-per-row))}main{color:var(--text-color)}main.spinner{margin-top:5rem}main.error,main.empty{margin:2rem}main.empty{text-align:center}main.has-content{position:fixed;top:0;left:0;right:0;bottom:0;display:grid;grid-template-rows:calc(12vw + 2px) min-content auto}main.theme-light{--highlight-color: #eee;--search-box-color: var(--highlight-color);--text-color: black;background-color:#fff}main.theme-dark{--highlight-color: #444;--search-box-color: #383e4b;--text-color: white;background-color:#22262e}main.theme-dark .icon.icon-giphy{background-image:url(../res/giphy-dark.svg)}main.theme-black{--highlight-color: #222;--search-box-color: var(--highlight-color);--text-color: white;background-color:#000}main.theme-black .icon.icon-giphy{background-image:url(../res/giphy-dark.svg)}div.powered-by-giphy{padding:1rem}div.powered-by-giphy>img{width:100%}.icon{width:100%;height:100%;background-color:var(--text-color);mask-size:contain;-webkit-mask-size:contain;mask-image:var(--icon-image);-webkit-mask-image:var(--icon-image)}.icon.icon-settings{--icon-image: url(../res/settings.svg)}.icon.icon-recent{--icon-image: url(../res/recent.svg)}.icon.icon.icon-search{--icon-image: url(../res/search.svg)}.icon.icon.icon-giphy{background:center/contain no-repeat url(../res/giphy-light.svg);mask:unset}nav{display:flex;overflow-x:auto}nav>a{border-bottom:2px solid rgba(0,0,0,0)}nav>a.visible{border-bottom-color:green}nav>a>div.sticker{width:12vw;height:12vw}div.pack-list,nav{scrollbar-width:none}div.pack-list::-webkit-scrollbar,nav::-webkit-scrollbar{display:none}div.pack-list{overflow-y:auto}div.pack-list.ios-safari-hack{position:fixed;top:calc(calc(12vw + 2px) + calc(2 * 0.7rem + 2 * 0.5rem + 1rem));bottom:0;left:0;right:0;-webkit-overflow-scrolling:touch}div.search-empty{margin:1.2rem;text-align:center}section.stickerpack{margin-top:.75rem}section.stickerpack>div.sticker-list{display:flex;flex-wrap:wrap}section.stickerpack>h1{margin:0 0 0 .75rem}section.stickerpack#pack-giphy{display:flex;justify-content:space-between;flex-direction:column;min-height:100%}div.sticker{display:flex;padding:4px;cursor:pointer;position:relative;width:var(--sticker-size);height:var(--sticker-size);box-sizing:border-box}div.sticker:hover{background-color:var(--highlight-color)}div.sticker>img{display:none;width:100%;object-fit:contain}div.sticker>img.visible{display:initial}div.sticker>.icon{width:70%;height:70%;margin:15%}div.search-box{position:relative;display:flex}div.search-box>input[type=text]{flex-grow:1;background-color:var(--search-box-color);outline:none;border:none;border-radius:.25rem;height:1rem;padding:.7rem;padding-right:calc(1rem + 0.7rem);margin:.5rem;font-size:1rem;color:var(--text-color)}div.search-box>span.icon{display:flex;position:absolute;top:calc(50% - 1rem/2);right:1rem;width:1rem;height:1rem;box-sizing:border-box}div.settings-list{display:flex;flex-direction:column}div.settings-list>*{margin:.5rem}div.settings-list button{padding:.5rem;border-radius:.25rem}div.settings-list input{width:100%}

View File

@ -32,6 +32,12 @@ $nav-bottom-highlight: 2px
$nav-height: calc(#{$nav-sticker-size} + #{$nav-bottom-highlight})
$nav-height-inverse: calc(-#{$nav-sticker-size} - #{$nav-bottom-highlight})
$search-box-icon-size: 1rem
$search-box-input-height: 1rem
$search-box-input-padding: .7rem
$search-box-input-margin: .5rem
$search-box-height: calc(2 * #{$search-box-input-padding} + 2 * #{$search-box-input-margin} + #{$search-box-input-height})
main
color: var(--text-color)
@ -50,25 +56,38 @@ main
left: 0
right: 0
bottom: 0
display: grid
grid-template-rows: $nav-height auto
grid-template-rows: $nav-height min-content auto
main.theme-light
--highlight-color: #eee
--search-box-color: var(--highlight-color)
--text-color: black
background-color: white
main.theme-dark
--highlight-color: #444
--search-box-color: #383e4b
--text-color: white
background-color: #22262e
.icon.icon-giphy
background-image: url(../res/giphy-dark.svg)
main.theme-black
--highlight-color: #222
--search-box-color: var(--highlight-color)
--text-color: white
background-color: black
.icon.icon-giphy
background-image: url(../res/giphy-dark.svg)
div.powered-by-giphy
padding: 1rem
> img
width: 100%
.icon
width: 100%
height: 100%
@ -84,6 +103,13 @@ main.theme-black
&.icon-recent
--icon-image: url(../res/recent.svg)
&.icon.icon-search
--icon-image: url(../res/search.svg)
&.icon.icon-giphy
background: center / contain no-repeat url(../res/giphy-light.svg)
mask: unset
nav
display: flex
overflow-x: auto
@ -109,12 +135,16 @@ div.pack-list
div.pack-list.ios-safari-hack
position: fixed
top: $nav-height
top: calc(#{$nav-height} + #{$search-box-height})
bottom: 0
left: 0
right: 0
-webkit-overflow-scrolling: touch
div.search-empty
margin: 1.2rem
text-align: center
section.stickerpack
margin-top: .75rem
@ -125,6 +155,12 @@ section.stickerpack
> h1
margin: 0 0 0 .75rem
section.stickerpack#pack-giphy
display: flex
justify-content: space-between
flex-direction: column
min-height: 100%
div.sticker
display: flex
padding: 4px
@ -150,6 +186,32 @@ div.sticker
height: 70%
margin: 15%
div.search-box
position: relative
display: flex
>input[type="text"]
flex-grow: 1
background-color: var(--search-box-color)
outline: none
border: none
border-radius: .25rem
height: $search-box-input-height
padding: $search-box-input-padding
padding-right: calc(#{$search-box-icon-size} + #{$search-box-input-padding})
margin: $search-box-input-margin
font-size: 1rem
color: var(--text-color)
>span.icon
display: flex
position: absolute
top: calc(50% - #{$search-box-icon-size} / 2)
right: $search-box-icon-size
width: $search-box-icon-size
height: $search-box-icon-size
box-sizing: border-box
div.settings-list
display: flex
flex-direction: column

View File

@ -1 +1 @@
.sk-center-wrapper{width:100%;display:flex;justify-content:space-around}.sk-chase{position:relative;animation:sk-chase 2.5s infinite linear both}.sk-chase.green>.sk-chase-dot:before{background-color:#00C853}.sk-chase>.sk-chase-dot{width:100%;height:100%;position:absolute;left:0;top:0;animation:sk-chase-dot 2.0s infinite ease-in-out both}.sk-chase>.sk-chase-dot:before{content:'';display:block;width:25%;height:25%;border-radius:100%;animation:sk-chase-dot-before 2.0s infinite ease-in-out both;background-color:#FFF}.sk-chase>.sk-chase-dot:nth-child(1){animation-delay:-1.1s}.sk-chase>.sk-chase-dot:nth-child(2){animation-delay:-1.0s}.sk-chase>.sk-chase-dot:nth-child(3){animation-delay:-0.9s}.sk-chase>.sk-chase-dot:nth-child(4){animation-delay:-0.8s}.sk-chase>.sk-chase-dot:nth-child(5){animation-delay:-0.7s}.sk-chase>.sk-chase-dot:nth-child(6){animation-delay:-0.6s}.sk-chase>.sk-chase-dot:nth-child(1):before{animation-delay:-1.1s}.sk-chase>.sk-chase-dot:nth-child(2):before{animation-delay:-1.0s}.sk-chase>.sk-chase-dot:nth-child(3):before{animation-delay:-0.9s}.sk-chase>.sk-chase-dot:nth-child(4):before{animation-delay:-0.8s}.sk-chase>.sk-chase-dot:nth-child(5):before{animation-delay:-0.7s}.sk-chase>.sk-chase-dot:nth-child(6):before{animation-delay:-0.6s}@keyframes sk-chase{100%{transform:rotate(360deg)}}@keyframes sk-chase-dot{80%,100%{transform:rotate(360deg)}}@keyframes sk-chase-dot-before{50%{transform:scale(0.4)}100%,0%{transform:scale(1)}}
.sk-center-wrapper{width:100%;display:flex;justify-content:space-around}.sk-chase{position:relative;animation:sk-chase 2.5s infinite linear both}.sk-chase.green>.sk-chase-dot:before{background-color:#00c853}.sk-chase>.sk-chase-dot{width:100%;height:100%;position:absolute;left:0;top:0;animation:sk-chase-dot 2s infinite ease-in-out both}.sk-chase>.sk-chase-dot:before{content:"";display:block;width:25%;height:25%;border-radius:100%;animation:sk-chase-dot-before 2s infinite ease-in-out both;background-color:#fff}.sk-chase>.sk-chase-dot:nth-child(1){animation-delay:-1.1s}.sk-chase>.sk-chase-dot:nth-child(2){animation-delay:-1s}.sk-chase>.sk-chase-dot:nth-child(3){animation-delay:-0.9s}.sk-chase>.sk-chase-dot:nth-child(4){animation-delay:-0.8s}.sk-chase>.sk-chase-dot:nth-child(5){animation-delay:-0.7s}.sk-chase>.sk-chase-dot:nth-child(6){animation-delay:-0.6s}.sk-chase>.sk-chase-dot:nth-child(1):before{animation-delay:-1.1s}.sk-chase>.sk-chase-dot:nth-child(2):before{animation-delay:-1s}.sk-chase>.sk-chase-dot:nth-child(3):before{animation-delay:-0.9s}.sk-chase>.sk-chase-dot:nth-child(4):before{animation-delay:-0.8s}.sk-chase>.sk-chase-dot:nth-child(5):before{animation-delay:-0.7s}.sk-chase>.sk-chase-dot:nth-child(6):before{animation-delay:-0.6s}@keyframes sk-chase{100%{transform:rotate(360deg)}}@keyframes sk-chase-dot{80%,100%{transform:rotate(360deg)}}@keyframes sk-chase-dot-before{50%{transform:scale(0.4)}100%,0%{transform:scale(1)}}

File diff suppressed because it is too large Load Diff