├── LICENSE ├── README.md ├── config.json ├── index.html ├── mastowall-favicon.png ├── script.js └── styles.css /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ralf Stockmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mastowall 1.1 2 | 3 | Mastowall is a social wall application that displays posts from the [Mastodon](https://joinmastodon.org/) social network based on specified hashtags. It was written entirely by [ChatGPT4](https://openai.com/product/gpt-4), guided only by text prompts. 4 | 5 | image 6 | 7 | 8 | Try it live: [Mastowall for the WWDC conference](https://rstockm.github.io/mastowall/?hashtags=wwdc,wwdc23,apple&server=https://mastodon.social)) 9 | 10 | Use your own hashtags and server: 11 | 12 | image 13 | 14 | JSON config file: 15 | 16 | image 17 | 18 | 19 | 20 | ## Features 21 | 22 | - **Display Posts:** The app fetches and displays posts from Mastodon based on the hashtags provided in the URL. If no hashtags are provided, it presents a form to enter up to three hashtags. 23 | 24 | - **Custom Mastodon Server:** Allows users to specify a Mastodon server URL from which to fetch posts. 25 | 26 | - **Real-Time Updates:** Mastowall updates the posts every 10 seconds, ensuring that the content displayed is always current. 27 | 28 | - **Relative Timestamps:** The timestamps of the posts are displayed relative to the current time, and are updated every minute to reflect the passing time. 29 | 30 | - **Masonry Grid Layout:** The posts are displayed in a masonry grid layout for a visually pleasing experience. 31 | 32 | - **Responsive Design:** The layout adjusts according to the screen size for better readability on different devices. 33 | 34 | - **Navbar Hashtag Navigation:** Clicking on the hashtags in the navbar takes you to the form screen, allowing you to change the existing hashtags easily. 35 | 36 | - **Navbar Color Customization:** The color of the navigation bar can now be customized via the `config.json` file. 37 | 38 | - **Including Replies:** By default, replies are excluded from the wall. However, this behavior can be changed by setting includeReplies to true in the `config.json` file. 39 | 40 | ## Technology Stack 41 | 42 | Mastowall is built using the following technologies: 43 | 44 | - **HTML, CSS, and JavaScript**: For structuring, styling, and functionality. 45 | 46 | - **[jQuery](https://jquery.com/)**: A fast, small, and feature-rich JavaScript library. 47 | 48 | - **[Masonry](https://masonry.desandro.com/)**: A JavaScript grid layout library. 49 | 50 | - **[Bootstrap](https://getbootstrap.com/)**: A popular CSS framework for responsive, mobile-first front-end web development. 51 | 52 | - **[DOMPurify](https://github.com/cure53/DOMPurify)**: Library for sanitizing HTML input, which should prevent the vast majority of malicious input from being rendered 53 | 54 | ## Usage 55 | 56 | 1. Load the application in a web browser. If no hashtags are specified in the URL, you will be presented with a form to enter up to three hashtags and a server URL. 57 | 58 | 2. After entering your hashtags and clicking 'Reload', the application will fetch and display posts from the specified Mastodon server that include those hashtags. 59 | 60 | 3. The displayed posts will update every 10 seconds. The relative timestamps will also update every minute. 61 | 62 | 4. To change the hashtags, click on them in the navbar to go back to the form screen. 63 | 64 | ## Sharing via URL 65 | 66 | Mastowall supports URL parameters to easily share specific hashtag configurations and the Mastodon server. Simply append the desired hashtags and the server URL to the URL following this format: `?hashtags=hashtag1,hashtag2,hashtag3&server=serverUrl` 67 | 68 | Enjoy using Mastowall! 69 | 70 | ## AI-Guided Development: A Proof of Concept 71 | 72 | Mastowall may serve as an example of how artificial intelligence can aid and accelerate the software development process. The development of this version of the app was guided by OpenAI's GPT-4, a large language model. 73 | 74 | In this process, the human developer posed problems, asked questions, and described the desired features and functionalities of the application. GPT-4 then provided solutions, answered queries, generated code snippets, and suggested optimal ways to implement these features. 75 | 76 | Every single line of code was written by ChatGPT4. 77 | Including this README. 78 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "navbarBrandText": "Mastowall 1.2 - written by ChatGPT4 - Prompting: Ralf Stockmann (rstockm)", 3 | "defaultServerUrl": "https://mastodon.social", 4 | "navbarColor": "#333355", 5 | "includeReplies": true 6 | } 7 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mastowall 1.2 7 | 8 | 9 | 10 | 11 | 12 | 13 | 17 | 18 |
19 |
20 |
21 |
22 |

Welcome to the Mastowall

23 |

Please enter up to three hashtags to load posts:

24 |
25 |
26 | 27 | 28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 | 40 |
41 | 42 | 43 |
44 |
45 |
46 |
47 |
48 | 49 |
50 |
51 |
52 | 53 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /mastowall-favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstockm/mastowall/5f1237b0aeab55d467e2dcc73b4371f40486cf36/mastowall-favicon.png -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | // The existingPosts array is used to track already displayed posts 2 | let existingPosts = []; 3 | 4 | // getUrlParameter helps to fetch URL parameters 5 | function getUrlParameter(name) { 6 | name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]'); 7 | var regex = new RegExp('[\\?&]' + name + '=([^&#]*)'); 8 | var results = regex.exec(location.search); 9 | return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); 10 | } 11 | 12 | // secondsAgo calculates how many seconds have passed since the provided date 13 | const secondsAgo = date => Math.floor((new Date() - date) / 1000); 14 | 15 | // timeAgo formats the time elapsed in a human readable format 16 | const timeAgo = function(seconds) { 17 | const intervals = [ 18 | { limit: 31536000, text: 'years' }, 19 | { limit: 2592000, text: 'months' }, 20 | { limit: 86400, text: 'days' }, 21 | { limit: 3600, text: 'hours' }, 22 | { limit: 60, text: 'minutes' } 23 | ]; 24 | 25 | for (let interval of intervals) { 26 | if (seconds >= interval.limit) { 27 | return Math.floor(seconds / interval.limit) + ` ${interval.text} ago`; 28 | } 29 | } 30 | return Math.floor(seconds) + " seconds ago"; 31 | }; 32 | 33 | let includeReplies; 34 | 35 | // fetchConfig fetches the configuration from the config.json file 36 | const fetchConfig = async function() { 37 | try { 38 | const config = await $.getJSON('config.json'); 39 | $('#navbar-brand').text(config.navbarBrandText); 40 | $('.navbar').css('background-color', config.navbarColor); 41 | includeReplies = config.includeReplies; 42 | return config.defaultServerUrl; 43 | } catch (error) { 44 | console.error("Error loading config.json:", error); 45 | } 46 | } 47 | 48 | // fetchPosts fetches posts from the server using the given hashtag 49 | const fetchPosts = async function(serverUrl, hashtag) { 50 | try { 51 | const posts = await $.get(`${serverUrl}/api/v1/timelines/tag/${hashtag}?limit=40`); 52 | return posts; 53 | } catch (error) { 54 | console.error(`Error loading posts for hashtag #${hashtag}:`, error); 55 | } 56 | }; 57 | 58 | // updateTimesOnPage updates the time information displayed for each post 59 | const updateTimesOnPage = function() { 60 | $('.card-text a').each(function() { 61 | const date = new Date($(this).attr('data-time')); 62 | const newTimeAgo = timeAgo(secondsAgo(date)); 63 | $(this).text(newTimeAgo); 64 | }); 65 | }; 66 | 67 | // displayPost creates and displays a post 68 | const displayPost = function(post) { 69 | if (existingPosts.includes(post.id) || (!includeReplies && post.in_reply_to_id !== null)) return; 70 | 71 | existingPosts.push(post.id); 72 | 73 | let cardHTML = ` 74 |
75 |
76 |
77 | 78 |

${DOMPurify.sanitize(post.account.display_name)}

79 |
80 | ${post.media_attachments[0] ? 81 | (post.media_attachments[0].url.endsWith('.mp4') ? 82 | `` : 83 | ``) : 84 | ''} 85 |

${DOMPurify.sanitize(post.content)}

86 | ${post.spoiler_text ? `

${DOMPurify.sanitize(post.spoiler_text)}

` : ''} 87 |

${timeAgo(secondsAgo(new Date(post.created_at)))}

88 |
89 |
90 | `; 91 | 92 | let $card = $(cardHTML); 93 | $('#wall').prepend($card); 94 | $('.masonry-grid').masonry('prepended', $card); 95 | }; 96 | 97 | // Set the document title based on the first hashtag in the URL 98 | document.addEventListener('DOMContentLoaded', function() { 99 | const hashtags = getUrlParameter('hashtags'); 100 | if (hashtags) { 101 | const firstHashtag = hashtags.split(',')[0]; 102 | document.title = `#${firstHashtag} - Mastowall 1.2`; 103 | } 104 | }); 105 | 106 | // updateWall displays all posts 107 | const updateWall = function(posts) { 108 | if (!posts || posts.length === 0) return; 109 | 110 | posts.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); 111 | posts.forEach(post => displayPost(post)); 112 | }; 113 | 114 | // hashtagsString returns a single string based on the given array of hashtags 115 | const hashtagsString = function(hashtagsArray) { 116 | return `${hashtagsArray.map(hashtag => `#${hashtag}`).join(' ')}`; 117 | } 118 | 119 | // updateHashtagsOnPage updates the displayed hashtags 120 | const updateHashtagsOnPage = function(hashtagsArray) { 121 | const settingsIcon = ' ⚙️'; 122 | const hashtagsText = hashtagsArray.length > 0 ? hashtagsString(hashtagsArray) + settingsIcon : 'No hashtags set' + settingsIcon; 123 | $('#hashtag-display').html(hashtagsText); 124 | }; 125 | 126 | // updateHashtagsInTitle updates the document title by appending the given array of hashtags 127 | const updateHashtagsInTitle = function(hashtagsArray) { 128 | const baseTitle = document.title; 129 | document.title = `${baseTitle} | ${hashtagsString(hashtagsArray)}`; 130 | } 131 | 132 | // handleHashtagDisplayClick handles the event when the hashtag display is clicked 133 | const handleHashtagDisplayClick = function(serverUrl) { 134 | $('#app-content').addClass('d-none'); 135 | $('#zero-state').removeClass('d-none'); 136 | 137 | const currentHashtags = getUrlParameter('hashtags').split(','); 138 | 139 | for (let i = 0; i < currentHashtags.length; i++) { 140 | $(`#hashtag${i+1}`).val(currentHashtags[i]); 141 | } 142 | 143 | $('#serverUrl').val(serverUrl); 144 | }; 145 | 146 | // handleHashtagFormSubmit handles the submission of the hashtag form 147 | const handleHashtagFormSubmit = function(e, hashtagsArray) { 148 | e.preventDefault(); 149 | 150 | let hashtags = [ 151 | $('#hashtag1').val(), 152 | $('#hashtag2').val(), 153 | $('#hashtag3').val() 154 | ]; 155 | 156 | hashtags = hashtags.filter(function(hashtag) { 157 | return hashtag !== '' && /^[\w]+$/.test(hashtag); 158 | }); 159 | 160 | let serverUrl = $('#serverUrl').val(); 161 | 162 | if (!/^https:\/\/[\w.\-]+\/?$/.test(serverUrl)) { 163 | alert('Invalid server URL.'); 164 | return; 165 | } 166 | 167 | const newUrl = window.location.origin + window.location.pathname + `?hashtags=${hashtags.join(',')}&server=${serverUrl}`; 168 | 169 | window.location.href = newUrl; 170 | }; 171 | 172 | // Initialize isFirstLoad flag 173 | let isFirstLoad = true; 174 | 175 | // On document ready, the script configures Masonry, handles events, fetches and displays posts 176 | $(document).ready(async function() { 177 | const defaultServerUrl = await fetchConfig(); 178 | $('.masonry-grid').masonry({ 179 | itemSelector: '.col-sm-3', 180 | columnWidth: '.col-sm-3', 181 | percentPosition: true 182 | }); 183 | 184 | // Initial reshuffle after 3 seconds only on first load 185 | if (isFirstLoad) { 186 | setTimeout(function() { 187 | $('.masonry-grid').masonry('layout'); 188 | isFirstLoad = false; 189 | }, 3000); 190 | } 191 | 192 | setInterval(function() { 193 | $('.masonry-grid').masonry('layout'); 194 | }, 10000); 195 | 196 | const hashtags = getUrlParameter('hashtags'); 197 | const hashtagsArray = hashtags ? hashtags.split(',') : []; 198 | const serverUrl = getUrlParameter('server') || defaultServerUrl; 199 | 200 | $('#hashtag-display').on('click', function() { 201 | handleHashtagDisplayClick(serverUrl); 202 | }); 203 | 204 | if (hashtagsArray.length > 0 && hashtagsArray[0] !== '') { 205 | const allPosts = await Promise.all(hashtagsArray.map(hashtag => fetchPosts(serverUrl, hashtag))); 206 | updateWall(allPosts.flat()); 207 | setInterval(async function() { 208 | const newPosts = await Promise.all(hashtagsArray.map(hashtag => fetchPosts(serverUrl, hashtag))); 209 | updateWall(newPosts.flat()); 210 | }, 10000); 211 | } else { 212 | $('#zero-state').removeClass('d-none'); 213 | $('#app-content').addClass('d-none'); 214 | } 215 | 216 | updateHashtagsOnPage(hashtagsArray); 217 | updateHashtagsInTitle(hashtagsArray); 218 | 219 | $('#hashtag-form').on('submit', function(e) { 220 | handleHashtagFormSubmit(e, hashtagsArray); 221 | }); 222 | 223 | updateTimesOnPage(); 224 | setInterval(updateTimesOnPage, 60000); 225 | }); 226 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* Add some custom CSS for the cards */ 2 | .card { 3 | margin-bottom: 20px; 4 | border-radius: 10px; 5 | box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.2); 6 | } 7 | 8 | /* Position the avatar and username in the top left of the card */ 9 | .card-avatar { 10 | height: 50px; 11 | width: 50px; 12 | border-radius: 50%; 13 | position: absolute; 14 | top: 10px; 15 | left: 10px; 16 | } 17 | 18 | #settings-icon { 19 | cursor: pointer; 20 | padding-left: 20px; 21 | opacity: 0.5; /* 50% transparency */ 22 | } 23 | 24 | #settings-icon:hover { 25 | opacity: 1; /* 100% opacity on hover */ 26 | } 27 | 28 | .card-username { 29 | position: absolute; 30 | top: 10px; 31 | left: 70px; 32 | } 33 | 34 | .card-text.text-right { 35 | text-align: right; 36 | margin-top: -20px !important; 37 | } 38 | 39 | /* Add padding to the card body to prevent overlay with avatar and username */ 40 | .card-body { 41 | padding-top: 40px; 42 | padding-bottom: 10px; 43 | } 44 | 45 | /* Style the media image */ 46 | .card-img-top { 47 | width: 100%; 48 | height: auto; 49 | } 50 | 51 | .card-title { 52 | font-weight: normal; 53 | } 54 | 55 | .card-text { 56 | margin-bottom: 1px !important; 57 | } 58 | 59 | .card { 60 | font-size: 0.9em; /* adjust this value to get the desired text size */ 61 | } 62 | 63 | /* Remove indent of URLs */ 64 | .invisible { 65 | font-size: 0 !important; 66 | line-height: 0 !important; 67 | } 68 | 69 | .hashtag { 70 | margin-right: 0px !important; 71 | } 72 | 73 | /* Custom navbar styles */ 74 | .navbar { 75 | height: 50px; /* reduce the height of the navbar */ 76 | background-color: rgb(227, 6, 19); 77 | margin-bottom: 10px !important; 78 | top: -10px !important; 79 | padding-top: 14px !important; 80 | } 81 | 82 | .navbar-brand { 83 | color: rgba(255, 255, 255, 0.8) !important; /* change the text color */ 84 | margin: 0 auto; /* center the brand name */ 85 | font-size: 0.9em; 86 | } 87 | 88 | .navbar-info { 89 | color: rgba(255, 255, 255, 0.8) !important; /* change the text color */ 90 | margin: 0 auto; /* center the brand name */ 91 | font-size: 1.2em; 92 | display: block !important; 93 | } 94 | 95 | .hashtag { 96 | margin-right: 10px; /* add space between the hashtags */ 97 | } 98 | 99 | .col-sm-3 { 100 | padding-left: 0px !important; 101 | padding-right: 0px !important; 102 | } 103 | 104 | /* Set the background color of the body */ 105 | body { 106 | background-color: #cccccc; 107 | margin-top: 00px !important; 108 | margin-left: 20px !important; 109 | margin-right: 20px !important; 110 | } 111 | 112 | @media (max-width: 1000px) { 113 | .col-sm-3 { 114 | flex: 0 0 50%; 115 | max-width: 50%; 116 | } 117 | .navbar-brand { 118 | display: none; 119 | } 120 | } 121 | 122 | @media (max-width: 600px) { 123 | .col-sm-3 { 124 | flex: 0 0 100%; 125 | max-width: 100%; 126 | } 127 | .navbar-brand { 128 | display: none; 129 | } 130 | } 131 | 132 | .avatar-img { 133 | width: 50px; 134 | height: 50px; 135 | } 136 | 137 | .container { 138 | max-width: 2000px !important; 139 | } 140 | 141 | .footer { 142 | background-color: rgb(200, 200, 200); 143 | color: #f2f2f2; 144 | position: fixed; 145 | left: 0; 146 | bottom: 0; 147 | width: 100%; 148 | padding-top: 2px !important; /* reduce padding-top to half */ 149 | padding-bottom: 2px !important; /* reduce padding-bottom to half */ 150 | font-size: 0.9em; 151 | } 152 | --------------------------------------------------------------------------------