Initial Commit

This commit is contained in:
Kentai Radiquum 2024-12-28 21:30:03 +05:00
commit e50589906c
Signed by: Radiquum
GPG key ID: 858E8EE696525EED
38 changed files with 3645 additions and 0 deletions

View file

@ -0,0 +1,34 @@
// 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/>.
const FREQUENTLY_USED = JSON.parse(window.localStorage.mauFrequentlyUsedStickerIDs || "{}")
let FREQUENTLY_USED_SORTED = null
export const add = id => {
const [count] = FREQUENTLY_USED[id] || [0]
FREQUENTLY_USED[id] = [count + 1, Date.now()]
window.localStorage.mauFrequentlyUsedStickerIDs = JSON.stringify(FREQUENTLY_USED)
FREQUENTLY_USED_SORTED = null
}
export const get = (limit = 16) => {
if (FREQUENTLY_USED_SORTED === null) {
FREQUENTLY_USED_SORTED = Object.entries(FREQUENTLY_USED)
.sort(([, [count1, date1]], [, [count2, date2]]) =>
count2 === count1 ? date2 - date1 : count2 - count1)
.map(([emoji]) => emoji)
}
return FREQUENTLY_USED_SORTED.slice(0, limit)
}

498
stickerpicker/src/index.js Normal file
View file

