diff --git a/frontend/package.json b/frontend/package.json index e71c0a6..940f5a9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@ionic/react-router": "^7.0.0", "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", + "fuse.js": "^6.6.2", "ionicons": "^7.1.2", "preact": "^10.17.0", "react-dom": "^18.2.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3c8ce2f..080c075 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -17,6 +17,9 @@ dependencies: '@types/react-router-dom': specifier: ^5.3.3 version: 5.3.3 + fuse.js: + specifier: ^6.6.2 + version: 6.6.2 ionicons: specifier: ^7.1.2 version: 7.1.2 @@ -2669,6 +2672,11 @@ packages: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} dev: true + /fuse.js@6.6.2: + resolution: {integrity: sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==} + engines: {node: '>=10'} + dev: false + /gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} diff --git a/frontend/src/components/BotItem.css b/frontend/src/components/BotItem.css new file mode 100644 index 0000000..681ec66 --- /dev/null +++ b/frontend/src/components/BotItem.css @@ -0,0 +1,3 @@ +.botChip { + text-decoration: none; +} diff --git a/frontend/src/components/BotItem.tsx b/frontend/src/components/BotItem.tsx new file mode 100644 index 0000000..b652a19 --- /dev/null +++ b/frontend/src/components/BotItem.tsx @@ -0,0 +1,47 @@ +import { + IonItem, + IonLabel, + IonChip, + IonBadge, + IonIcon, + IonAvatar, +} from "@ionic/react"; +import { languageOutline, openOutline } from "ionicons/icons"; + +import { Bot } from "../store"; +import "./BotItem.css"; + +export default function BotItem({ bot }: { bot: Bot }) { + return ( + + + + + + + + {bot.addr} + + + +
+ + {bot.lang.label} + +

{bot.description}

+

+ Admin: + + {bot.admin.name} + + +

+
+
+ ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 43bd252..747fd9e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,11 @@ import React from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; import { init } from "./store"; +import { setupIonicReact } from "@ionic/react"; + +setupIonicReact({ + mode: "ios", +}); const container = document.getElementById("root"); const root = createRoot(container!); diff --git a/frontend/src/pages/Home.css b/frontend/src/pages/Home.css index 58d29cd..0c354f1 100644 --- a/frontend/src/pages/Home.css +++ b/frontend/src/pages/Home.css @@ -11,3 +11,10 @@ width: 100px; height: 100px; } + +#footer { + text-align: center; + font-size: 16px; + line-height: 22px; + color: #8c8c8c; +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 433790c..c626066 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,65 +1,78 @@ import { IonContent, IonPage, - IonHeader, - IonToolbar, - IonTitle, IonItem, - IonLabel, IonList, - IonChip, - IonBadge, - IonIcon, IonSpinner, + IonSearchbar, + IonToast, + IonProgressBar, } from "@ionic/react"; -import { languageOutline, openOutline } from "ionicons/icons"; -import { useStore } from "../store"; +import Fuse from "fuse.js"; +import { create } from "zustand"; +import { useState } from "react"; +import { warningOutline } from "ionicons/icons"; + +import { useStore, Bot } from "../store"; +import BotItem from "../components/BotItem"; import "./Home.css"; +const fuseOptions = { + keys: ["addr", "description", "admin.name", "lang.label"], + threshold: 0.4, +}; + +interface HomeState { + query: string; + results: Bot[]; +} + +const homeStore = create()((set) => ({ + query: "", + results: [], +})); + const Home: React.FC = () => { const state = useStore(); - const data = state.data || { bots: [], admins: {}, langs: {} }; + const query = homeStore((state) => state.query); + let results = homeStore((state) => state.results); + if (!query) { + results = state.bots; + } + const fuse = new Fuse(state.bots, fuseOptions); + const handleInput = (ev: Event) => { + const target = ev.target as HTMLIonSearchbarElement; + const query = target ? target.value!.toLowerCase() : ""; + if (query) { + homeStore.setState({ + query: query, + results: fuse.search(query).map((result) => result.item), + }); + } else { + homeStore.setState({ query: query }); + } + }; + return ( - - - - Public Bots - {state.lastSync && ( - {state.lastSync.toLocaleString()} - )} - - - + {state.syncing && state.lastUpdated && ( + + )} + {state.bots.length > 0 && ( + <> +
+ handleInput(ev)} + placeholder={"Search among " + state.bots.length + " bots"} + > + + )} {state.lastUpdated ? ( - {data.bots.map((bot) => ( - - -

- - {bot.addr} - - -

- - {data.langs[bot.lang]} - -

{bot.description}

-

- Admin: - - {bot.admin} - - -

-
-
+ {results.map((bot) => ( + ))}
) : ( @@ -67,6 +80,19 @@ const Home: React.FC = () => { )} + {state.lastSync && ( + + )} + {state.error && ( + useStore.setState({ error: undefined })} + duration={5000} + > + )}
); diff --git a/frontend/src/store.ts b/frontend/src/store.ts index de5c60d..eacee40 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -4,6 +4,7 @@ const api = (() => { return { sync: () => { localStorage.setItem(lastSyncReqKey, new Date().toString()); + useStore.setState({ syncing: true }); window.webxdc.sendUpdate( { payload: { id: "sync", method: "Sync", params: [null] } }, "", @@ -14,50 +15,66 @@ const api = (() => { const lastSyncKey = "LastSyncKey"; const lastSyncReqKey = "LastSyncReqKey"; const lastUpdatedKey = "LastUpdatedKey"; -const dataKey = "DataKey"; +const botsKey = "BotsKey"; const maxSerialKey = "MaxSerialKey"; type Response = { id: string; result?: any; - error?: { code: number; message: string; data: any }; + error?: Error; }; -type Admin = { url: string }; -type Bot = { +type Error = { code: number; message: string; data: any }; +type Admin = { name: string; url: string }; +type Lang = { label: string; code: string }; +export type Bot = { addr: string; url: string; description: string; - lang: string; - admin: string; + lang: Lang; + admin: Admin; }; interface State { lastSync?: Date; lastUpdated?: string; - data?: { - bots: Bot[]; - admins: { [key: string]: Admin }; - langs: { [key: string]: string }; - }; + bots: Bot[]; + error?: Error; + syncing: boolean; applyWebxdcUpdate: (update: Response) => void; } export const useStore = create()((set) => ({ + syncing: false, + bots: [], applyWebxdcUpdate: (update: Response) => set((state) => { + state = { ...state, syncing: false }; if (update.error) { - return; // TODO: display error? + return { + ...state, + error: update.error, + }; } const result = update.result; if (result) { localStorage.setItem(lastSyncKey, new Date().toString()); localStorage.setItem(lastUpdatedKey, result.lastUpdated); - localStorage.setItem(dataKey, JSON.stringify(result.data)); - const newState = { + const data = result.data; + data.bots.map((bot: any) => { + bot.admin = { ...data.admins[bot.admin], name: bot.admin }; + bot.lang = { code: bot.lang, label: data.langs[bot.lang] }; + }); + data.bots.sort((a: Bot, b: Bot) => { + if (a.addr < b.addr) { + return -1; + } + return 1; + }); + localStorage.setItem(botsKey, JSON.stringify(data.bots)); + return { ...state, lastUpdated: result.lastUpdated, - data: result.data, + bots: data.bots, }; - return result; } return state; }), @@ -80,20 +97,24 @@ export async function init() { const lastUpdated = localStorage.getItem(lastUpdatedKey); if (lastUpdated) { const lastSync = new Date(localStorage.getItem(lastSyncKey) || ""); - const data = JSON.parse(localStorage.getItem(dataKey) || ""); + const bots = JSON.parse(localStorage.getItem(botsKey) || ""); useStore.setState({ ...useStore.getState(), lastSync: lastSync, lastUpdated: lastUpdated, - data: data, + bots: bots, }); } - const lastSyncReq = localStorage.getItem(lastSyncReqKey) || ""; - if ( - !lastSyncReq || - new Date().getTime() - new Date(lastSyncReq).getTime() > 1000 * 60 * 10 - ) { - api.sync(); - } + setInterval(() => { + const lastSyncReq = localStorage.getItem(lastSyncReqKey) || ""; + const lastUpdated = localStorage.getItem(lastUpdatedKey); + if ( + !lastSyncReq || + new Date().getTime() - new Date(lastSyncReq).getTime() > + 1000 * 60 * (lastUpdated ? 10 : 1) + ) { + api.sync(); + } + }, 5000); } diff --git a/frontend/webxdc.js b/frontend/webxdc.js index 1e82fb0..95c536d 100644 --- a/frontend/webxdc.js +++ b/frontend/webxdc.js @@ -245,4 +245,236 @@ window.alterXdcApp = () => { } }; -window.addEventListener("load", window.alterXdcApp); +//window.addEventListener("load", window.alterXdcApp); + +// mock data +window.webxdc.sendUpdate( + { + payload: { + error: null, + id: "sync", + result: { + data: { + admins: { adbenitez: { url: "mailto:adbenitez@hispanilandia.net" } }, + bots: [ + { + addr: "adb_bot1@testrun.org", + admin: "adbenitez", + description: "Web gateway, get URL previews and download files", + lang: "en", + url: "OPENPGP4FPR:8D0025A5DDA22D50EB38A731DC8D7EB24BECDFEB#a=adb%5Fbot1%40testrun.org&n=www&i=N2ZpQ9wDKLq&s=lr1Z8T3TlOI", + }, + { + addr: "superrrrlonggggaddresssbottttt@superlong.domain.org", + admin: "adbenitez", + description: "A bot with a super long address and description is also super long, very very long, as you can probably notice by now, this is quite the long detailed description for an imaginary bot, hope you enjoy using this bot, please support us sharing the word, thanks in advance! and thanks for reading this much! we work hard to improve our bot description, stay tuned", + lang: "en", + url: "OPENPGP4FPR:8D0025A5DDA22D50EB38A731DC8D7EB24BECDFEB#a=adb%5Fbot1%40testrun.org&n=www&i=N2ZpQ9wDKLq&s=lr1Z8T3TlOI", + }, + { + addr: "cartelera@hispanilandia.net", + admin: "adbenitez", + description: "Permite consultar la cartelera de la TV cubana", + lang: "es", + url: "OPENPGP4FPR:D0E1D04F7CB4DF675FF40C16B8757470D98E7742#a=cartelera%40hispanilandia.net&n=Cartelera%20TV&i=bE_sYQa0JZD&s=eyf5eQIShJT", + }, + { + addr: "chatbot@testrun.org", + admin: "adbenitez", + description: "Conversational AI bot, talk to it in private", + lang: "multi", + url: "OPENPGP4FPR:ACB006F0EF18032E1992A64BF1BD44F8385AE3D4#a=chatbot%40testrun.org&n=ChatBot&i=mbY70x0thoC&s=kB_C5bW3jIr", + }, + { + addr: "deltabot@buzon.uy", + admin: "adbenitez", + description: "Miscellaneous bot", + lang: "en", + url: "OPENPGP4FPR:C823D993CF37BF5D8C834F8F08505516CF8AB8C8#a=deltabot%40buzon.uy&n=Misc.%20Bot&i=YMorOP_2ppb&s=LX4bGaOhVu-", + }, + { + addr: "deltalandbot@testrun.org", + admin: "adbenitez", + description: "Deltaland, fantasy world, chat adventure, MMO game", + lang: "en", + url: "OPENPGP4FPR:FD06CE9EA9562A51FA7FCA84B026574F9FB923A8#a=deltalandbot%40testrun.org&n=Deltaland%20Bot%20%5BBETA%5D&i=QdEBHZBR8yI&s=AuLHwV5BqVi", + }, + { + addr: "downloaderbot@hispanilandia.net", + admin: "adbenitez", + description: + "File downloader bot, get files from the web to your inbox", + lang: "en", + url: "OPENPGP4FPR:83D90328467A9216D3244B5AA23F544DFED077E9#a=downloaderbot%40hispanilandia.net&n=File%20Downloader&i=v-cJnR80WCy&s=q6LqhqGfLR6", + }, + { + addr: "faqbot@testrun.org", + admin: "adbenitez", + description: + "FAQ bot, allows saving answer to common questions or #tags", + lang: "en", + url: "OPENPGP4FPR:279714071CC59EB4A9943122A3B4FF4BB7264A0E#a=faqbot%40testrun.org&n=FAQ%20Bot&i=PhdQtXTJQkp&s=WAPGhvIBtEy", + }, + { + addr: "feedsbot@hispanilandia.net", + admin: "adbenitez", + description: "Allows to subscribe to RSS/Atom feeds", + lang: "en", + url: "OPENPGP4FPR:EDBCBD0131B2216D60F76FF46834D1E33169F00E#a=feedsbot%40hispanilandia.net&n=FeedsBot&i=7AYtkEyVmW8&s=1HWCvzIMM9M", + }, + { + addr: "groupsbot@hispanilandia.net", + admin: "adbenitez", + description: + "Public super groups and channels (anoymous mailing lists)", + lang: "en", + url: "OPENPGP4FPR:6185B0FC60681A7F06A31735070D21CEEB40B859#a=groupsbot%40hispanilandia.net&n=SuperGroupsBot&i=e_XiPctpNVS&s=1NRdaNor1Rc", + }, + { + addr: "groupsbot@testrun.org", + admin: "adbenitez", + description: + "Bot that allows to invite friends to your private groups so you don't need to be online for them to join", + lang: "en", + url: "OPENPGP4FPR:6FE1642916908F1AC9CC7557CC99CF5DDB92043C#a=groupsbot%40testrun.org&n=InviteBot&i=AptcQCUYP3X&s=j6C75z6IKU8", + }, + { + addr: "howdoi@hispanilandia.net", + admin: "adbenitez", + description: "Get instant coding answers from Stack Overflow", + lang: "en", + url: "OPENPGP4FPR:118B1592A24183E6D1922F7C8A775F662D0B8DC4#a=howdoi%40hispanilandia.net&n=How%20do%20I%3F&i=JgugrCgP01u&s=7k9-7Z62Um7", + }, + { + addr: "mini-apps@hispanilandia.net", + admin: "adbenitez", + description: "DeltaLab's Mini-Games Store 🎮", + lang: "en", + url: "OPENPGP4FPR:3CC3726E55E69CF4B52368C411819C7E7639B38C#a=mini%2Dapps%40hispanilandia.net&n=&i=jHGRY-9E7jd&s=cRh0KZJmfKJ", + }, + { + addr: "lyrics@hispanilandia.net", + admin: "adbenitez", + description: "Search for song lyrics 🎼🎶🎤", + lang: "en", + url: "OPENPGP4FPR:AAA362B3B891EDA4152DCF40D4A635364D5D9CA0#a=lyrics%40hispanilandia.net&n=LyricsBot&i=sM5oxC789zg&s=MyVVfdzw_cf", + }, + { + addr: "mangadl@testrun.org", + admin: "adbenitez", + description: + "Manga downloader bot with support for several sites and languages", + lang: "multi", + url: "OPENPGP4FPR:8904D68A0B560EEEA20A06031BA3B5859361097B#a=mangadl%40testrun.org&n=MangaDownloader&i=fLXeIm7l2pP&s=Kpn1KG4fWiS", + }, + { + addr: "memes@hispanilandia.net", + admin: "adbenitez", + description: "Get funny memes", + lang: "multi", + url: "OPENPGP4FPR:2099C7D3744F3B62E0C11EE4CFED5478A92DA043#a=memes%40hispanilandia.net&n=Memes%20Bot&i=egz8nDAMV6q&s=oydmbu8ZV6j", + }, + { + addr: "polls@hispanilandia.net", + admin: "adbenitez", + description: + "Polls bot, allows to create and participate in polls", + lang: "en", + url: "OPENPGP4FPR:B47AB02369B0DC86C05E1F1825E7EB00BD917E8D#a=polls%40hispanilandia.net&n=PollsBot&i=4usXSVZ1y_q&s=s201RPZzEDW", + }, + { + addr: "simplebot@systemli.org", + admin: "adbenitez", + description: "Allows to get link/URL previews and search the web", + lang: "en", + url: "OPENPGP4FPR:81B0247BFBB7E3BE20593EB0B0E0983481685179#a=simplebot%40systemli.org&n=www&i=d1JutH49hDH&s=F_Xd0SmbcXM", + }, + { + addr: "simplebot@testrun.org", + admin: "adbenitez", + description: "Mastodon/DeltaChat bridge", + lang: "en", + url: "OPENPGP4FPR:3CD6F460C18365C226A3115E5D5DCC2B68286A7A#a=simplebot%40testrun.org&n=MASTODON%20BRIDGE&i=vliFxNkyG5I&s=CEHn5i91saa", + }, + { + addr: "stickerbot@hispanilandia.net", + admin: "adbenitez", + description: "Allows to download sticker packs", + lang: "en", + url: "OPENPGP4FPR:505ABCB5FE466D5A74A0FD1A33B81CFE12CD0A8D#a=stickerbot%40hispanilandia.net&n=StickerBot&i=wM2bpwc2EzK&s=5YAwTNLcJhp", + }, + { + addr: "tgbridge@testrun.org", + admin: "adbenitez", + description: "Telegram/DeltaChat groups bridge (relay-bot)", + lang: "en", + url: "OPENPGP4FPR:05B5EF4667BF45AF8E437415DF14FC5F0C721EA8#a=tgbridge%40testrun.org&n=Telegram%20Bridge&i=68W2tEfJHrA&s=2wYVxvks-0M", + }, + { + addr: "translator@hispanilandia.net", + admin: "adbenitez", + description: "Translate text to any language", + lang: "en", + url: "OPENPGP4FPR:F6948DDA3046531A190F26FBCBD3E8DC2F7924CB#a=translator%40hispanilandia.net&n=Translator%20Bot&i=wMuG5nircgB&s=Q4r26QE7prU", + }, + { + addr: "uploaderbot@hispanilandia.net", + admin: "adbenitez", + description: "Upload files to a cloud and get the download link", + lang: "en", + url: "OPENPGP4FPR:9C9DA1499EDD478A80994B58C65D6348DFA09264#a=uploaderbot%40hispanilandia.net&n=File%20to%20Link&i=nB8AjS72u07&s=2WWEkH8MfBc", + }, + { + addr: "voice2text@hispanilandia.net", + admin: "adbenitez", + description: "Convert voice messages to text", + lang: "en", + url: "OPENPGP4FPR:7191E7BF4FA2518F608B25678CFB565A6282034B#a=voice2text%40hispanilandia.net&n=Voice%20to%20Text&i=VeVJzQnn8oL&s=HFye19A4B3z", + }, + { + addr: "writefreely@hispanilandia.net", + admin: "adbenitez", + description: "WriteFreely/DeltaChat bridge", + lang: "en", + url: "OPENPGP4FPR:B6F03DA7D8DF8EB6EE7E0D030A8E0B513E40D443#a=writefreely%40hispanilandia.net&n=WriteFreelyBot&i=r45fDGvqhcK&s=ZpEkv_FWyRl", + }, + { + addr: "web2img@testrun.org", + admin: "adbenitez", + description: + "Web to Image converter, take screenshots of web sites", + lang: "en", + url: "OPENPGP4FPR:B854D991B27307F8393A934CEE9BFD63D19250D3#a=web2img%40testrun.org&n=Web%20to%20Image&i=le_x0ejIaW-&s=EESq-4vLPM3", + }, + { + addr: "web2pdf@hispanilandia.net", + admin: "adbenitez", + description: "Web to PDF converter", + lang: "en", + url: "OPENPGP4FPR:90F3B4441063F3C770FCD8FEE218583044B7032D#a=web2pdf%40hispanilandia.net&n=web2pdf&i=iX-CDo5AitT&s=NorJEYpieER", + }, + { + addr: "xkcd@hispanilandia.net", + admin: "adbenitez", + description: "A bot to fetch comics from https://xkcd.com", + lang: "en", + url: "OPENPGP4FPR:8CFCEA1E7CB8E914457D98E47AAD060AD1EBF992#a=xkcd%40hispanilandia.net&n=xkcd%20bot&i=pYj-Ex5wh-m&s=ktkqonTzmkK", + }, + ], + langs: { en: "English", es: "Español", multi: "Multi-language" }, + }, + lastUpdated: "2023-08-28T05:40:41.026815296+02:00", + }, + }, + }, + "", +); +window.setTimeout(()=>{ + window.webxdc.sendUpdate( + { + payload:{ + error: {code: -543, message: "Fatal error. Something went pretty wrong, we are so sorry! :("}, + id: "sync", + } + }); +}, 2000); diff --git a/src/main.go b/src/main.go index 0b97ce9..dab7dda 100644 --- a/src/main.go +++ b/src/main.go @@ -19,6 +19,7 @@ var cli = botcli.New("public-bots") var cfg *Config type Config struct { + LastChecked time.Time LastUpdated time.Time Data []byte Path string `json:"-"` @@ -45,16 +46,20 @@ func (self *Config) GetMetadata() *Metadata { return &Metadata{LastUpdated: self.LastUpdated, Data: self.Data} } -func (self *Config) Save(data []byte) error { +func (self *Config) Save(data []byte) (bool, error) { self.mutex.Lock() defer self.mutex.Unlock() - self.Data = data - self.LastUpdated = time.Now() + self.LastChecked = time.Now() + changed := !bytes.Equal(data, cfg.Data) + if changed { + self.Data = data + self.LastUpdated = self.LastChecked + } output, err := json.Marshal(self) if err != nil { - return err + return false, err } - return os.WriteFile(self.Path, output, 0666) + return changed, os.WriteFile(self.Path, output, 0666) } func onBotInit(cli *botcli.BotCli, bot *deltachat.Bot, cmd *cobra.Command, args []string) { @@ -75,6 +80,11 @@ func updateMetadataLoop() { url := "https://github.com/deltachat-bot/public-bots/raw/main/data.json" logger := cli.Logger.With("origin", "metadata-loop") for { + toSleep := 3 * time.Hour - time.Since(cfg.LastChecked) + if toSleep>0 { + logger.Debugf("Sleeping for %v", toSleep) + time.Sleep(toSleep) + } changed, err := getMetadata(url) if err != nil { logger.Error(err) @@ -83,7 +93,6 @@ func updateMetadataLoop() { } else { logger.Debug("Metadata have not changed") } - time.Sleep(3 * time.Hour) } } @@ -98,13 +107,7 @@ func getMetadata(url string) (bool, error) { if err != nil { return false, err } - if bytes.Equal(body, cfg.GetMetadata().Data) { - return false, nil - } - if err := cfg.Save(body); err != nil { - return false, err - } - return true, nil + return cfg.Save(body) } func main() {