diff --git a/.gitignore b/.gitignore index f7f1c12..421885a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Generated HTML output/*.html +# Downloaded QR codes output/qr-codes # Server-side cache diff --git a/.phpenv b/.phpenv index 5ff259d..e9c3b31 100644 --- a/.phpenv +++ b/.phpenv @@ -1,11 +1,12 @@ /dev/null 2>/dev/null & disown -# Update HTML on file change. Doesn't check for new files. +# Update HTML on file change. watchdog: - find . | grep -v ".git" | entr -n -s "$(MAKE) html" + set -o pipefail; \ + while :; do find . | grep -v ".git" | entr -nds "$(MAKE) html" && exit; done # Remove artefacts clean: diff --git a/README.md b/README.md index 4dd696d..2c2dae9 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,14 @@ This script crawls known sources of published Session Communities, queries their servers for available information and displays this information as a static HTML page. -The results of this can be viewed on https://sessioncommunities.online/. - +The results of this can be viewed on . ## What is Session? Session is a private messaging app that protects your meta-data, encrypts your communications, and makes sure your messaging activities leave no digital trail behind. -https://getsession.org/ - + ## Details @@ -59,20 +57,28 @@ The details can be seen in [`curl_get_contents()`](php/utils/utils.php). ### Official repositories -- GitHub: -- Lokinet Gitea: +- Codeberg: +- Lokinet Gitea (mirror): +- GitHub (former official repository): +- Lokinet Gitea (former mirror): If your favourite Session community is missing a language flag, you can issue a pull request here: - +Alternatively, support this issue on Oxen Github to make language tags a native feature: + +- + ### Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). ## Contact -If you want to contact me, you can add me on Session via my -[ONS](https://docs.oxen.io/using-the-oxen-blockchain/using-oxen-name-system): -`someguy`. +To report issues, visit the Web Development Community on [caliban.org](http://sog.caliban.org/r/webdev). + +Contact gravel on Session as `gravel` (Registered ID) or [read more](https://codeberg.org/gravel/gravel). + +Contact SomeGuy on Session as `someguy` (Registered ID). diff --git a/output/css/instructions.css b/output/css/instructions.css index e31f6b2..c210325 100644 --- a/output/css/instructions.css +++ b/output/css/instructions.css @@ -1,7 +1,21 @@ +body { + background-color: black; + color: white; +} + header { text-align: center; } +a, .anchorstyle { + color: hsl(210, 100%, 60%); + text-decoration: underline; +} + +#language-selection-title, label[for^=language-selection] { + font-size: 1.2em; +} + #instructions { padding: 1em; } @@ -14,7 +28,7 @@ header { font-size: 1.25em; line-height: 1.5; padding: 2em; - background-color: silver; + background-color: hsl(0, 0%, 9%); border: 2px solid black; border-radius: 1em; } diff --git a/output/styles2.css b/output/index.css similarity index 75% rename from output/styles2.css rename to output/index.css index 0924940..49dd909 100644 --- a/output/styles2.css +++ b/output/index.css @@ -1,5 +1,4 @@ :root { - --alternate-row-color: #e8e8e8; --body-margin: 8px; /* Default value in browsers */ --max-font-size-unitless: 18; @@ -36,6 +35,14 @@ --dynamic-columns-width: var(--expanded-dynamic-columns-width); } +:root { + --cell-padding-h: 0.5em; + --cell-padding-v: 0.5em; + --cell-padding: var(--cell-padding-h) var(--cell-padding-v); + --cell-padding-small: + calc( var(--cell-padding-h) / 2 ) calc( var(--cell-padding-v) / 2 ); +} + html { font-size: clamp(10px, 2vw, var(--max-font-size-unitless) * 1px); } @@ -44,17 +51,51 @@ body { margin: 0; } -html.js .noscript, .hidden { +#toggle-theme-switch { display: none; } -.clickable { - cursor: pointer; +#theming-root { + width: 100%; + height: 100%; + margin: 0; + background-color: var(--secondary-color); + color: var(--primary-color); +} + +#toggle-theme-switch:not(:checked) ~ #theming-root { + --primary-color: hsl(0, 0%, 100%); + --secondary-color: hsl(0, 0%, 0%); + --secondary-color-heading: hsl(0, 0%, 17%); + --secondary-color-shaded: hsl(0, 0%, 9%); + --anchor-color: hsl(210, 100%, 60%); + --color-http: hsl(0, 0%, 17%); + --color-https: hsl(195, 53%, 21%); +} + + +#toggle-theme-switch:checked ~ #theming-root { + --primary-color: hsl(0, 0%, 0%); + --secondary-color: hsl(0, 0%, 100%); + --secondary-color-heading: hsl(0, 0%, 83%); + --secondary-color-shaded: hsl(0, 0%, 91%); + --anchor-color: hsl(210, 100%, 40%); + --color-http: hsl(0, 0%, 83%); + --color-https: hsl(195, 53%, 79%) } -.anchorstyle { - color: rgb(0, 102, 204); +a, .anchorstyle { + color: var(--anchor-color); text-decoration: underline; + cursor: pointer; +} + +*, *::after, *::before { + transition: color 3s, background-color 2s; +} + +html.js .noscript, .hidden { + display: none; } html:not(.js) .js-only { @@ -84,6 +125,10 @@ html:not(.js) .js-only { background-color: pink; } +gap { + flex-grow: 1000; +} + header { display: flex; direction: row; @@ -93,6 +138,11 @@ header { padding-inline: var(--body-margin); } +#header-start, #header-end { + display: flex; + gap: 0.5em; +} + #headline { text-align: center; flex-grow: 1; @@ -102,12 +152,6 @@ header { #tbl_communities { margin: 0 auto; - - --cell-padding-h: 0.5em; - --cell-padding-v: 0.5em; - --cell-padding: var(--cell-padding-h) var(--cell-padding-v); - --cell-padding-small: - calc( var(--cell-padding-h) / 2 ) calc( var(--cell-padding-v) / 2 ); } /* Cells in general */ @@ -156,16 +200,21 @@ header { } #tbl_communities th { - background-color: lightgray; + background-color: var(--secondary-color-heading); +} + +#tbl_communities tr { + background-color: var(--row-color); } #tbl_communities tr:nth-child(even) { - --row-color: white; + background-color: var(--secondary-color); + --row-color: var(--secondary-color); } #tbl_communities tr:nth-child(odd) { - --row-color: var(--alternate-row-color); - background-color: var(--alternate-row-color); + background-color: var(--secondary-color-shaded); + --row-color: var(--secondary-color-shaded); } /* Particular columns */ @@ -251,9 +300,13 @@ header { text-shadow: 0 0 0.5em #0003; } -a[href^="http:"] .protocol-indicator { background-color:lightgray } +a[href^="http:"] .protocol-indicator { + background-color: var(--color-http); +} -a[href^="https:"] .protocol-indicator { background-color:lightblue } +a[href^="https:"] .protocol-indicator { + background-color: var(--color-https); +} a[href^="http:"] .protocol-indicator::after { content: "HTTP"; @@ -316,7 +369,10 @@ a[href^="https:"] .protocol-indicator::after { .copy_button { font-size: 1.1em; + background-color: var(--secondary-color); + color: var(--primary-color); padding: var(--cell-padding); + border-radius: 10%; } /* --- Footer --- */ @@ -354,47 +410,70 @@ label[for=toggle-show-room-ids]::after { } /* --- QR code modals --- */ -.qr-code { - display: block; - margin-left: auto; - margin-right: auto; - width: 50%; +#details-modal { + padding: 0; + width: 80vw; + max-height: 80vh; + color: var(--primary-color); + background-color: var(--secondary-color); } -.qr-code-icon { - cursor: pointer; + +#details-modal-contents { + display: flex; + position: relative; + flex-direction: row; + padding: 3em; } -.qr-code-modal { - display: none; /* Hidden by default */ - position: fixed; /* Stay in place */ - z-index: 1; /* Sit on top */ - left: 0; - top: 0; - width: 100%; /* Full width */ - height: 100%; /* Full height */ - padding-top: 100px; /* Location of the box */ - background-color: rgb(0,0,0); /* Fallback color */ - background-color: rgba(0,0,0,0.4); /* Black w/ opacity */ - /*overflow: auto;*/ /* Enable scroll if needed */ - -} -.qr-code-modal-content { - background-color: #ffffff; - border: 1px solid #000000; - width: 80%; - margin: auto; - padding: 20px; -} -.qr-code-modal-close { - float: right; - font-size: 35px; - font-weight: bold; - color: #aaaaaa; -} -.qr-code-modal-close:hover, -.qr-code-modal-close:focus { + +#details-modal-close { + position: absolute; cursor: pointer; - text-decoration: none; - color: #000000; + top: 0rem; + right: 0rem; + font-size: 3.5rem; + width: 5rem; + height: 5.5rem; + text-align: center; +} + +#details-modal-start { + display: flex; + flex-direction: column; + margin-right: 1em; +} + +#details-modal-end { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +#details-modal-start #details-modal-description { + max-height: 50vh; + overflow: auto; +} + +#details-modal-start #details-modal-description-inner:empty::after { + content: "No description"; + font-style: italic; +} + +#details-modal-end #details-modal-qr-code { + width: 20em; + height: 20em; + margin-bottom: 1em; +} + +#details-modal-end #details-modal-qr-code-label { + text-align: center; +} + +#details-modal :is(#details-modal-copy-button, #details-modal-copy-staff-id) { + color: var(--primary-color); + background-color: var(--secondary-color); + border-radius: 10%; + padding: var(--cell-padding-small); } /* */ @@ -423,7 +502,6 @@ label[for=toggle-show-room-ids]::after { } /* Responsive properties */ - .td_name .room-label:nth-of-type(5) ~ .room-label { display: none; } @@ -470,10 +548,6 @@ label[for=toggle-show-room-ids]::after { :root { --dynamic-columns-width: var(--collapsed-dynamic-columns-width); } - - .td_name .room-label:nth-of-type(1) ~ .room-label { - display: none; - } } @media (max-width: 500px) { @@ -481,6 +555,10 @@ label[for=toggle-show-room-ids]::after { /* ! For when descriptions don't wrap and 100vw doesn't work. */ --dynamic-columns-width: 15rem; } + + #details-modal-contents { + flex-direction: column; + } } /* Animations to fade the snackbar in and out */ diff --git a/output/js/constants.js b/output/js/constants.js index d31050f..81a7681 100644 --- a/output/js/constants.js +++ b/output/js/constants.js @@ -7,14 +7,36 @@ export const dom = { tbl_communities: () => document.getElementById("tbl_communities"), tbl_communities_content_rows: () => Array.from(dom.tbl_communities()?.rows)?.filter(row => !row.querySelector('th')), + community_row: (communityID) => document.getElementById(communityID), + row_info: (row) => { + /** @type {string[]} */ + return { + language_flag: row.querySelector('.td_language').textContent.trim(), + name: row.querySelector('.td_name').textContent.trim(), + description: row.querySelector('.td_description').textContent.trim(), + users: parseFloat(row.querySelector('.td_users').textContent.trim()), + preview_link: row.querySelector('.td_preview a[href]').getAttribute('href'), + join_link: row.querySelector('.td_join_url a[href]').getAttribute('href'), + hostname: row.getAttribute('data-hostname'), + public_key: row.getAttribute('data-pubkey'), + staff: row.getAttribute('data-staff') + }; + }, meta_timestamp: () => document.querySelector('meta[name=timestamp]'), last_checked: () => document.getElementById("last_checked_value"), - qr_modal: (communityID) => document.getElementById(`modal_${communityID}`), + /** @return {HTMLDialogElement | null} */ + details_modal: () => document.getElementById('details-modal'), + details_modal_qr_code: () => document.getElementById('details-modal-qr-code'), join_urls: () => document.getElementsByClassName("join_url_container"), servers_hidden: () => document.getElementById("servers_hidden"), - snackbar: () => document.getElementById("copy-snackbar") + snackbar: () => document.getElementById("copy-snackbar"), + qr_code_buttons: () => document.querySelectorAll('.qr-code-button'), } +export const JOIN_URL_PASTE = "Copied URL to clipboard. Paste into Session app to join"; + +export const communityQRCodeURL = (communityID) => `qr-codes/${communityID}.png` + export const COLUMN = { IDENTIFIER: 0, LANGUAGE: 1, NAME: 2, DESCRIPTION: 3, USERS: 4, PREVIEW: 5, @@ -33,11 +55,20 @@ export const COMPARISON = { }; export const ATTRIBUTES = { + ROW: { + IDENTIFIER: 'data-identifier', + PUBLIC_KEY: 'data-pubkey', + HOSTNAME: 'data-hostname', + STAFF_DATA: 'data-staff' + }, SORTING: { ACTIVE: 'data-sort', ASCENDING: 'data-sort-asc', COLUMN: 'data-sorted-by', // COLUMN_LITERAL: 'sorted-by' + }, + HYDRATION: { + CONTENT: 'data-hydrate-with' } }; diff --git a/output/main.js b/output/main.js index 580bb4f..f8412f1 100644 --- a/output/main.js +++ b/output/main.js @@ -1,6 +1,6 @@ // Hello reader! // This project can be found at: -// https://lokilocker.com/someguy/sessioncommunities.online +// https://codeberg.com/gravel/sessioncommunities.online /** * This JavaScript file uses the JSDoc commenting style. @@ -16,7 +16,8 @@ // Import magic numbers and data import { dom, COLUMN, COLUMN_LITERAL, COMPARISON, ATTRIBUTES, - columnAscendingByDefault, columnIsSortable, COLUMN_TRANSFORMATION, element + columnAscendingByDefault, columnIsSortable, COLUMN_TRANSFORMATION, + element, JOIN_URL_PASTE, communityQRCodeURL } from './js/constants.js'; // Hidden communities for transparency. @@ -45,6 +46,7 @@ const transformJoinURL = (join_link) => { return element.button({ textContent: "Copy", className: "copy_button", + title: "Click here to copy the join URL", onclick: () => copyToClipboard(join_link) }); } @@ -70,31 +72,122 @@ function onLoad() { markSortableColumns(); addQRModalHandlers(); addServerIconInteractions(); + preloadQRCodes(); } function displayQRModal(communityID) { - dom.qr_modal(communityID).style.display = "block"; + const modal = dom.details_modal(); + + if (!modal) { + throw new DOMException("Modal element not found."); + } + + const row = dom.community_row(communityID); + + if (!row) { + throw new DOMException("Community row not found."); + } + + const rowInfo = dom.row_info(row); + + for (const element of modal.querySelectorAll(`[${ATTRIBUTES.HYDRATION.CONTENT}]`)) { + const attributes = element.getAttribute(ATTRIBUTES.HYDRATION.CONTENT); + if (!attributes) continue; + for (const attribute of attributes.split(';')) { + const [property, targetProperty] = attribute.includes(':') + ? attribute.split(":") + : [attribute, 'textContent']; + if (!Object.getOwnPropertyNames(rowInfo).includes(property)) { + console.error(`Unknown rowInfo property: ${property}`); + continue; + } + if (targetProperty === 'textContent') { + element.textContent = rowInfo[property]; + } else { + element.setAttribute(targetProperty, rowInfo[property]); + } + } + } + + dom.details_modal_qr_code().src = communityQRCodeURL(communityID); + + modal.showModal(); } function hideQRModal(communityID) { - dom.qr_modal(communityID).style.display = "none"; + dom.details_modal().close(); } function addQRModalHandlers() { const rows = dom.tbl_communities_content_rows(); if (!rows) throw new Error("Rows not found"); for (const row of rows) { - const communityID = row.getAttribute('data-identifier'); + const communityID = row.getAttribute(ATTRIBUTES.ROW.IDENTIFIER); row.querySelector('.td_qr_code').addEventListener( 'click', () => displayQRModal(communityID) ); - const closeButton = - dom.qr_modal(communityID).querySelector('.qr-code-modal-close'); - closeButton.addEventListener( + row.querySelector('.td_name').addEventListener( 'click', - () => hideQRModal(communityID) + (e) => { + e.preventDefault(); + displayQRModal(communityID); + } ); + + } + const closeButton = + dom.details_modal().querySelector('#details-modal-close'); + closeButton.addEventListener( + 'click', + () => hideQRModal() + ); + dom.details_modal().addEventListener('click', function (e) { + if (this == e.target) { + this.close(); + } + }); + + document.querySelector('#details-modal-copy-button').addEventListener( + 'click', + function () { + copyToClipboard(this.getAttribute('data-href')); + } + ) + + + document.querySelector('#details-modal-copy-staff-id')?.addEventListener( + 'click', + function () { + /** + * @type {string[]} + */ + const staff = JSON.parse(this.getAttribute(ATTRIBUTES.ROW.STAFF_DATA)); + if (staff.length == 0) { + alert("No public moderators available for this Community."); + return; + } + const staffId = staff[~~(staff.length * Math.random())]; + copyToClipboard(`@${staffId}`, 'Copied staff ID to clipboard.'); + } + ) + + for (const anchor of dom.qr_code_buttons()) { + // Disable QR code links + anchor.setAttribute("href", "#"); + anchor.removeAttribute("target"); + anchor.addEventListener('click', (e) => { e.preventDefault(); return false }); + } + +} + +function preloadQRCodes() { + const rows = dom.tbl_communities_content_rows(); + const identifiers = rows.map( + rowElement => rowElement.getAttribute(ATTRIBUTES.ROW.IDENTIFIER) + ); + for (const identifier of identifiers) { + (new Image()).src = communityQRCodeURL(identifier); } } @@ -133,13 +226,20 @@ function hideElementByID(id) { /** * Copies text to clipboard and shows an informative toast. * @param {string} text - Text to copy to clipboard. + * @param {string} [toastText] - Text shown by toast. */ -function copyToClipboard(text) { +function copyToClipboard(text, toastText = JOIN_URL_PASTE) { navigator.clipboard.writeText(text); // Find snackbar element const snackbar = dom.snackbar(); + if (!snackbar) { + throw new DOMException("Could not find snackbar"); + } + + snackbar.textContent = toastText; + snackbar.classList.add('show') // After 3 seconds, hide the snackbar. @@ -163,8 +263,8 @@ function setLastChecked(last_checked) { function addServerIconInteractions() { const rows = dom.tbl_communities_content_rows(); for (const row of rows) { - const hostname = row.getAttribute('data-hostname'); - const publicKey = row.getAttribute('data-pubkey'); + const hostname = row.getAttribute(ATTRIBUTES.ROW.HOSTNAME); + const publicKey = row.getAttribute(ATTRIBUTES.ROW.PUBLIC_KEY); const serverIcon = row.querySelector('.td_server_icon'); serverIcon.addEventListener('click', () => { alert(`Host: ${hostname}\n\nPublic key:\n${publicKey}`); @@ -320,4 +420,4 @@ function sortTable(column) { // `html.js` selector for styling purposes document.documentElement.classList.add("js"); -document.addEventListener('DOMContentLoaded', () => onLoad()); +document.addEventListener('DOMContentLoaded', () => onLoad()); \ No newline at end of file diff --git a/output/qrcode-solid.svg b/output/qrcode-solid.svg index 556a04c..ec99749 100644 --- a/output/qrcode-solid.svg +++ b/output/qrcode-solid.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/php/utils/room-invites.php b/php/utils/room-invites.php new file mode 100644 index 0000000..8ec5bb5 --- /dev/null +++ b/php/utils/room-invites.php @@ -0,0 +1,39 @@ +get_room_identifier(); + $png_cached = room_qr_code_path($room_id); + if (file_exists($png_cached)) { + return room_qr_code_path_relative($room_id); + } + log_debug("Fetching QR code for $room_id."); + $png = file_get_contents($room->get_invite_url()); + file_put_contents($png_cached, $png); + return room_qr_code_path_relative($room_id); + } + + file_exists($QR_CODES) or mkdir($QR_CODES, 0700); +?> \ No newline at end of file diff --git a/sites/+components/qr_modals.php b/sites/+components/qr_modals.php index 8d66b06..bd7c67a 100644 --- a/sites/+components/qr_modals.php +++ b/sites/+components/qr_modals.php @@ -1,46 +1,74 @@ - /** - * Fetch QR codes from SOGS server and encode them as base64 - * @param CommunityRoom $room - */ - function base64_qr_code($room, $size = "512x512") { - $room_id = $room->get_room_identifier(); - $png_cached = room_qr_code_cached($room_id); - if (file_exists($png_cached)) { - return base64_encode(file_get_contents($png_cached)); - } - log_debug("Fetching QR code for $room_id."); - $png = file_get_contents($room->get_invite_url()); - file_put_contents($png_cached, $png); - return base64_encode($png); - } + +
+
+ × +
+
+

+ +

+

+ Description: + +

+ +
+

+ Language: +

+

+ Users: +

+

+ Server: + +

- file_exists($QR_CODES) or mkdir($QR_CODES, 0700); -?> +

+ -

- -
+
+ diff --git a/sites/+components/tbl_communities.php b/sites/+components/tbl_communities.php index cfbacb4..f4984f7 100644 --- a/sites/+components/tbl_communities.php +++ b/sites/+components/tbl_communities.php @@ -1,6 +1,7 @@ get_join_url()); $pubkey = html_sanitize($pubkey); $hostname = html_sanitize($hostname); + + $staff_json = json_encode(array_map('html_sanitize', $room->get_staff())); ?> - @@ -117,12 +121,18 @@ - Pictogram of a QR code + Pictogram of a QR code +
- + - + Self-updating list of active Session communities -
-
-
-
+ +
+
+
+
+ + Instructions +
+

