Compare commits

..

35 Commits

Author SHA1 Message Date
f090cc076e Merge branch 'maunium:master' into feat/search-focus 2023-01-08 21:53:27 +01:00
f59406a47a Add missing parameter to getting sticker sets. Fixes #51 2022-11-15 12:58:38 +02:00
31cc608a2b use visibility from widget API to autofocus on frame opening when required 2022-02-06 19:08:03 +01:00
9b87f7436f handle focus for iframe 2022-02-06 18:41:40 +01:00
26ab8e2821 properly handle mobile devices for autofocus 2022-02-06 16:25:38 +01:00
e30535a975 ignore autofocus issues on mobiles (disabled by design) 2022-02-06 16:04:39 +01:00
a197bda145 remove autofocus property for android 2022-02-06 15:58:28 +01:00
909aaf8d44 improve for mobile support 2022-02-06 15:47:38 +01:00
ad26574762 use flex instead of absolute for search field css 2022-02-06 12:19:17 +01:00
fed4f08218 handle autofocus search + setting on/off 2022-02-06 12:18:11 +01:00
41f0432e8d handle resetting search field 2022-02-06 12:17:29 +01: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
69 changed files with 584 additions and 5601 deletions

View File

@ -1,19 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.py]
max_line_length = 99
[*.js]
max_line_length = 100
indent_style = tab
[*.{json,sass}]
indent_size = 2

2
.gitignore vendored
View File