@ -0,0 +1,498 @@
// 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, Component } from "../lib/htm/preact.js";
import { Spinner } from "./spinner.js";
import { SearchBox } from "./search-box.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 = "../stickerpacks";
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";
// const makeThumbnailURL = mxc => `${HOMESERVER_URL}/_matrix/media/v3/thumbnail/${mxc.slice(6)}?height=128&width=128&method=scale`
const makeThumbnailURL = (mxc) =>
`${HOMESERVER_URL}/__thumbnail/${mxc.slice(0, 2)}/${mxc.slice(
2,
4
)}/${mxc.slice(4)}`;
// 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 supportedThemes = ["light", "dark", "black"];
const defaultState = {
packs: [],
filtering: {
searchTerm: "",
packs: [],
},
};
class App extends Component {
constructor(props) {
super(props);
this.defaultTheme = params.get("theme");
this.state = {
viewingGifs: false,
packs: defaultState.packs,
loading: true,
error: null,
stickersPerRow: parseInt(localStorage.mauStickersPerRow || "4"),
theme: localStorage.mauStickerThemeOverride || this.defaultTheme,
frequentlyUsed: {
id: "frequently-used",
title: "Frequently used",
stickerIDs: frequent.get(),
stickers: [],
},
filtering: defaultState.filtering,
};
if (!supportedThemes.includes(this.state.theme)) {
this.state.theme = "light";
}
if (!supportedThemes.includes(this.defaultTheme)) {
this.defaultTheme = "light";
}
this.stickersByID = new Map(
JSON.parse(localStorage.mauFrequentlyUsedStickerCache || "[]")
);
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.sendSticker = this.sendSticker.bind(this);
this.navScroll = this.navScroll.bind(this);
this.reloadPacks = this.reloadPacks.bind(this);
this.observeSectionIntersections =
this.observeSectionIntersections.bind(this);
this.observeImageIntersections = this.observeImageIntersections.bind(this);
}
_getStickersByID(ids) {
return ids
.map((id) => this.stickersByID.get(id))
.filter((sticker) => !!sticker);
}
updateFrequentlyUsed() {
const stickerIDs = frequent.get();
const stickers = this._getStickersByID(stickerIDs);
this.setState({
frequentlyUsed: {
...this.state.frequentlyUsed,
stickerIDs,
stickers,
},
});
localStorage.mauFrequentlyUsedStickerCache = JSON.stringify(
stickers.map((sticker) => [sticker.id, sticker])
);
}
searchStickers(e) {
const sanitizeString = (s) => s.toLowerCase().trim();
const searchTerm = sanitizeString(e.target.value);
const allPacks = [this.state.frequentlyUsed, ...this.state.packs];
const packsWithFilteredStickers = allPacks.map((pack) => ({
...pack,
stickers: pack.stickers.filter(
(sticker) =>
sanitizeString(sticker.body).includes(searchTerm) ||
sanitizeString(sticker.id).includes(searchTerm)
),
}));
this.setState({
filtering: {
...this.state.filtering,
searchTerm,
packs: packsWithFilteredStickers.filter(
({ stickers }) => !!stickers.length
),
},
});
}
setStickersPerRow(val) {
localStorage.mauStickersPerRow = val;
document.documentElement.style.setProperty(
"--stickers-per-row",
localStorage.mauStickersPerRow
);
this.setState({
stickersPerRow: val,
});
this.packListRef.scrollTop = this.packListRef.scrollHeight;
}
setTheme(theme) {
if (theme === "default") {
delete localStorage.mauStickerThemeOverride;
this.setState({ theme: this.defaultTheme });
} else {
localStorage.mauStickerThemeOverride = theme;
this.setState({ theme: theme });
}
}
reloadPacks() {
this.imageObserver.disconnect();
this.sectionObserver.disconnect();
this.setState({
packs: defaultState.packs,
filtering: defaultState.filtering,
});
this._loadPacks(true);
}
_loadPacks(disableCache = false) {
const cache = disableCache ? "no-cache" : undefined;
fetch(INDEX, { cache }).then(
async (indexRes) => {
if (indexRes.status >= 400) {
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) {
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);
}
this.setState({
packs: [...this.state.packs, packData],
loading: false,
});
}
this.updateFrequentlyUsed();
},
(error) => this.setState({ loading: false, error })
);
}
componentDidMount() {
document.documentElement.style.setProperty(
"--stickers-per-row",
this.state.stickersPerRow.toString()
);
this._loadPacks();
this.imageObserver = new IntersectionObserver(
this.observeImageIntersections,
{
rootMargin: "100px",
}
);
this.sectionObserver = new IntersectionObserver(
this.observeSectionIntersections
);
}
observeImageIntersections(intersections) {
for (const entry of intersections) {
const img = entry.target.children.item(0);
if (entry.isIntersecting) {
img.setAttribute("src", img.getAttribute("data-src"));
img.classList.add("visible");
} else {
img.removeAttribute("src");
img.classList.remove("visible");
}
}
}
observeSectionIntersections(intersections) {
const navWidth = this.navRef.getBoundingClientRect().width;
let minX = 0,
maxX = navWidth;
let minXElem = null;
let maxXElem = null;
for (const entry of intersections) {
const packID = entry.target.getAttribute("data-pack-id");
if (!packID) {
continue;
}
const navElement = document.getElementById(`nav-${packID}`);
if (entry.isIntersecting) {
navElement.classList.add("visible");
const bb = navElement.getBoundingClientRect();
if (bb.x < minX) {
minX = bb.x;
minXElem = navElement;
} else if (bb.right > maxX) {
maxX = bb.right;
maxXElem = navElement;
}
} else {
navElement.classList.remove("visible");
}
}
if (minXElem !== null) {
minXElem.scrollIntoView({ inline: "start" });
} else if (maxXElem !== null) {
maxXElem.scrollIntoView({ inline: "end" });
}
}
componentDidUpdate() {
if (this.packListRef === null) {
return;
}
for (const elem of this.packListRef.getElementsByClassName("sticker")) {
this.imageObserver.observe(elem);
}
for (const elem of this.packListRef.children) {
this.sectionObserver.observe(elem);
}
}
componentWillUnmount() {
this.imageObserver.disconnect();
this.sectionObserver.disconnect();
}
sendSticker(evt) {
const id = evt.currentTarget.getAttribute("data-sticker-id");
const sticker = this.stickersByID.get(id);
frequent.add(id);
this.updateFrequentlyUsed();
widgetAPI.sendSticker(sticker);
}
navScroll(evt) {
this.navRef.scrollLeft += evt.deltaY;
}
render() {
const theme = `theme-${this.state.theme}`;
const filterActive = !!this.state.filtering.searchTerm;
const packs = filterActive
? this.state.filtering.packs
: [this.state.frequentlyUsed, ...this.state.packs];
if (this.state.loading) {
return html`
<main class="spinner ${theme}">
<${Spinner} size=${80} green />
</main>
`;
} else if (this.state.error) {
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>
`;
}
const onClickOverride = this.state.viewingGifs
? (evt, packID) => {
evt.preventDefault();
this.setState({ viewingGifs: false }, () => {
scrollToSection(null, packID);
});
}
: null;
return html` <main class="has-content ${theme}">
<nav onWheel=${this.navScroll} ref=${(elem) => (this.navRef = elem)}>
<${NavBarItem}
pack=${this.state.frequentlyUsed}
iconOverride="recent"
onClickOverride=${onClickOverride}
/>
${this.state.packs.map(
(pack) =>
html`<${NavBarItem}
id=${pack.id}
pack=${pack}
onClickOverride=${onClickOverride}
/>`
)}
<${NavBarItem}
pack=${{ id: "settings", title: "Settings" }}
iconOverride="settings"
onClickOverride=${onClickOverride}
/>
</nav>
<${SearchBox}
onInput=${this.searchStickers}
value=${this.state.filtering.searchTerm ?? ""}
/>
<div
class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}"
ref=${(elem) => (this.packListRef = elem)}
>
${filterActive && packs.length === 0
? html`<div class="search-empty">
<h1>No stickers match your search</h1>
</div>`
: null}
${packs.map(
(pack) =>
html`<${Pack}
id=${pack.id}
pack=${pack}
send=${this.sendSticker}
/>`
)}
<${Settings} app=${this} />
</div>
</main>`;
}
}
const Settings = ({ app }) => html`
<section
class="stickerpack settings"
id="pack-settings"
data-pack-id="settings"
>
<h1>Settings</h1>
<div class="settings-list">
<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"
id="stickers-per-row"
value=${app.state.stickersPerRow}
onInput=${(evt) => app.setStickersPerRow(evt.target.value)}
/>
</div>
<div>
<label for="theme">Theme: </label>
<select
name="theme"
id="theme"
onChange=${(evt) => app.setTheme(evt.target.value)}
>
<option value="default">Default</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="black">Black</option>
</select>
</div>
</div>
</section>
`;
// By default we just let the browser handle scrolling to sections, but webviews on Element iOS
// 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}`);
if (pack) {
pack.scrollIntoView({ block: "start", behavior: "instant" });
}
evt?.preventDefault();
};
const NavBarItem = ({
pack,
iconOverride = null,
onClickOverride = null,
extraClass = null,
}) => html`
<a
href="#pack-${pack.id}"
id="nav-${pack.id}"
data-pack-id=${pack.id}
title=${pack.title}
class="${extraClass}"
onClick=${onClickOverride
? (evt) => onClickOverride(evt, pack.id)
: isMobileSafari
? (evt) => scrollToSection(evt, pack.id)
: undefined}
>
<div class="sticker">
${iconOverride
? html` <span class="icon icon-${iconOverride}" /> `
: html`
<img
src=${makeThumbnailURL(pack.stickers[0].id)}
alt=${pack.stickers[0].body}
class="visible"
/>
`}
</div>
</a>
`;
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">
${pack.stickers.map(
(sticker) => html`
<${Sticker} key=${sticker.id} content=${sticker} send=${send} />
`
)}
</div>
</section>
`;
const Sticker = ({ content, send }) => html`
<div class="sticker" onClick=${send} data-sticker-id=${content.id}>
<img
data-src=${makeThumbnailURL(content.id)}
alt=${content.body}
title=${content.body}
/>
</div>
`;
render(html`<${App} />`, document.body);

View file

@ -0,0 +1,26 @@
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import {html} from "../lib/htm/preact.js"
export const SearchBox = ({onInput, onKeyUp, value, placeholder = 'Find stickers'}) => {
const component = html`
<div class="search-box">
<input type="text" placeholder=${placeholder} value=${value} onInput=${onInput} onKeyUp=${onKeyUp}/>
<span class="icon icon-search"/>
</div>
`
return component
}

View file

@ -0,0 +1,41 @@
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { html } from "../lib/htm/preact.js"
export const Spinner = ({ size = 40, noCenter = false, noMargin = false, green = false }) => {
let margin = 0
if (!isNaN(+size)) {
size = +size
margin = noMargin ? 0 : `${Math.round(size / 6)}px`
size = `${size}px`
}
const noInnerMargin = !noCenter || !margin
const comp = html`
<div style="width: ${size}; height: ${size}; margin: ${noInnerMargin ? 0 : margin} 0;"
class="sk-chase ${green && "green"}">
<div class="sk-chase-dot" />
<div class="sk-chase-dot" />
<div class="sk-chase-dot" />
<div class="sk-chase-dot" />
<div class="sk-chase-dot" />
<div class="sk-chase-dot" />
</div>
`
if (!noCenter) {
return html`<div style="margin: ${margin} 0;" class="sk-center-wrapper">${comp}</div>`
}
return comp
}

View file

@ -0,0 +1,77 @@
// 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/>.
let widgetId = null
window.onmessage = event => {
if (!window.parent || !event.data) {
return
}
const request = event.data
if (!request.requestId || !request.widgetId || !request.action || request.api !== "toWidget") {
return
}
if (widgetId) {
if (widgetId !== request.widgetId) {
return
}
} else {
widgetId = request.widgetId
}
let response
if (request.action === "visibility") {
response = {}
} else if (request.action === "capabilities") {
response = { capabilities: ["m.sticker"] }
} else {
response = { error: { message: "Action not supported" } }
}
window.parent.postMessage({ ...request, response }, event.origin)
}
export function sendSticker(content) {
const data = {
content: { ...content },
// `name` is for Element Web (and also the spec)
// Element Android uses content -> body as the name
name: content.body,
}
// Custom field that stores the ID even for non-telegram stickers
delete data.content.id
// This is for Element iOS
const widgetData = {
...data,
description: content.body,
file: content.filename ?? `${content.id}.png`,
}
delete widgetData.content.filename
// Element iOS explodes if there are extra fields present
delete widgetData.content["net.maunium.telegram.sticker"]
window.parent.postMessage({
api: "fromWidget",
action: "m.sticker",
requestId: `sticker-${Date.now()}`,
widgetId,
data,
widgetData,
}, "*")
}