Session Communities

- + - + - + -
+
-
-

- unique Session Communities - on servers have been found. - (None hidden as JS is off) -

-

- Last checked - (UTC) - . -

-

- This site is not affiliated with - Oxen Privacy Tech Foundation. -
- Communities shown are fetched automatically from +

+

+ unique Session Communities + on servers have been found. + (None hidden as JS is off) +

+

+ Last checked + (UTC) + . +

+

+ This site is not affiliated with + Oxen Privacy Tech Foundation. +
+ Communities shown are fetched automatically from + various sources. +
+ + We make an attempt to hide communities containing + objectionable or illegal content, but + you should still proceed with caution. + + + Proceed with caution when joining unofficial communities. + As JavaScript is disabled, no communities are filtered from the list. + +

+

+ This site works fine without JavaScript. + However, some interactive features are + only available with JS enabled. +

+

+ +

+
-
- Copied URL to clipboard. Paste into Session app to join + title="Closed groups curated by community moderators" + >Closed Groups + session.directory + Host Your Own Community + Session Terms Of Service + + +
+
diff --git a/sites/instructions.php b/sites/instructions.php index fb4275d..53acb02 100644 --- a/sites/instructions.php +++ b/sites/instructions.php @@ -41,9 +41,11 @@ Go back to Community list

- Choose your language: - $file): ?> + + Choose your language: +
+ $file): ?>