@ -12,5 +12,3 @@ web/lib/import-map.json
*.session
/*.json
*.bak
*.log
config.yaml

View File

@ -1,4 +0,0 @@
include README.md
include LICENSE
include requirements.txt
include optional-requirements.txt

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 | ❌ | ✔️ | ✔️ |

View File

@ -1,11 +0,0 @@
# Format: #/name defines a new extras_require group called name
# Uncommented lines after the group definition insert things into that group.
#/server
mautrix==0.8.0rc4
asyncpg>=0.20,<0.22
attrs
setuptools
aiodns
ruamel.yaml
jsonschema

View File

@ -1,6 +1,6 @@
aiohttp>=3,<4
yarl>=1,<2
aiohttp
yarl
pillow
telethon>=1.16
telethon
cryptg
python-magic

View File

@ -5,19 +5,6 @@ from sticker.get_version import git_tag, git_revision, version, linkified_versio
with open("requirements.txt") as reqs:
install_requires = reqs.read().splitlines()
with open("optional-requirements.txt") as reqs:
extras_require = {}
current = []
for line in reqs.read().splitlines():
if line.startswith("#/"):
extras_require[line[2:]] = current = []
elif not line or line.startswith("#"):
continue
else:
current.append(line)
extras_require["all"] = list({dep for deps in extras_require.values() for dep in deps})
try:
long_desc = open("README.md").read()
except IOError:
@ -47,7 +34,6 @@ setuptools.setup(
packages=setuptools.find_packages(),
install_requires=install_requires,
extras_require=extras_require,
python_requires="~=3.6",
classifiers=[
@ -62,14 +48,7 @@ setuptools.setup(
"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-server=sticker.server:cmd",
]},
package_data={"sticker.server": [
"example-config.yaml",
"frontend/index.html", "frontend/setup/index.html",
"frontend/src/*", "frontend/lib/*/*.js", "frontend/res/*", "frontend/style/*.css",
], "sticker.server.api": ["pack.schema.json"]}
)

View File

@ -42,6 +42,7 @@ if TYPE_CHECKING:
url: str
info: MediaInfo
id: str
msgtype: str
else:
MediaInfo = None
StickerInfo = None
@ -59,6 +60,8 @@ async def load_config(path: str) -> None:
homeserver_url = input("Homeserver URL: ")
access_token = input("Access token: ")
whoami_url = URL(homeserver_url) / "_matrix" / "client" / "r0" / "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({

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 functools import partial
from io import BytesIO
import os.path
import json
@ -21,6 +22,7 @@ from PIL import Image
from . import matrix
open_utf8 = partial(open, encoding='UTF-8')
def convert_image(data: bytes) -> (bytes, int, int):
image: Image.Image = Image.open(BytesIO(data)).convert("RGBA")
@ -41,7 +43,7 @@ def convert_image(data: bytes) -> (bytes, int, int):
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 +51,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 +76,5 @@ def make_sticker(mxc: str, width: int, height: int, size: int,
"mimetype": "image/png",
},
},
"msgtype": "m.sticker",
}

View File

@ -93,7 +93,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:
@ -112,14 +112,14 @@ async def main(args: argparse.Namespace) -> None:
if sticker:
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_to_index(picker_file_name, args.add_to_index)

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

@ -1,53 +0,0 @@
# 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/>.
from mautrix.util.program import Program
from mautrix.util.async_db import Database
from .config import Config
from .server import Server
from .database import upgrade_table, Base
from ..version import version
class StickerServer(Program):
module = "sticker.server"
name = "maunium-stickerpicker server"
version = version
command = "python -m sticker.server"
description = "Server for maunium-stickerpicker"
config_class = Config
config: Config
server: Server
database: Database
async def start(self) -> None:
self.database = Database(url=self.config["database"], upgrade_table=upgrade_table)
Base.db = self.database
self.server = Server(self.config)
await self.database.start()
await self.server.start()
await super().start()
async def stop(self) -> None:
await super().stop()
await self.server.stop()
StickerServer().run()

View File

@ -1,38 +0,0 @@
# 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/>.
from aiohttp import web
from ..config import Config
from .auth import (routes as auth_routes, init as auth_init,
token_middleware, widget_secret_middleware)
from .fed_connector import init as init_fed_connector
from .packs import routes as packs_routes, init as packs_init
from .setup import routes as setup_routes
integrations_app = web.Application()
integrations_app.add_routes(auth_routes)
packs_app = web.Application(middlewares=[widget_secret_middleware])
packs_app.add_routes(packs_routes)
setup_app = web.Application(middlewares=[token_middleware])
setup_app.add_routes(setup_routes)
def init(config: Config) -> None:
init_fed_connector()
auth_init(config)
packs_init(config)

View File

@ -1,216 +0,0 @@
# 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/>.
from typing import Tuple, Callable, Awaitable, Optional, TYPE_CHECKING
import logging
import json
from mautrix.client import Client
from mautrix.types import UserID
from mautrix.util.logging import TraceLogger
from aiohttp import web, hdrs, ClientError, ClientSession
from yarl import URL
from ..database import AccessToken, User
from ..config import Config
from .errors import Error
from . import fed_connector
if TYPE_CHECKING:
from typing import TypedDict
class OpenIDPayload(TypedDict):
access_token: str
token_type: str
matrix_server_name: str
expires_in: int
class OpenIDResponse(TypedDict):
sub: str
Handler = Callable[[web.Request], Awaitable[web.Response]]
log: TraceLogger = logging.getLogger("mau.api.auth")
routes = web.RouteTableDef()
config: Config
def get_ip(request: web.Request) -> str:
if config["server.trust_forward_headers"]:
try:
return request.headers["X-Forwarded-For"]
except KeyError:
pass
return request.remote
def get_auth_header(request: web.Request) -> str:
try:
auth = request.headers["Authorization"]
if not auth.startswith("Bearer "):
raise Error.invalid_auth_header
return auth[len("Bearer "):]
except KeyError:
raise Error.missing_auth_header
async def get_user(request: web.Request) -> Tuple[User, AccessToken]:
auth = get_auth_header(request)
try:
token_id, token_val = auth.split(":")
token_id = int(token_id)
except ValueError:
raise Error.invalid_auth_token
token = await AccessToken.get(token_id)
if not token or not token.check(token_val):
raise Error.invalid_auth_token
elif token.expired:
raise Error.auth_token_expired
await token.update_ip(get_ip(request))
return await User.get(token.user_id), token
@web.middleware
async def token_middleware(request: web.Request, handler: Handler) -> web.Response:
if request.method == hdrs.METH_OPTIONS:
return await handler(request)
user, token = await get_user(request)
request["user"] = user
request["token"] = token
return await handler(request)
async def get_widget_user(request: web.Request) -> User:
try:
user_id = UserID(request.headers["X-Matrix-User-ID"])
except KeyError:
raise Error.missing_user_id_header
user = await User.get(user_id)
if user is None:
raise Error.user_not_found
return user
@web.middleware
async def widget_secret_middleware(request: web.Request, handler: Handler) -> web.Response:
if request.method == hdrs.METH_OPTIONS:
return await handler(request)
user = await get_widget_user(request)
request["user"] = user
return await handler(request)
account_cors_headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "OPTIONS, GET, POST",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
}
@routes.get("/account")
async def get_auth(request: web.Request) -> web.Response:
user, token = await get_user(request)
return web.json_response({"user_id": token.user_id}, headers=account_cors_headers)
async def check_openid_token(homeserver: str, token: str) -> Optional[UserID]:
server_info = await fed_connector.resolve_server_name(homeserver)
headers = {"Host": server_info.host_header}
userinfo_url = URL.build(scheme="https", host=server_info.host, port=server_info.port,
path="/_matrix/federation/v1/openid/userinfo",
query={"access_token": token})
try:
async with fed_connector.http.get(userinfo_url, headers=headers) as resp:
data: 'OpenIDResponse' = await resp.json()
return UserID(data["sub"])
except (ClientError, json.JSONDecodeError, KeyError, ValueError) as e:
log.debug(f"Failed to check OpenID token from {homeserver}", exc_info=True)
return None
@routes.route(hdrs.METH_OPTIONS, "/account/register")
@routes.route(hdrs.METH_OPTIONS, "/account/logout")
@routes.route(hdrs.METH_OPTIONS, "/account")
async def cors_token(_: web.Request) -> web.Response:
return web.Response(status=200, headers=account_cors_headers)
async def resolve_client_well_known(server_name: str) -> str:
url = URL.build(scheme="https", host=server_name, port=443, path="/.well-known/matrix/client")
async with ClientSession() as sess, sess.get(url) as resp:
data = await resp.json()
return data["m.homeserver"]["base_url"]
@routes.post("/account/register")
async def exchange_token(request: web.Request) -> web.Response:
try:
data: 'OpenIDPayload' = await request.json()
except json.JSONDecodeError:
raise Error.request_not_json
try:
matrix_server_name = data["matrix_server_name"]
access_token = data["access_token"]
except KeyError:
raise Error.invalid_openid_payload
log.trace(f"Validating OpenID token from {matrix_server_name}")
user_id = await check_openid_token(matrix_server_name, access_token)
if user_id is None:
raise Error.invalid_openid_token
_, homeserver = Client.parse_user_id(user_id)
if homeserver != data["matrix_server_name"]:
raise Error.homeserver_mismatch
permissions = config.get_permissions(user_id)
if not permissions.access:
raise Error.no_access
try:
log.trace(f"Trying to resolve {matrix_server_name}'s client .well-known")
homeserver_url = await resolve_client_well_known(matrix_server_name)
log.trace(f"Got {homeserver_url} from {matrix_server_name}'s client .well-known")
except (ClientError, json.JSONDecodeError, KeyError, ValueError, TypeError):
log.trace(f"Failed to resolve {matrix_server_name}'s client .well-known", exc_info=True)
raise Error.client_well_known_error
user = await User.get(user_id)
if user is None:
log.debug(f"Creating user {user_id} with homeserver client URL {homeserver_url}")
user = User.new(user_id, homeserver_url=homeserver_url)
await user.insert()
elif user.homeserver_url != homeserver_url:
log.debug(f"Updating {user_id}'s homeserver client URL from {user.homeserver_url} "
f"to {homeserver_url}")
await user.set_homeserver_url(homeserver_url)
token = await user.new_access_token(get_ip(request))
return web.json_response({
"user_id": user_id,
"token": token,
"permissions": permissions._asdict(),
}, headers=account_cors_headers)
@routes.post("/account/logout")
async def logout(request: web.Request) -> web.Response:
user, token = await get_user(request)
await token.delete()
return web.json_response({}, status=204, headers=account_cors_headers)
def init(cfg: Config) -> None:
global config
config = cfg

View File

@ -1,119 +0,0 @@
# 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/>.
from typing import Dict, Optional
from collections import deque
import json
from aiohttp import web
class _ErrorMeta:
def __init__(self, *args, **kwargs) -> None:
pass
@staticmethod
def _make_error(errcode: str, error: str) -> Dict[str, str]:
return {
"body": json.dumps({
"error": error,
"errcode": errcode,
}).encode("utf-8"),
"content_type": "application/json",
"headers": {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "OPTIONS, GET, POST, PUT, DELETE, HEAD",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
}
}
@property
def request_not_json(self) -> web.HTTPException:
return web.HTTPBadRequest(**self._make_error("M_NOT_JSON",
"Request body is not valid JSON"))
@property
def missing_auth_header(self) -> web.HTTPException:
return web.HTTPForbidden(**self._make_error("M_MISSING_TOKEN",
"Missing authorization header"))
@property
def missing_user_id_header(self) -> web.HTTPException:
return web.HTTPForbidden(**self._make_error("NET.MAUNIUM_MISSING_USER_ID",
"Missing user ID header"))
@property
def user_not_found(self) -> web.HTTPException:
return web.HTTPNotFound(**self._make_error("NET.MAUNIUM_USER_NOT_FOUND",
"User not found"))
@property
def invalid_auth_header(self) -> web.HTTPException:
return web.HTTPForbidden(**self._make_error("M_UNKNOWN_TOKEN",
"Invalid authorization header"))
@property
def invalid_auth_token(self) -> web.HTTPException:
return web.HTTPForbidden(**self._make_error("M_UNKNOWN_TOKEN",
"Invalid authorization token"))
@property
def auth_token_expired(self) -> web.HTTPException:
return web.HTTPForbidden(**self._make_error("NET.MAUNIUM_TOKEN_EXPIRED",
"Authorization token has expired"))
@property
def invalid_openid_payload(self) -> web.HTTPException:
return web.HTTPBadRequest(**self._make_error("M_BAD_JSON", "Missing one or more "
"fields in OpenID payload"))
@property
def invalid_openid_token(self) -> web.HTTPException:
return web.HTTPForbidden(**self._make_error("M_UNKNOWN_TOKEN",
"Invalid OpenID token"))
@property
def no_access(self) -> web.HTTPException:
return web.HTTPUnauthorized(**self._make_error(
"M_UNAUTHORIZED",
"You are not authorized to access this maunium-stickerpicker instance"))
@property
def homeserver_mismatch(self) -> web.HTTPException:
return web.HTTPUnauthorized(**self._make_error(
"M_UNAUTHORIZED", "Request matrix_server_name and OpenID sub homeserver don't match"))
@property
def pack_not_found(self) -> web.HTTPException:
return web.HTTPNotFound(**self._make_error("NET.MAUNIUM_PACK_NOT_FOUND",
"Sticker pack not found"))
def schema_error(self, message: str, path: Optional[deque] = None) -> web.HTTPException:
if path:
path_str = "in " + "".join(str(part) for part in path)
else:
path_str = "at top level"
return web.HTTPBadRequest(**self._make_error(
"M_BAD_REQUEST", f"Schema validation error {path_str}: {message}"))
@property
def client_well_known_error(self) -> web.HTTPException:
return web.HTTPForbidden(**self._make_error("NET.MAUNIUM_CLIENT_WELL_KNOWN_ERROR",
"Failed to resolve homeserver URL "
"from client .well-known"))
class Error(metaclass=_ErrorMeta):
pass

View File

@ -1,110 +0,0 @@
from typing import Tuple, Any, NamedTuple, Dict, Optional
from time import time
import ipaddress
import logging
import asyncio
import json
from mautrix.util.logging import TraceLogger
from aiohttp import ClientRequest, TCPConnector, ClientSession, ClientTimeout, ClientError
from aiohttp.client_proto import ResponseHandler
from yarl import URL
import aiodns
log: TraceLogger = logging.getLogger("mau.federation")
class ResolvedServerName(NamedTuple):
host_header: str
host: str
port: int
expire: int
class ServerNameSplit(NamedTuple):
host: str
port: Optional[int]
is_ip: bool
dns_resolver: aiodns.DNSResolver
http: ClientSession
server_name_cache: Dict[str, ResolvedServerName] = {}
class MatrixFederationTCPConnector(TCPConnector):
"""An extension to aiohttp's TCPConnector that correctly sets the TLS SNI for Matrix federation
requests, where the TCP host may not match the SNI/Host header."""
async def _wrap_create_connection(self, *args: Any, server_hostname: str, req: ClientRequest,
**kwargs: Any) -> Tuple[asyncio.Transport, ResponseHandler]:
split = parse_server_name(req.headers["Host"])
return await super()._wrap_create_connection(*args, server_hostname=split.host,
req=req, **kwargs)
def parse_server_name(name: str) -> ServerNameSplit:
port_split = name.rsplit(":", 1)
if len(port_split) == 2 and port_split[1].isdecimal():
name, port = port_split
else:
port = None
try:
ipaddress.ip_address(name)
is_ip = True
except ValueError:
is_ip = False
res = ServerNameSplit(host=name, port=port, is_ip=is_ip)
log.trace(f"Parsed server name {name} into {res}")
return res
async def resolve_server_name(server_name: str) -> ResolvedServerName:
try:
cached = server_name_cache[server_name]
if cached.expire > int(time()):
log.trace(f"Using cached server name resolution for {server_name}: {cached}")
return cached
except KeyError:
log.trace(f"No cached server name resolution for {server_name}")
host_header = server_name
hostname, port, is_ip = parse_server_name(host_header)
ttl = 86400
if port is None and not is_ip:
well_known_url = URL.build(scheme="https", host=host_header, port=443,
path="/.well-known/matrix/server")
try:
log.trace(f"Requesting {well_known_url} to resolve {server_name}'s .well-known")
async with http.get(well_known_url) as resp:
if resp.status == 200:
well_known_data = await resp.json()
host_header = well_known_data["m.server"]
log.debug(f"Got {host_header} from {server_name}'s .well-known")
hostname, port, is_ip = parse_server_name(host_header)
else:
log.trace(f"Got non-200 status {resp.status} from {server_name}'s .well-known")
except (ClientError, json.JSONDecodeError, KeyError, ValueError) as e:
log.debug(f"Failed to fetch .well-known for {server_name}: {e}")
if port is None and not is_ip:
log.trace(f"Querying SRV at _matrix._tcp.{host_header}")
res = await dns_resolver.query(f"_matrix._tcp.{host_header}", "SRV")
if res:
hostname = res[0].host
port = res[0].port
ttl = max(res[0].ttl, 300)
log.debug(f"Got {hostname}:{port} from {host_header}'s Matrix SRV record")
else:
log.trace(f"No SRV records found at _matrix._tcp.{host_header}")
result = ResolvedServerName(host_header=host_header, host=hostname, port=port or 8448,
expire=int(time()) + ttl)
server_name_cache[server_name] = result
log.debug(f"Resolved server name {server_name} -> {result}")
return result
def init():
global http, dns_resolver
dns_resolver = aiodns.DNSResolver(loop=asyncio.get_running_loop())
http = ClientSession(timeout=ClientTimeout(total=10),
connector=MatrixFederationTCPConnector())

View File

@ -1,126 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"description": "A sticker pack compatible with maunium-stickerpicker",
"properties": {
"id": {
"type": "string",
"description": "An unique identifier for the sticker pack",
"readOnly": true
},
"title": {
"type": "string",
"description": "The title of the sticker pack"
},
"stickers": {
"type": "array",
"description": "The stickers in the pack",
"items": {
"type": "object",
"description": "A single sticker",
"properties": {
"id": {
"type": "string",
"description": "An unique identifier for the sticker"
},
"url": {
"type": "string",
"description": "The Matrix content URI to the sticker",
"pattern": "mxc://.+?/.+"
},
"body": {
"type": "string",
"description": "The description text for the sticker"
},
"info": {
"type": "object",
"description": "Matrix media info",
"properties": {
"w": {
"type": "integer",
"description": "The intended display width of the sticker"
},
"h": {
"type": "integer",
"description": "The intended display height of the sticker"
},
"size": {
"type": "integer",
"description": "The size of the sticker image in bytes"
},
"mimetype": {
"type": "string",
"description": "The mime type of the sticker image"
}
},
"additionalProperties": true,
"required": [
"w",
"h",
"size",
"mimetype"
]
},
"net.maunium.telegram.sticker": {
"type": "object",
"description": "Telegram metadata about the sticker",
"properties": {
"pack": {
"type": "string",
"description": "Information about the pack the sticker is in",
"properties": {
"id": {
"type": "string",
"description": "The ID of the sticker pack"
},
"short_name": {
"type": "string",
"description": "The short name of the Telegram sticker pack from t.me/addstickers/<shortname>"
}
}
},
"id": {
"type": "string",
"description": "The ID of the sticker document"
},
"emoticons": {
"type": "array",
"description": "Emojis that are associated with the sticker",
"items": {
"type": "string",
"description": "A single unicode emoji"
}
}
}
}
},
"required": [
"id",
"url",
"body",
"info"
],
"additionalProperties": true
}
},
"net.maunium.telegram.pack": {
"type": "object",
"description": "Telegram metadata about the pack",
"properties": {
"short_name": {
"type": "string",
"description": "The short name of the Telegram sticker pack from t.me/addstickers/<shortname>"
},
"hash": {
"type": "string",
"description": "The Telegram-specified hash of the stickerpack that can be used to quickly check if it has changed"
}
}
}
},
"additionalProperties": true,
"required": [
"title",
"stickers"
]
}

View File

@ -1,52 +0,0 @@
# 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/>.
from aiohttp import web
from ..database import User
from ..config import Config
from .errors import Error
routes = web.RouteTableDef()
config: Config
@routes.get("/index.json")
async def get_packs(req: web.Request) -> web.Response:
user: User = req["user"]
packs = await user.get_packs()
return web.json_response({
"homeserver_url": user.homeserver_url,
"is_sticker_server": True,
"packs": [f"{pack.id}.json" for pack in packs],
})
@routes.get("/{pack_id}.json")
async def get_pack(req: web.Request) -> web.Response:
user: User = req["user"]
pack = await user.get_pack(req.match_info["pack_id"])
if pack is None:
raise Error.pack_not_found
stickers = await pack.get_stickers()
return web.json_response({
**pack.to_dict(),
"stickers": [sticker.to_dict() for sticker in stickers],
})
def init(cfg: Config) -> None:
global config
config = cfg

View File

@ -1,107 +0,0 @@
# 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/>.
from typing import Any
import random
import string
import json
from aiohttp import web
from pkg_resources import resource_stream
import jsonschema
from ..database import User, AccessToken, Pack, Sticker
from .errors import Error
routes = web.RouteTableDef()
pack_schema = json.load(resource_stream("sticker.server.api", "pack.schema.json"))
@routes.get("/whoami")
async def whoami(req: web.Request) -> web.Response:
user: User = req["user"]
token: AccessToken = req["token"]
return web.json_response({
"id": user.id,
"widget_secret": user.widget_secret,
"homeserver_url": user.homeserver_url,
"last_seen": int(token.last_seen_date.timestamp() / 60) * 60,
})
@routes.get("/packs")
async def packs(req: web.Request) -> web.Response:
user: User = req["user"]
packs = await user.get_packs()
return web.json_response([pack.to_dict() for pack in packs])
async def get_json(req: web.Request, schema: str) -> Any:
try:
data = await req.json()
except json.JSONDecodeError:
raise Error.request_not_json
try:
jsonschema.validate(data, schema)
except jsonschema.ValidationError as e:
raise Error.schema_error(e.message, e.path)
return data
@routes.post("/packs/create")
async def upload_pack(req: web.Request) -> web.Response:
data = await get_json(req, pack_schema)
user: User = req["user"]
title = data.pop("title")
raw_stickers = data.pop("stickers")
pack_id_suffix = data.pop("id", "".join(random.choices(string.ascii_lowercase, k=12)))
pack = Pack(id=f"{user.id}_{pack_id_suffix}", owner=user.id, title=title, meta=data)
stickers = [Sticker(pack_id=pack.id, id=sticker.pop("id"), url=sticker.pop("url"),
body=sticker.pop("body"), meta=sticker) for sticker in raw_stickers]
await pack.insert()
await pack.set_stickers(stickers)
await user.add_pack(pack)
return web.json_response({
**pack.to_dict(),
"stickers": [sticker.to_dict() for sticker in stickers],
})
@routes.get("/pack/{pack_id}")
async def get_pack(req: web.Request) -> web.Response:
user: User = req["user"]
pack = await user.get_pack(req.match_info["pack_id"])
if pack is None:
raise Error.pack_not_found
return web.json_response({
**pack.to_dict(),
"stickers": [sticker.to_dict() for sticker in await pack.get_stickers()],
})
@routes.delete("/pack/{pack_id}")
async def delete_pack(req: web.Request) -> web.Response:
user: User = req["user"]
pack = await user.get_pack(req.match_info["pack_id"])
if pack is None:
raise Error.pack_not_found
if pack.owner != user.id:
await user.remove_pack(pack)
else:
await pack.delete()
return web.Response(status=204)

View File

@ -1,55 +0,0 @@
# 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/>.
from typing import NamedTuple
from mautrix.util.config import BaseFileConfig, ConfigUpdateHelper
from mautrix.types import UserID
from mautrix.client import Client
class Permissions(NamedTuple):
access: bool = False
create_packs: bool = False
telegram_import: bool = False
class Config(BaseFileConfig):
def do_update(self, helper: ConfigUpdateHelper) -> None:
copy = helper.copy
copy("database")
copy("server.host")
copy("server.port")
copy("server.public_url")
copy("server.override_resource_path")
copy("server.trust_forward_headers")
copy("telegram_import.bot_token")
copy("telegram_import.homeserver.address")
copy("telegram_import.homeserver.access_token")
copy("permissions")
copy("logging")
def get_permissions(self, mxid: UserID) -> Permissions:
_, homeserver = Client.parse_user_id(mxid)
return Permissions(**{
**self["permissions"].get("*", {}),
**self["permissions"].get(homeserver, {}),
**self["permissions"].get(mxid, {}),
})

View File

@ -1,6 +0,0 @@
from .base import Base
from .upgrade import upgrade_table
from .sticker import Sticker
from .pack import Pack
from .access_token import AccessToken
from .user import User

View File

@ -1,72 +0,0 @@
# 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/>.
from typing import Optional, ClassVar
from datetime import datetime, timedelta
import hashlib
from attr import dataclass
import asyncpg
from mautrix.types import UserID
from .base import Base
@dataclass(kw_only=True)
class AccessToken(Base):
token_expiry: ClassVar[timedelta] = timedelta(days=1)
user_id: UserID
token_id: int
token_hash: bytes
last_seen_ip: str
last_seen_date: datetime
@classmethod
async def get(cls, token_id: int) -> Optional['AccessToken']:
q = ("SELECT user_id, token_hash, last_seen_ip, last_seen_date "
"FROM access_token WHERE token_id=$1")
row: asyncpg.Record = await cls.db.fetchrow(q, token_id)
if row is None:
return None
return cls(**row, token_id=token_id)
async def update_ip(self, ip: str) -> None:
if self.last_seen_ip == ip and (self.last_seen_date.replace(second=0, microsecond=0)
== datetime.now().replace(second=0, microsecond=0)):
# Same IP and last seen on this minute, skip update
return
q = ("UPDATE access_token SET last_seen_ip=$2, last_seen_date=current_timestamp "
"WHERE token_id=$1 RETURNING last_seen_date")
self.last_seen_date = await self.db.fetchval(q, self.token_id, ip)
self.last_seen_ip = ip
def check(self, token: str) -> bool:
return self.token_hash == hashlib.sha256(token.encode("utf-8")).digest()
@property
def expired(self) -> bool:
return self.last_seen_date + self.token_expiry < datetime.now()
async def delete(self) -> None:
await self.db.execute("DELETE FROM access_token WHERE token_id=$1", self.token_id)
@classmethod
async def insert(cls, user_id: UserID, token: str, ip: str) -> int:
q = ("INSERT INTO access_token (user_id, token_hash, last_seen_ip, last_seen_date) "
"VALUES ($1, $2, $3, current_timestamp) RETURNING token_id")
hashed = hashlib.sha256(token.encode("utf-8")).digest()
return await cls.db.fetchval(q, user_id, hashed, ip)

View File

@ -1,9 +0,0 @@
from typing import ClassVar, TYPE_CHECKING
from mautrix.util.async_db import Database
fake_db = Database("") if TYPE_CHECKING else None
class Base:
db: ClassVar[Database] = fake_db

View File

@ -1,64 +0,0 @@
# 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/>.
from typing import List, Dict, Any
import json
from attr import dataclass
from mautrix.types import UserID
from .base import Base
from .sticker import Sticker
@dataclass(kw_only=True)
class Pack(Base):
id: str
owner: UserID
title: str
meta: Dict[str, Any]
async def delete(self) -> None:
await self.db.execute("DELETE FROM pack WHERE id=$1", self.id)
async def insert(self) -> None:
await self.db.execute("INSERT INTO pack (id, owner, title, meta) VALUES ($1, $2, $3, $4)",
self.id, self.owner, self.title, json.dumps(self.meta))
@classmethod
def from_data(cls, **data: Any) -> 'Pack':
meta = json.loads(data.pop("meta"))
return cls(**data, meta=meta)
async def get_stickers(self) -> List[Sticker]:
res = await self.db.fetch('SELECT id, url, body, meta, "order" '
'FROM sticker WHERE pack_id=$1 ORDER BY "order"', self.id)
return [Sticker.from_data(**row, pack_id=self.id) for row in res]
async def set_stickers(self, stickers: List[Sticker]) -> None:
data = ((sticker.id, self.id, sticker.url, sticker.body, json.dumps(sticker.meta), order)
for order, sticker in enumerate(stickers))
columns = ["id", "pack_id", "url", "body", "meta", "order"]
async with self.db.acquire() as conn, conn.transaction():
await conn.execute("DELETE FROM sticker WHERE pack_id=$1", self.id)
await conn.copy_records_to_table("sticker", records=data, columns=columns)
def to_dict(self) -> Dict[str, Any]:
return {
**self.meta,
"title": self.title,
"id": self.id,
}

View File

@ -1,47 +0,0 @@
# 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/>.
from typing import Dict, Any
import json
from attr import dataclass
import attr
from mautrix.types import ContentURI
from .base import Base
@dataclass(kw_only=True)
class Sticker(Base):
pack_id: str
order: int = 0
id: str
url: ContentURI = attr.ib(order=False)
body: str = attr.ib(order=False)
meta: Dict[str, Any] = attr.ib(order=False)
def to_dict(self) -> Dict[str, Any]:
return {
**self.meta,
"body": self.body,
"url": self.url,
"id": self.id,
}
@classmethod
def from_data(cls, **data: Any) -> 'Sticker':
meta = json.loads(data.pop("meta"))
return cls(**data, meta=meta)

View File

@ -1,57 +0,0 @@
# 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/>.
from asyncpg import Connection
from mautrix.util.async_db.upgrade import UpgradeTable
upgrade_table = UpgradeTable()
@upgrade_table.register(description="Initial revision")
async def upgrade_v1(conn: Connection) -> None:
await conn.execute("""CREATE TABLE "user" (
id TEXT PRIMARY KEY,
widget_secret TEXT NOT NULL,
homeserver_url TEXT NOT NULL
)""")
await conn.execute("""CREATE TABLE access_token (
token_id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
token_hash BYTEA NOT NULL,
last_seen_ip TEXT,
last_seen_date TIMESTAMP
)""")
await conn.execute("""CREATE TABLE pack (
id TEXT PRIMARY KEY,
owner TEXT REFERENCES "user"(id) ON DELETE SET NULL,
title TEXT NOT NULL,
meta JSONB NOT NULL
)""")
await conn.execute("""CREATE TABLE user_pack (
user_id TEXT REFERENCES "user"(id) ON DELETE CASCADE,
pack_id TEXT REFERENCES pack(id) ON DELETE CASCADE,
"order" INT NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, pack_id)
)""")
await conn.execute("""CREATE TABLE sticker (
id TEXT,
pack_id TEXT REFERENCES pack(id) ON DELETE CASCADE,
url TEXT NOT NULL,
body TEXT NOT NULL,
meta JSONB NOT NULL,
"order" INT NOT NULL DEFAULT 0,
PRIMARY KEY (id, pack_id)
)""")

View File

@ -1,104 +0,0 @@
# 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/>.
from typing import Optional, List, ClassVar
import random
import string
import time
from attr import dataclass
import asyncpg
from mautrix.types import UserID
from .base import Base
from .pack import Pack
from .access_token import AccessToken
@dataclass(kw_only=True)
class User(Base):
token_charset: ClassVar[str] = string.ascii_letters + string.digits
id: UserID
widget_secret: str
homeserver_url: str
@classmethod
def _random_token(cls) -> str:
return "".join(random.choices(cls.token_charset, k=64))
@classmethod
def new(cls, id: UserID, homeserver_url: str) -> 'User':
return User(id=id, widget_secret=cls._random_token(), homeserver_url=homeserver_url)
@classmethod
async def get(cls, id: UserID) -> Optional['User']:
q = 'SELECT id, widget_secret, homeserver_url FROM "user" WHERE id=$1'
row: asyncpg.Record = await cls.db.fetchrow(q, id)
if row is None:
return None
return cls(**row)
async def regenerate_widget_secret(self) -> None:
self.widget_secret = self._random_token()
await self.db.execute('UPDATE "user" SET widget_secret=$1 WHERE id=$2',
self.widget_secret, self.id)
async def set_homeserver_url(self, url: str) -> None:
self.homeserver_url = url
await self.db.execute('UPDATE "user" SET homeserver_url=$1 WHERE id=$2', url, self.id)
async def new_access_token(self, ip: str) -> str:
token = self._random_token()
token_id = await AccessToken.insert(self.id, token, ip)
return f"{token_id}:{token}"
async def delete(self) -> None:
await self.db.execute('DELETE FROM "user" WHERE id=$1', self.id)
async def insert(self) -> None:
q = 'INSERT INTO "user" (id, widget_secret, homeserver_url) VALUES ($1, $2, $3)'
await self.db.execute(q, self.id, self.widget_secret, self.homeserver_url)
async def get_packs(self) -> List[Pack]:
res = await self.db.fetch("SELECT id, owner, title, meta FROM user_pack "
"LEFT JOIN pack ON pack.id=user_pack.pack_id "
'WHERE user_id=$1 ORDER BY "order"', self.id)
return [Pack.from_data(**row) for row in res]
async def get_pack(self, pack_id: str) -> Optional[Pack]:
row = await self.db.fetchrow("SELECT id, owner, title, meta FROM user_pack "
"LEFT JOIN pack ON pack.id=user_pack.pack_id "
"WHERE user_id=$1 AND pack_id=$2", self.id, pack_id)
if row is None:
return None
return Pack.from_data(**row)
async def set_packs(self, packs: List[Pack]) -> None:
data = ((self.id, pack.id, order)
for order, pack in enumerate(packs))
columns = ["user_id", "pack_id", "order"]
async with self.db.acquire() as conn, conn.transaction():
await conn.execute("DELETE FROM user_pack WHERE user_id=$1", self.id)
await conn.copy_records_to_table("user_pack", records=data, columns=columns)
async def add_pack(self, pack: Pack) -> None:
q = 'INSERT INTO user_pack (user_id, pack_id, "order") VALUES ($1, $2, $3)'
await self.db.execute(q, self.id, pack.id, int(time.time()))
async def remove_pack(self, pack: Pack) -> None:
q = "DELETE FROM user_pack WHERE user_id=$1 AND pack_id=$2"
await self.db.execute(q, self.id, pack.id)

View File

@ -1,74 +0,0 @@
# Postgres database URL for storing sticker packs and other things.
database: postgres://username:password@hostname/dbname
# Settings for the actual HTTP server
server:
# The IP and port to listen to.
hostname: 0.0.0.0
port: 29329
# Public base URL where the server is visible.
public_url: https://example.com
# Override path from where to load UI resources.
# Set to false to using pkg_resources to find the path.
override_resource_path: false
# Whether or not to trust X-Forwarded-For headers for determining the request IP.
trust_forward_headers: false
# Telegram configuration for downloading sticker packs. In the future, this will be client-side and
# none of this configuration will be necessary.
telegram_import:
# Create your own bot at https://t.me/BotFather
bot_token: null
# Matrix homeserver access details. This is only used for uploading Telegram-imported stickers.
homeserver:
address: https://example.com
access_token: null
# Permissions for who is allowed to use the sticker picker.
#
# Values are objects that should contain boolean values each permission:
# access - Access the sticker picker and use existing packs.
# create_packs - Create packs by uploading images.
# telegram_import - Create packs by importing from Telegram. Images are stored on
#
# Permission keys may be user IDs, server names or "*". If a server name or user ID permission
# doesn't specify some keys, they'll be inherited from the higher level.
permissions:
"*":
access: true
create_packs: true
telegram_import: false
"example.com":
telegram_import: true
# Python logging configuration.
#
# See Configuration dictionary schema in the Python documentation for more info:
# https://docs.python.org/3.9/library/logging.config.html#configuration-dictionary-schema
logging:
version: 1
formatters:
colored:
(): mautrix.util.logging.ColorFormatter
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
normal:
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
handlers:
file:
class: logging.handlers.RotatingFileHandler
formatter: normal
filename: ./sticker.server.log
maxBytes: 10485760
backupCount: 10
console:
class: logging.StreamHandler
formatter: colored
loggers:
mau:
level: DEBUG
aiohttp:
level: INFO
root:
level: DEBUG
handlers: [file, console]

View File

@ -1 +0,0 @@
../../web/

View File

@ -1,50 +0,0 @@
# 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/>.
from pkg_resources import resource_filename
from aiohttp import web
from .api import packs_app, setup_app, integrations_app, init as init_api
from .static import StaticResource
from .config import Config
class Server:
config: Config
runner: web.AppRunner
app: web.Application
site: web.TCPSite
def __init__(self, config: Config) -> None:
init_api(config)
self.config = config
self.app = web.Application()
self.app.add_subapp("/_matrix/integrations/v1", integrations_app)
self.app.add_subapp("/setup/api", setup_app)
self.app.add_subapp("/packs", packs_app)
resource_path = (config["server.override_resource_path"]
or resource_filename("sticker.server", "frontend"))
self.app.router.register_resource(StaticResource("/", resource_path, name="frontend"))
self.runner = web.AppRunner(self.app)
async def start(self) -> None:
await self.runner.setup()
self.site = web.TCPSite(self.runner, self.config["server.host"],
self.config["server.port"])
await self.site.start()
async def stop(self) -> None:
await self.runner.cleanup()

View File

@ -1,106 +0,0 @@
# Simplified version of aiohttp's StaticResource with support for index.html
# https://github.com/aio-libs/aiohttp/blob/v3.6.2/aiohttp/web_urldispatcher.py#L496-L678
# Licensed under Apache 2.0
from typing import Callable, Awaitable, Tuple, Optional, Union, Dict, Set, Iterator, Any
from pathlib import Path, PurePath
from aiohttp.web import (Request, StreamResponse, FileResponse, ResourceRoute, AbstractResource,
AbstractRoute, UrlMappingMatchInfo, HTTPNotFound, HTTPForbidden)
from aiohttp.abc import AbstractMatchInfo
from yarl import URL
Handler = Callable[[Request], Awaitable[StreamResponse]]
class StaticResource(AbstractResource):
def __init__(self, prefix: str, directory: Union[str, PurePath], *, name: Optional[str] = None,
error_path: Optional[str] = "index.html", chunk_size: int = 256 * 1024) -> None:
super().__init__(name=name)
try:
directory = Path(directory).resolve()
if not directory.is_dir():
raise ValueError("Not a directory")
except (FileNotFoundError, ValueError) as error:
raise ValueError(f"No directory exists at '{directory}'") from error
self._directory = directory
self._chunk_size = chunk_size
self._prefix = prefix
self._error_file = (directory / error_path) if error_path else None
self._routes = {
"GET": ResourceRoute("GET", self._handle, self),
"HEAD": ResourceRoute("HEAD", self._handle, self),
}
@property
def canonical(self) -> str:
return self._prefix
def add_prefix(self, prefix: str) -> None:
assert prefix.startswith("/")
assert not prefix.endswith("/")
assert len(prefix) > 1
self._prefix = prefix + self._prefix
def raw_match(self, prefix: str) -> bool:
return False
def url_for(self, *, filename: Union[str, Path]) -> URL:
if isinstance(filename, Path):
filename = str(filename)
while filename.startswith("/"):
filename = filename[1:]
return URL.build(path=f"{self._prefix}/{filename}")
def get_info(self) -> Dict[str, Any]:
return {
"directory": self._directory,
"prefix": self._prefix,
}
def set_options_route(self, handler: Handler) -> None:
if "OPTIONS" in self._routes:
raise RuntimeError("OPTIONS route was set already")
self._routes["OPTIONS"] = ResourceRoute("OPTIONS", handler, self)
async def resolve(self, request: Request) -> Tuple[Optional[AbstractMatchInfo], Set[str]]:
path = request.rel_url.raw_path
method = request.method
allowed_methods = set(self._routes)
if not path.startswith(self._prefix):
return None, set()
if method not in allowed_methods:
return None, allowed_methods
return UrlMappingMatchInfo({
"filename": URL.build(path=path[len(self._prefix):], encoded=True).path
}, self._routes[method]), allowed_methods
def __len__(self) -> int:
return len(self._routes)
def __iter__(self) -> Iterator[AbstractRoute]:
return iter(self._routes.values())
async def _handle(self, request: Request) -> StreamResponse:
try:
filename = Path(request.match_info["filename"])
if not filename.anchor:
filepath = (self._directory / filename).resolve()
if filepath.is_file():
return FileResponse(filepath, chunk_size=self._chunk_size)
index_path = (self._directory / filename / "index.html").resolve()
if index_path.is_file():
return FileResponse(index_path, chunk_size=self._chunk_size)
except (ValueError, FileNotFoundError) as error:
raise HTTPNotFound() from error
except HTTPForbidden:
raise
except Exception as error:
request.app.logger.exception("Error while trying to serve static file")
raise HTTPNotFound() from error
def __repr__(self) -> str:
name = f"'{self.name}'" if self.name is not None else ""
return f"<StaticResource {name} {self._prefix} -> {self._directory!r}>"

View File

@ -71,7 +71,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"]}
@ -99,7 +99,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}",
@ -117,8 +117,6 @@ async def reupload_pack(client: TelegramClient, pack: StickerSetFull, output_dir
pack_url_regex = re.compile(r"^(?:(?:https?://)?(?:t|telegram)\.(?:me|dog)/addstickers/)?"
r"([A-Za-z0-9-_]+)"
r"(?:\.json)?$")
api_id = 298751
api_hash = "cb676d6bae20553c9996996a8f52b4d7"
parser = argparse.ArgumentParser()
@ -134,17 +132,18 @@ parser.add_argument("pack", help="Sticker pack URLs to import", action="append",
async def main(args: argparse.Namespace) -> None:
await matrix.load_config(args.config)
client = TelegramClient(args.session, api_id, api_hash)
client = TelegramClient(args.session, 298751, "cb676d6bae20553c9996996a8f52b4d7")
await client.start()
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]:
@ -154,7 +153,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()

View File

@ -1,196 +0,0 @@
{
"env": {
"es6": true,
"browser": true
},
"extends": [
"eslint:recommended",
"plugin:import/errors",
"plugin:import/warnings"
],
"parser": "@babel/eslint-parser",
"parserOptions": {
"sourceType": "module",
"requireConfigFile": false
},
"plugins": [
"import",
"react-hooks"
],
"rules": {
"indent": [
"error",
"tab"
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"double",
{
"avoidEscape": true
}
],
"semi": [
"error",
"never"
],
"comma-dangle": [
"error",
"only-multiline"
],
"comma-spacing": [
"error",
{
"after": true
}
],
"eol-last": [
"error",
"always"
],
"no-trailing-spaces": [
"error"
],
"camelcase": [
"error",
{
"properties": "always"
}
],
"import/no-unresolved": "off",
"import/named": "error",
"import/namespace": "error",
"import/default": "error",
"import/export": "error",
"import/order": [
"error",
{
"newlines-between": "always",
"pathGroups": [
{
"pattern": "{.,..,../..,../../..,../../../..}/lib/**",
"group": "external"
}
],
"groups": [
"builtin",
"external",
[
"internal",
"sibling",
"parent"
],
"index"
]
}
],
"max-len": [
"error",
{
"code": 100
}
],
"prefer-const": [
"error",
{
"destructuring": "all",
"ignoreReadBeforeAssign": false
}
],
"arrow-spacing": [
"error",
{
"before": true,
"after": true
}
],
"space-before-blocks": [
"error",
"always"
],
"object-curly-spacing": [
"error",
"always"
],
"array-bracket-spacing": [
"error",
"never"
],
"space-in-parens": [
"error",
"never"
],
"keyword-spacing": [
"error",
{
"before": true,
"after": true
}
],
"key-spacing": [
"error",
{
"afterColon": true
}
],
"template-curly-spacing": [
"error",
"never"
],
"no-empty": [
"error",
{
"allowEmptyCatch": true
}
],
"arrow-body-style": [
"error",
"as-needed"
],
"no-multiple-empty-lines": [
"error",
{
"max": 1,
"maxBOF": 0,
"maxEOF": 0
}
],
"no-prototype-builtins": "off",
"dot-notation": [
"error",
{
"allowKeywords": true
}
],
"quote-props": [
"error",
"as-needed"
],
"no-multi-spaces": [
"error"
],
"space-infix-ops": [
"error"
],
"object-curly-newline": [
"error",
{
"multiline": false,
"consistent": true
}
],
"no-mixed-operators": [
"error"
],
"no-extra-parens": [
"error",
"all",
{
"nestedBinaryExpressions": false
}
]
}
}

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

@ -5,15 +5,15 @@
<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/widget-api.js"/>
<link rel="modulepreload" href="src/widget/frequently-used.js"/>
<link rel="modulepreload" href="src/Spinner.js"/>
<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="stylesheet" href="style/widget.css"/>
<link rel="stylesheet" href="style/index.css"/>
<link rel="stylesheet" href="style/spinner.css"/>
<script src="src/widget/index.js" type="module"></script>
<script src="src/index.js" type="module"></script>
<script nomodule>document.body.innerText = "This sticker picker requires modern JavaScript"</script>
</head>
<body>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,5 +0,0 @@
import { n } from '../common/preact.module-9c264606.js';
var t,u,r,o=0,i=[],c=n.__r,f=n.diffed,e=n.__c,a=n.unmount;function v(t,r){n.__h&&n.__h(u,t,o||r),o=0;var i=u.__H||(u.__H={__:[],__h:[]});return t>=i.__.length&&i.__.push({}),i.__[t]}function m(n){return o=1,p(k,n)}function p(n,r,o){var i=v(t++,2);return i.t=n,i.__c||(i.__=[o?o(r):k(void 0,r),function(n){var t=i.t(i.__[0],n);i.__[0]!==t&&(i.__=[t,i.__[1]],i.__c.setState({}));}],i.__c=u),i.__}function y(r,o){var i=v(t++,3);!n.__s&&j(i.__H,o)&&(i.__=r,i.__H=o,u.__H.__h.push(i));}function l(r,o){var i=v(t++,4);!n.__s&&j(i.__H,o)&&(i.__=r,i.__H=o,u.__h.push(i));}function h(n){return o=5,_(function(){return {current:n}},[])}function s(n,t,u){o=6,l(function(){"function"==typeof n?n(t()):n&&(n.current=t());},null==u?u:u.concat(n));}function _(n,u){var r=v(t++,7);return j(r.__H,u)&&(r.__=n(),r.__H=u,r.__h=n),r.__}function A(n,t){return o=8,_(function(){return n},t)}function F(n){var r=u.context[n.__c],o=v(t++,9);return o.__c=n,r?(null==o.__&&(o.__=!0,r.sub(u)),r.props.value):n.__}function T(t,u){n.useDebugValue&&n.useDebugValue(u?u(t):t);}function d(n){var r=v(t++,10),o=m();return r.__=n,u.componentDidCatch||(u.componentDidCatch=function(n){r.__&&r.__(n),o[1](n);}),[o[0],function(){o[1](void 0);}]}function q(){i.forEach(function(t){if(t.__P)try{t.__H.__h.forEach(b),t.__H.__h.forEach(g),t.__H.__h=[];}catch(u){t.__H.__h=[],n.__e(u,t.__v);}}),i=[];}n.__r=function(n){c&&c(n),t=0;var r=(u=n.__c).__H;r&&(r.__h.forEach(b),r.__h.forEach(g),r.__h=[]);},n.diffed=function(t){f&&f(t);var u=t.__c;u&&u.__H&&u.__H.__h.length&&(1!==i.push(u)&&r===n.requestAnimationFrame||((r=n.requestAnimationFrame)||function(n){var t,u=function(){clearTimeout(r),x&&cancelAnimationFrame(t),setTimeout(n);},r=setTimeout(u,100);x&&(t=requestAnimationFrame(u));})(q));},n.__c=function(t,u){u.some(function(t){try{t.__h.forEach(b),t.__h=t.__h.filter(function(n){return !n.__||g(n)});}catch(r){u.some(function(n){n.__h&&(n.__h=[]);}),u=[],n.__e(r,t.__v);}}),e&&e(t,u);},n.unmount=function(t){a&&a(t);var u=t.__c;if(u&&u.__H)try{u.__H.__.forEach(b);}catch(t){n.__e(t,u.__v);}};var x="function"==typeof requestAnimationFrame;function b(n){"function"==typeof n.__c&&n.__c();}function g(n){n.__c=n.__();}function j(n,t){return !n||n.length!==t.length||t.some(function(t,u){return t!==n[u]})}function k(n,t){return "function"==typeof t?t(n):t}
export { A as useCallback, F as useContext, T as useDebugValue, y as useEffect, d as useErrorBoundary, s as useImperativeHandle, l as useLayoutEffect, _ as useMemo, p as useReducer, h as useRef, m as useState };

View File

@ -4,34 +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",
"preact/hooks"
],
"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.5.5",
"snowpack": "^2.16.1"
},
"devDependencies": {
"@babel/core": "^7.12.3",
"@babel/eslint-parser": "^7.12.1",
"eslint": "^7.12.1",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-react-hooks": "^4.2.0",
"node-sass": "^5.0.0"
"htm": "^3.1.0",
"preact": "^10.5.14",
"esinstall": "^1.1.7",
"sass": "^1.42.1"
}
}

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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/></svg>

After

Width:  |  Height:  |  Size: 313 B

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

View File

@ -1,23 +0,0 @@
<!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>Setup - Maunium sticker picker</title>
<link rel="modulepreload" href="../lib/htm/preact.js"/>
<link rel="modulepreload" href="../lib/preact/hooks.js"/>
<link rel="modulepreload" href="src/Spinner.js"/>
<link rel="modulepreload" href="src/Button.js"/>
<link rel="stylesheet" href="../style/setup.css"/>
<link rel="stylesheet" href="../style/setup-login.css"/>
<link rel="stylesheet" href="../style/spinner.css"/>
<link rel="stylesheet" href="../style/button.css"/>
<script src="../src/setup/index.js" type="module"></script>
<script nomodule>document.body.innerText = "This setup page requires modern JavaScript"</script>
</head>
<body>
<noscript>This setup page requires JavaScript</noscript>
</body>
</html>

View File

@ -1,30 +0,0 @@
// 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"
const Button = ({
type = "button", class: className = "", children,
variant = "filled", size = "normal",
...customProps
}) => {
const props = {
class: `mau-button variant-${variant} size-${size} ${className}`,
type, ...customProps,
}
return html`<button ...${props}>${children}</button>`
}
export default Button

View File

@ -13,41 +13,56 @@
//
// 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 { shouldAutofocusSearchBar, shouldDisplayAutofocusSearchBar, SearchBox } from "./search-box.js"
import { checkMobileSafari } from "./user-agent-detect.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"
let INDEX = `${PACKS_BASE_URL}/index.json`
const params = new URLSearchParams(document.location.search)
if (params.has('config')) {
INDEX = params.get("config")
}
// This is updated from packs/index.json
let HOMESERVER_URL = "https://matrix-client.matrix.org"
// eslint-disable-next-line max-len
const makeThumbnailURL = mxc => `${HOMESERVER_URL}/_matrix/media/r0/thumbnail/${mxc.substr(6)}?height=128&width=128&method=scale`
// 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/)
const isMobileSafari = checkMobileSafari()
const query = Object.fromEntries(location.search
.substr(1).split("&")
.map(part => part.split("="))
.map(([key, value = ""]) => [key, value]))
// We need to detect iOS webkit / Android because autofocusing a field does not open
// the device keyboard by design, making the option obsolete
const shouldAutofocusOption = shouldAutofocusSearchBar()
const displayAutofocusOption = shouldDisplayAutofocusSearchBar()
const supportedThemes = ["light", "dark", "black"]
const defaultState = {
packs: [],
loading: true,
error: null,
}
const defaultSearchState = {
searchTerm: null,
filteredPacks: null
}
class App extends Component {
constructor(props) {
super(props)
this.defaultTheme = query.theme
this.defaultTheme = params.get("theme")
this.state = {
packs: [],
loading: true,
error: null,
...defaultState,
...defaultSearchState,
stickersPerRow: parseInt(localStorage.mauStickersPerRow || "4"),
theme: localStorage.mauStickerThemeOverride || this.defaultTheme,
frequentlyUsed: {
@ -64,11 +79,12 @@ class App extends Component {
this.defaultTheme = "light"
}
this.stickersByID = new Map(JSON.parse(localStorage.mauFrequentlyUsedStickerCache || "[]"))
this.state.frequentlyUsed.stickers = this._getStickersByID(
this.state.frequentlyUsed.stickerIDs)
this.state.frequentlyUsed.stickers = this._getStickersByID(this.state.frequentlyUsed.stickerIDs)
this.imageObserver = null
this.packListRef = null
this.navRef = null
this.searchStickers = this.searchStickers.bind(this)
this.resetSearch = this.resetSearch.bind(this)
this.sendSticker = this.sendSticker.bind(this)
this.navScroll = this.navScroll.bind(this)
this.reloadPacks = this.reloadPacks.bind(this)
@ -90,14 +106,39 @@ class App extends Component {
stickers,
},
})
localStorage.mauFrequentlyUsedStickerCache = JSON.stringify(
stickers.map(sticker => [sticker.id, sticker]))
localStorage.mauFrequentlyUsedStickerCache = JSON.stringify(stickers.map(sticker => [sticker.id, sticker]))
}
// Search
resetSearch() {
this.setState({ ...defaultSearchState })
}
searchStickers(searchTerm) {
const sanitizeString = s => s.toLowerCase().trim()
const sanitizedSearch = sanitizeString(searchTerm)
const allPacks = [this.state.frequentlyUsed, ...this.state.packs]
const packsWithFilteredStickers = allPacks.map(pack => ({
...pack,
stickers: pack.stickers.filter(sticker =>
sanitizeString(sticker.body).includes(sanitizedSearch) ||
sanitizeString(sticker.id).includes(sanitizedSearch)
),
}))
const filteredPacks = packsWithFilteredStickers.filter(({ stickers }) => !!stickers.length)
this.setState({ searchTerm, filteredPacks })
}
// End search
// Settings
setStickersPerRow(val) {
localStorage.mauStickersPerRow = val
document.documentElement.style.setProperty("--stickers-per-row",
localStorage.mauStickersPerRow)
document.documentElement.style.setProperty("--stickers-per-row", localStorage.mauStickersPerRow)
this.setState({
stickersPerRow: val,
})
@ -114,42 +155,40 @@ class App extends Component {
}
}
setAutofocusSearchBar(checked) {
localStorage.mauAutofocusSearchBar = checked
}
// End settings
reloadPacks() {
this.imageObserver.disconnect()
this.sectionObserver.disconnect()
this.setState({ packs: [] })
this.setState({ packs: defaultState.packs })
this.resetSearch()
this._loadPacks(true)
}
_loadPacks(disableCache = false) {
const args = {
cache: disableCache ? "no-cache" : undefined,
headers: query.user_id && query.token ? {
Authorization: `Bearer ${query.token}`,
"X-Matrix-User-ID": query.user_id,
} : {},
}
fetch(`${PACKS_BASE_URL}/index.json`, args).then(async indexRes => {
const cache = disableCache ? "no-cache" : undefined
fetch(INDEX, { cache }).then(async indexRes => {
if (indexRes.status >= 400) {
try {
const errData = await indexRes.json()
this.setState({
loading: false,
error: errData.error,
})
} catch (err) {
this.setState({
loading: false,
error: indexRes.status !== 404 ? indexRes.statusText : null,
})
}
return
}
const indexData = await indexRes.json()
HOMESERVER_URL = indexData.homeserver_url || HOMESERVER_URL
// TODO only load pack metadata when scrolled into view?
for (const packFile of indexData.packs) {
const packRes = await fetch(`${PACKS_BASE_URL}/${packFile}`, args)
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)
@ -160,15 +199,11 @@ class App extends Component {
})
}
this.updateFrequentlyUsed()
}, error => this.setState({
loading: false,
error,
}))
}, error => this.setState({ loading: false, error }))
}
componentDidMount() {
document.documentElement.style.setProperty("--stickers-per-row",
this.state.stickersPerRow.toString())
document.documentElement.style.setProperty("--stickers-per-row", this.state.stickersPerRow.toString())
this._loadPacks()
this.imageObserver = new IntersectionObserver(this.observeImageIntersections, {
rootMargin: "100px",
@ -197,6 +232,9 @@ class App extends Component {
for (const entry of intersections) {
const packID = entry.target.getAttribute("data-pack-id")
const navElement = document.getElementById(`nav-${packID}`)
if (!navElement) {
continue
}
if (entry.isIntersecting) {
navElement.classList.add("visible")
const bb = navElement.getBoundingClientRect()
@ -240,44 +278,46 @@ class App extends Component {
const sticker = this.stickersByID.get(id)
frequent.add(id)
this.updateFrequentlyUsed()
this.resetSearch()
widgetAPI.sendSticker(sticker)
}
navScroll(evt) {
this.navRef.scrollLeft += evt.deltaY * 12
this.navRef.scrollLeft += evt.deltaY
}
render() {
const theme = `theme-${this.state.theme}`
const filterActive = !!this.state.filteredPacks
const packs = filterActive ? this.state.filteredPacks : [this.state.frequentlyUsed, ...this.state.packs]
const noPacksForSearch = filterActive && packs.length === 0
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}">
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}">
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" />
${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}/>`)}
<${SearchBox}
value=${this.state.searchTerm}
onSearch=${this.searchStickers}
onReset=${this.resetSearch}
/>
<div class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}" ref=${elem => this.packListRef = elem}>
${noPacksForSearch ? 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>`
@ -291,9 +331,9 @@ const Settings = ({ app }) => html`
<button onClick=${app.reloadPacks}>Reload</button>
<div>
<label for="stickers-per-row">Stickers per row: ${app.state.stickersPerRow}</label>
<input type="range" min=2 max=10 id="stickers-per-row"
<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>
@ -304,6 +344,14 @@ const Settings = ({ app }) => html`
<option value="black">Black</option>
</select>
</div>
${displayAutofocusOption ? html`<div>
Autofocus search bar:
<input
type="checkbox"
checked=${shouldAutofocusOption}
onChange=${evt => app.setAutofocusSearchBar(evt.target.checked)}
/>
</div>` : null}
</div>
</section>
`
@ -312,22 +360,16 @@ 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",
})
pack.scrollIntoView({ block: "start", behavior: "instant" })
evt.preventDefault()
}
const NavBarItem = ({
pack,
iconOverride = null,
}) => html`
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}>
onClick=${isMobileSafari ? (evt => scrollToSection(evt, pack.id)) : undefined}>
<div class="sticker">
${iconOverride ? html`
<span class="icon icon-${iconOverride}" />
<span class="icon icon-${iconOverride}"/>
` : html`
<img src=${makeThumbnailURL(pack.stickers[0].url)}
alt=${pack.stickers[0].body} class="visible" />
@ -336,10 +378,7 @@ const NavBarItem = ({
</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">
@ -350,12 +389,9 @@ const Pack = ({
</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>
`

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

@ -0,0 +1,89 @@
// 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, Component } from "../lib/htm/preact.js"
import { checkMobileSafari, checkAndroid } from "./user-agent-detect.js"
export function shouldDisplayAutofocusSearchBar() {
return !checkMobileSafari() && !checkAndroid()
}
export function shouldAutofocusSearchBar() {
return localStorage.mauAutofocusSearchBar === 'true' && shouldDisplayAutofocusSearchBar()
}
export function focusSearchBar() {
const inputInWebView = document.querySelector('.search-box input')
if (inputInWebView && shouldAutofocusSearchBar()) {
inputInWebView.focus()
}
}
export class SearchBox extends Component {
constructor(props) {
super(props)
this.autofocus = shouldAutofocusSearchBar()
this.value = props.value
this.onSearch = props.onSearch
this.onReset = props.onReset
this.search = this.search.bind(this)
this.clearSearch = this.clearSearch.bind(this)
}
componentDidMount() {
focusSearchBar()
}
componentWillReceiveProps(props) {
this.value = props.value
}
search(e) {
if (e.key === "Escape") {
this.clearSearch()
return
}
this.onSearch(e.target.value)
}
clearSearch() {
this.onReset()
}
render() {
const isEmpty = !this.value
const className = `icon-display ${isEmpty ? null : 'reset-click-zone'}`
const title = isEmpty ? null : 'Click to reset'
const onClick = isEmpty ? null : this.clearSearch
const iconToDisplay = `icon-${isEmpty ? 'search' : 'reset'}`
return html`
<div class="search-box">
<input
placeholder="Find stickers …"
value=${this.value}
onKeyUp=${this.search}
autoFocus=${this.autofocus}
/>
<div class=${className} title=${title} onClick=${onClick}>
<span class="icon ${iconToDisplay}" />
</div>
</div>
`
}
}

View File

@ -1,73 +0,0 @@
// 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 { useEffect, useState } from "../../lib/preact/hooks.js"
import { html } from "../../lib/htm/preact.js"
import LoginView from "./LoginView.js"
import Spinner from "../Spinner.js"
import * as matrix from "./matrix-api.js"
import * as sticker from "./sticker-api.js"
const App = () => {
const [loggedIn, setLoggedIn] = useState(Boolean(localStorage.mxAccessToken))
const [widgetSecret, setWidgetSecret] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
if (!loggedIn) {
return html`
<${LoginView}
onLoggedIn=${() => setLoggedIn(Boolean(localStorage.mxAccessToken))}
/>`
}
useEffect(() => {
if (widgetSecret === null) {
setLoading(true)
const whoamiReceived = data => {
setLoading(false)
setWidgetSecret(data.widget_secret)
}
const reauth = async () => {
const openIDToken = await matrix.requestOpenIDToken(
localStorage.mxHomeserver, localStorage.mxUserID, localStorage.mxAccessToken)
const integrationData = await matrix.requestIntegrationToken(openIDToken)
localStorage.stickerSetupAccessToken = integrationData.token
return await sticker.whoami()
}
const whoamiErrored = err => {
console.error("Setup API whoami returned", err)
if (err.code === "NET.MAUNIUM_TOKEN_EXPIRED" || err.code === "M_UNKNOWN_TOKEN") {
return reauth().then(whoamiReceived)
} else {
throw err
}
}
sticker.whoami().then(whoamiReceived, whoamiErrored).catch(err => {
setLoading(false)
setError(err.message)
})
}
}, [])
if (loading) {
return html`<${Spinner} size=80 green />`
}
return html`${widgetSecret}`
}
export default App

View File

@ -1,207 +0,0 @@
// 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 { useEffect, useLayoutEffect, useRef, useState } from "../../lib/preact/hooks.js"
import { html } from "../../lib/htm/preact.js"
import * as matrix from "./matrix-api.js"
import Button from "../Button.js"
import Spinner from "../Spinner.js"
const query = Object.fromEntries(location.search
.substr(1).split("&")
.map(part => part.split("="))
.map(([key, value = ""]) => [key, value]))
const LoginView = ({ onLoggedIn }) => {
const usernameWrapperRef = useRef()
const usernameRef = useRef()
const serverRef = useRef()
const passwordRef = useRef()
const previousServerValue = useRef()
const [loading, setLoading] = useState(false)
const [userIDFocused, setUserIDFocused] = useState(false)
const [supportedFlows, setSupportedFlows] = useState(["m.login.password"])
const [username, setUsername] = useState("")
const [server, setServer] = useState("")
const [serverURL, setServerURL] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState(null)
const keyDown = evt => {
if ((evt.target.name === "username" && evt.key === ":") || evt.key === "Enter") {
if (evt.target.name === "username") {
serverRef.current.focus()
} else if (evt.target.name === "server") {
passwordRef.current.focus()
}
evt.preventDefault()
} else if (evt.target.name === "server" && !evt.target.value && evt.key === "Backspace") {
usernameRef.current.focus()
}
}
const paste = evt => {
if (usernameRef.current.value !== "" || serverRef.current.value !== "") {
return
}
let data = evt.clipboardData.getData("text")
if (data.startsWith("@")) {
data = data.substr(1)
}
const separator = data.indexOf(":")
if (separator === -1) {
setUsername(data)
} else {
setUsername(data.substr(0, separator))
setServer(data.substr(separator + 1))
serverRef.current.focus()
}
evt.preventDefault()
}
useLayoutEffect(() => usernameRef.current.focus(), [])
const onFocus = () => setUserIDFocused(true)
const onBlur = () => {
setUserIDFocused(false)
if (previousServerValue.current !== server && server) {
previousServerValue.current = server
setSupportedFlows(null)
setError(null)
matrix.resolveWellKnown(server).then(url => {
setServerURL(url)
localStorage.mxServerName = server
localStorage.mxHomeserver = url
return matrix.getLoginFlows(url)
}).then(flows => {
setSupportedFlows(flows)
}).catch(err => {
setError(err.message)
setSupportedFlows(["m.login.password"])
})
}
}
useEffect(() => {
if (localStorage.mxHomeserver && query.loginToken) {
console.log("Found homeserver in localstorage and loginToken in query, " +
"attempting SSO token login")
setError(null)
setLoading(true)
submit(query.loginToken, localStorage.mxHomeserver)
.catch(err => console.error("Fatal error:", err))
.finally(() => setLoading(false))
const url = new URL(location.href)
url.searchParams.delete("loginToken")
history.replaceState({}, document.title, url.toString())
}
}, [])
const submit = async (token, serverURLOverride) => {
let authInfo
if (token) {
authInfo = {
type: "m.login.token",
token,
}
} else {
authInfo = {
type: "m.login.password",
identifier: {
type: "m.id.user",
user: username,
},
password,
}
}
try {
const actualServerURL = serverURLOverride || serverURL
const [accessToken, userID, realURL] = await matrix.login(actualServerURL, authInfo)
const openIDToken = await matrix.requestOpenIDToken(realURL, userID, accessToken)
const integrationData = await matrix.requestIntegrationToken(openIDToken)
localStorage.mxHomeserver = realURL
localStorage.mxAccessToken = accessToken
localStorage.mxUserID = userID
localStorage.stickerSetupAccessToken = integrationData.token
onLoggedIn()
} catch (err) {
setError(err.message)
}
}
const onSubmit = evt => {
evt.preventDefault()
setError(null)
setLoading(true)
submit()
.catch(err => console.error("Fatal error:", err))
.finally(() => setLoading(false))
}
const startSSOLogin = () => {
const redir = encodeURIComponent(location.href)
location.href = `${serverURL}/_matrix/client/r0/login/sso/redirect?redirectUrl=${redir}`
}
const usernameWrapperClick = evt => evt.target === usernameWrapperRef.current
&& usernameRef.current.focus()
const ssoButton = html`
<${Button} type="button" disabled=${!serverURL} onClick=${startSSOLogin}
title=${!serverURL ? "Enter your server name before using SSO" : undefined}>
${loading ? html`<${Spinner} size=30/>` : "Log in with SSO"}
</Button>
`
const disablePwLogin = !username || !server || !serverURL
const loginButton = html`
<${Button} type="submit" disabled=${disablePwLogin}
title=${disablePwLogin ? "Fill out the form before submitting" : undefined}>
${loading ? html`<${Spinner} size=30/>` : "Log in"}
</Button>
`
return html`
<main class="login-view">
<form class="login-box ${error ? "has-error" : ""}" onSubmit=${onSubmit}>
<h1>Stickerpicker setup</h1>
<div class="username input ${userIDFocused ? "focus" : ""}"
ref=${usernameWrapperRef} onClick=${usernameWrapperClick}>
<span onClick=${() => usernameRef.current.focus()}>@</span>
<input type="text" placeholder="username" name="username" value=${username}
onChange=${evt => setUsername(evt.target.value)} ref=${usernameRef}
onKeyDown=${keyDown} onFocus=${onFocus} onBlur=${onBlur}
onPaste=${paste}/>
<span onClick=${() => serverRef.current.focus()}>:</span>
<input type="text" placeholder="example.com" name="server" value=${server}
onChange=${evt => setServer(evt.target.value)} ref=${serverRef}
onKeyDown=${keyDown} onFocus=${onFocus} onBlur=${onBlur}/>
</div>
<input type="password" placeholder="password" name="password" value=${password}
class="password input" ref=${passwordRef}
disabled=${supportedFlows && !supportedFlows.includes("m.login.password")}
onChange=${evt => setPassword(evt.target.value)}/>
<div class="button-group">
${supportedFlows === null && html`<${Spinner} green size=30 />`}
${supportedFlows?.includes("m.login.sso") && ssoButton}
${supportedFlows?.includes("m.login.password") && loginButton}
</div>
${error && html`<div class="error">${error}</div>`}
</form>
</main>`
}
export default LoginView

View File

@ -1,20 +0,0 @@
// 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, render } from "../../lib/htm/preact.js"
import App from "./App.js"
render(html`<${App} />`, document.body)

View File

@ -1,112 +0,0 @@
// 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 { tryFetch, integrationPrefix } from "./tryGet.js"
export const resolveWellKnown = async (server) => {
try {
const resp = await fetch(`https://${server}/.well-known/matrix/client`)
const data = await resp.json()
let url = data["m.homeserver"].base_url
if (url.endsWith("/")) {
url = url.slice(0, -1)
}
return url
} catch (err) {
console.error("Resolution failed:", err)
throw new Error(`Failed to resolve Matrix URL for ${server}`)
}
}
export const getLoginFlows = async (address) => {
const data = await tryFetch(`${address}/_matrix/client/r0/login`, {},
{ service: address, requestType: "get login flows" })
const flows = []
for (const flow of data.flows) {
flows.push(flow.type)
}
return flows
}
export const login = async (address, authInfo) => {
const data = await tryFetch(`${address}/_matrix/client/r0/login`, {
method: "POST",
body: JSON.stringify({
...authInfo,
/* eslint-disable camelcase */
device_id: "maunium-stickerpicker",
initial_device_display_name: "maunium-stickerpicker",
/* eslint-enable camelcase */
}),
headers: {
"Content-Type": "application/json",
},
}, {
service: address,
requestType: "login",
})
if (data.well_known && data.well_known["m.homeserver"]) {
address = data.well_known["m.homeserver"].base_url || address
if (address.endsWith("/")) {
address = address.slice(0, -1)
}
}
return [data.access_token, data.user_id, address]
}
export const whoami = (address, accessToken) => tryFetch(
`${address}/_matrix/client/r0/account/whoami`,
{
headers: { Authorization: `Bearer ${accessToken}` },
},
{
service: address,
requestType: "whoami",
},
)
export const requestOpenIDToken = (address, userID, accessToken) => tryFetch(
`${address}/_matrix/client/r0/user/${userID}/openid/request_token`,
{
method: "POST",
body: "{}",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
},
{
service: "OpenID",
requestType: "token",
},
)
export const requestIntegrationToken = tokenData => tryFetch(
`${integrationPrefix}/account/register`, {
method: "POST",
body: JSON.stringify(tokenData),
headers: {
"Content-Type": "application/json",
},
},
{
service: "sticker server",
requestType: "register",
})
export const logout = () => tryFetch(`${integrationPrefix}/account/logout`, { method: "POST" }, {
service: "sticker server",
requestType: "logout",
})

View File

@ -1,34 +0,0 @@
// 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 { tryFetch as tryFetchDefault, setupPrefix } from "./tryGet.js"
const service = "setup API"
const tryFetch = (url, options, reqInfo) => {
if (!options.headers?.Authorization) {
if (!options.headers) {
options.headers = {}
}
options.headers.Authorization = `Bearer ${localStorage.stickerSetupAccessToken}`
}
return tryFetchDefault(url, options, reqInfo)
}
export const whoami = () => tryFetch(
`${setupPrefix}/whoami`,
{}, { service, requestType: "whoami" },
)

View File

@ -1,73 +0,0 @@
// 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/>.
export const integrationPrefix = "../_matrix/integrations/v1"
export const setupPrefix = "api"
export const queryToURL = (url, query) => {
if (!Array.isArray(query)) {
query = Object.entries(query)
}
query = query.map(([key, value]) =>
[key, typeof value === "string" ? value : JSON.stringify(value)])
url = `${url}?${new URLSearchParams(query)}`
return url
}
class MatrixError extends Error {
constructor(data, status) {
super(data.error)
this.code = data.errcode
this.httpStatus = status
}
}
export const tryFetch = async (url, options, reqInfo) => {
if (options.query) {
url = queryToURL(url, options.query)
delete options.query
}
const reqName = `${reqInfo.service} ${reqInfo.requestType}`
let resp
try {
resp = await fetch(url, options)
} catch (err) {
console.error(reqName, "request failed:", err)
throw new Error(`Failed to contact ${reqInfo.service}`)
}
if (resp.status === 502) {
console.error("Unexpected", reqName, "request bad gateway:", await resp.text())
throw new Error(`Failed to contact ${reqInfo.service}`)
}
if (reqInfo.raw) {
return resp
} else if (resp.status === 204) {
return
}
let data
try {
data = await resp.json()
} catch (err) {
console.error(reqName, "request JSON parse failed:", err)
throw new Error(`Invalid response from ${reqInfo.service}`)
}
if (data.error && data.errcode) {
throw new MatrixError(data, resp.status)
} else if (resp.status >= 400) {
console.error("Unexpected", reqName, "request status:", resp.status, data)
throw new Error(data.error || data.message || `Invalid response from ${reqInfo.service}`)
}
return data
}

View File

@ -15,7 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { html } from "../lib/htm/preact.js"
const Spinner = ({ size = 40, noCenter = false, noMargin = false, green = false }) => {
export const Spinner = ({ size = 40, noCenter = false, noMargin = false, green = false }) => {
let margin = 0
if (!isNaN(+size)) {
size = +size
@ -39,5 +39,3 @@ const Spinner = ({ size = 40, noCenter = false, noMargin = false, green = false
}
return comp
}
export default Spinner

View File

@ -0,0 +1,18 @@
export const getUserAgent = () => navigator.userAgent || navigator.vendor || window.opera
export const checkiOSDevice = () => {
const agent = getUserAgent()
return agent.match(/(iPod|iPhone|iPad)/)
}
export const checkMobileSafari = () => {
const agent = getUserAgent()
return agent.match(/(iPod|iPhone|iPad)/) && agent.match(/AppleWebKit/)
}
export const checkAndroid = () => {
const agent = getUserAgent()
return agent.match(/android/i)
}
export const checkMobileDevice = () => checkiOSDevice() || checkAndroid()

View File

@ -13,6 +13,8 @@
//
// 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 { focusSearchBar } from "./search-box.js"
let widgetId = null
window.onmessage = event => {
@ -33,10 +35,12 @@ window.onmessage = event => {
widgetId = request.widgetId
}
let response
let response = {}
if (request.action === "visibility") {
response = {}
if (request.action === "visibility") { // visibility of the widget changed
if (request.visible) {
focusSearchBar() // we have to re-focus the search bar when appropriate
}
} else if (request.action === "capabilities") {
response = { capabilities: ["m.sticker"] }
} else {

View File

@ -1 +0,0 @@
button.mau-button{cursor:pointer;margin:.5rem 0;border-radius:.25rem;font-size:1rem;box-sizing:border-box;padding:0}button.mau-button:disabled{cursor:default}button.mau-button.size-thick{height:3rem}button.mau-button.size-normal{height:2.5rem}button.mau-button.size-thin{height:2rem}button.mau-button.variant-filled{background-color:#2e7d32;color:#fff;border:none}button.mau-button.variant-filled:hover{background-color:#005005}button.mau-button.variant-filled:disabled{background-color:#CCC;color:#212121}button.mau-button.variant-outlined{background-color:#fff;border:2px solid #2e7d32;color:#2e7d32}button.mau-button.variant-outlined:hover{background-color:#60ad5e}button.mau-button.variant-outlined:disabled{background-color:#fff;border-color:#CCC}

View File

@ -1,60 +0,0 @@
// 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 theme.sass
button.mau-button
cursor: pointer
margin: .5rem 0
border-radius: .25rem
font-size: 1rem
box-sizing: border-box
padding: 0
&:disabled
cursor: default
&.size-thick
height: 3rem
&.size-normal
height: 2.5rem
&.size-thin
height: 2rem
&.variant-filled
background-color: $primary
color: $primaryContrastText
border: none
&:hover
background-color: $primaryDark
&:disabled
background-color: $disabled
color: $text
&.variant-outlined
background-color: $background
border: 2px solid $primary
color: $primary
&:hover
background-color: $primaryLight
&:disabled
background-color: $background
border-color: $disabled

1
web/style/index.css Normal file
View File

@ -0,0 +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) 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-black{--highlight-color: #222;--search-box-color: var(--highlight-color);--text-color: white;background-color:#000}.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-search{--icon-image: url(../res/search.svg)}.icon.icon-reset{--icon-image: url(../res/reset.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(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}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{display:flex;margin:.5rem;padding:0;border-radius:.4rem;background-color:var(--search-box-color);opacity:.5;transition:all .1s;border:1px solid transparent}div.search-box:focus-within{opacity:1;border:1px solid var(--text-color)}div.search-box:not(:focus-within):hover{opacity:.7}div.search-box input,div.search-box .icon-display{color:var(--text-color);outline:none;border:none;height:1rem;margin:0;padding:.7rem;background-color:transparent;-webkit-tap-highlight-color:transparent}div.search-box .icon-display{width:1rem}div.search-box .icon-display.reset-click-zone{cursor:pointer}div.search-box .icon-display .icon{display:block;width:1rem;height:1rem}div.search-box .icon-display .icon-search{opacity:.5}div.search-box input{flex-grow:1;font-size:1rem}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:not([type=checkbox]){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,22 +56,24 @@ 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
main.theme-black
--highlight-color: #222
--search-box-color: var(--highlight-color)
--text-color: white
background-color: black
@ -84,6 +92,12 @@ main.theme-black
&.icon-recent
--icon-image: url(../res/recent.svg)
&.icon-search
--icon-image: url(../res/search.svg)
&.icon-reset
--icon-image: url(../res/reset.svg)
nav
display: flex
overflow-x: auto
@ -109,12 +123,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
@ -150,6 +168,51 @@ div.sticker
height: 70%
margin: 15%
div.search-box
display: flex
margin: $search-box-input-margin
padding: 0
border-radius: .4rem
background-color: var(--search-box-color)
opacity: .5
transition: all .1s
border: 1px solid transparent
&:focus-within
opacity: 1
border: 1px solid var(--text-color)
&:not(:focus-within):hover
opacity: .7
input,.icon-display
color: var(--text-color)
outline: none
border: none
height: $search-box-input-height
margin: 0
padding: $search-box-input-padding
background-color: transparent
-webkit-tap-highlight-color: transparent
.icon-display
width: $search-box-input-height
&.reset-click-zone
cursor: pointer
.icon
display: block
width: $search-box-icon-size
height: $search-box-icon-size
.icon-search
opacity: .5
input
flex-grow: 1
font-size: 1rem
div.settings-list
display: flex
flex-direction: column
@ -161,5 +224,5 @@ div.settings-list
padding: .5rem
border-radius: .25rem
input
input:not([type="checkbox"])
width: 100%

View File

@ -1 +0,0 @@
main.login-view{position:fixed;top:0;bottom:0;right:0;left:0;background-color:#2e7d32;display:flex;justify-content:space-around}main.login-view form.login-box{background-color:#fff;width:25rem;height:22.5rem;padding:2.5rem 2.5rem 2rem;margin-top:3rem;border-radius:.25rem;box-sizing:border-box;display:flex;flex-direction:column}main.login-view form.login-box.has-error{min-height:27rem;height:auto;margin-bottom:auto}main.login-view form.login-box h1{color:#2e7d32;margin:.5rem auto 3rem;font-size:1.5rem}main.login-view form.login-box .input{margin:.5rem 0;border-radius:.25rem;border:1px solid #DDD;padding:1px}main.login-view form.login-box .input:hover,main.login-view form.login-box .input:focus,main.login-view form.login-box .input.focus{border-color:#2e7d32}main.login-view form.login-box .input:focus,main.login-view form.login-box .input.focus{border-width:2px;padding:0}main.login-view form.login-box .username{display:flex;cursor:text}main.login-view form.login-box .username>input{border:none;padding:.75rem .125rem;color:#212121;min-width:0;font-size:1rem}main.login-view form.login-box .username>input:last-of-type{padding-right:.5rem;border-radius:0 .25rem .25rem 0}main.login-view form.login-box .username>input:focus{outline:none}main.login-view form.login-box .username>span{user-select:none;padding:.75rem 0;color:#212121}main.login-view form.login-box .username>span:first-of-type{padding-left:.5rem}main.login-view form.login-box .password{font-size:1rem;margin:.5rem 0;border-radius:.25rem;border:1px solid #DDD;padding:calc(.75rem + 1px) 1rem;box-sizing:border-box}main.login-view form.login-box .password:hover:not(:disabled),main.login-view form.login-box .password:focus:not(:disabled){border-color:#2e7d32}main.login-view form.login-box .password:focus{padding:0.75rem calc(1rem - 1px);border-width:2px;outline:none}main.login-view form.login-box .button-group{display:flex;gap:4px}main.login-view form.login-box .button-group button{width:100%}main.login-view form.login-box .error{padding:1rem;border-radius:.25rem;border:2px solid #B71C1C;background-color:#F7A9A1;margin:.5rem 0;width:100%;box-sizing:border-box}

View File

@ -1,105 +0,0 @@
// 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 theme.sass
main.login-view
position: fixed
top: 0
bottom: 0
right: 0
left: 0
background-color: $primary
display: flex
justify-content: space-around
form.login-box
background-color: $background
width: 25rem
height: 22.5rem
padding: 2.5rem 2.5rem 2rem
margin-top: 3rem
border-radius: .25rem
box-sizing: border-box
display: flex
flex-direction: column
&.has-error
min-height: 27rem
height: auto
margin-bottom: auto
h1
color: $primary
margin: .5rem auto 3rem
font-size: 1.5rem
.input
margin: .5rem 0
border-radius: .25rem
border: 1px solid $border
padding: 1px
&:hover, &:focus, &.focus
border-color: $primary
&:focus, &.focus
border-width: 2px
padding: 0
.username
display: flex
cursor: text
& > input
border: none
padding: .75rem .125rem
color: $text
min-width: 0
font-size: 1rem
&:last-of-type
padding-right: .5rem
border-radius: 0 .25rem .25rem 0
&:focus
outline: none
& > span
user-select: none
padding: .75rem 0
color: $text
&:first-of-type
padding-left: .5rem
.password
@include input
.button-group
display: flex
gap: 4px
button
width: 100%
.error
padding: 1rem
border-radius: .25rem
border: 2px solid $errorDark
background-color: $error
margin: .5rem 0
width: 100%
box-sizing: border-box

View File

@ -1 +0,0 @@
body{font-family:sans-serif}

View File

@ -1,18 +0,0 @@
// 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/>.
body
font-family: sans-serif

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)}}

View File

View File

@ -1,33 +0,0 @@
// Material UI green 800
$primary: #2e7d32
$primaryDark: #005005
$primaryLight: #60ad5e
// Material UI blue 700
$secondary: #1976d2
$secondaryDark: #004ba0
$secondaryLight: #63a4ff
$error: #F7A9A1
$errorDark: #B71C1C
$primaryContrastText: white
$background: white
$text: #212121
$border: #DDD
$disabled: #CCC
@mixin input
font-size: 1rem
margin: .5rem 0
border-radius: .25rem
border: 1px solid $border
padding: calc(.75rem + 1px) 1rem
box-sizing: border-box
&:hover:not(:disabled), &:focus:not(:disabled)
border-color: $primary
&:focus
padding: .75rem calc(1rem - 1px)
border-width: 2px
outline: none

View File

@ -1 +0,0 @@
*{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%}

File diff suppressed because it is too large Load Diff