tuiter 2006

init

oeiuwq.com e7d73208

+6600
+8
.gitignore
··· 1 + vic 2 + main 3 + hk2006 4 + .env.example 5 + tuiter 6 + 2006 7 + *.log 8 + *.sqlite
+201
LICENSE
··· 1 + Apache License 2 + Version 2.0, January 2004 3 + http://www.apache.org/licenses/ 4 + 5 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 + 7 + 1. Definitions. 8 + 9 + "License" shall mean the terms and conditions for use, reproduction, 10 + and distribution as defined by Sections 1 through 9 of this document. 11 + 12 + "Licensor" shall mean the copyright owner or entity authorized by 13 + the copyright owner that is granting the License. 14 + 15 + "Legal Entity" shall mean the union of the acting entity and all 16 + other entities that control, are controlled by, or are under common 17 + control with that entity. For the purposes of this definition, 18 + "control" means (i) the power, direct or indirect, to cause the 19 + direction or management of such entity, whether by contract or 20 + otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 + outstanding shares, or (iii) beneficial ownership of such entity. 22 + 23 + "You" (or "Your") shall mean an individual or Legal Entity 24 + exercising permissions granted by this License. 25 + 26 + "Source" form shall mean the preferred form for making modifications, 27 + including but not limited to software source code, documentation 28 + source, and configuration files. 29 + 30 + "Object" form shall mean any form resulting from mechanical 31 + transformation or translation of a Source form, including but 32 + not limited to compiled object code, generated documentation, 33 + and conversions to other media types. 34 + 35 + "Work" shall mean the work of authorship, whether in Source or 36 + Object form, made available under the License, as indicated by a 37 + copyright notice that is included in or attached to the work 38 + (an example is provided in the Appendix below). 39 + 40 + "Derivative Works" shall mean any work, whether in Source or Object 41 + form, that is based on (or derived from) the Work and for which the 42 + editorial revisions, annotations, elaborations, or other modifications 43 + represent, as a whole, an original work of authorship. For the purposes 44 + of this License, Derivative Works shall not include works that remain 45 + separable from, or merely link (or bind by name) to the interfaces of, 46 + the Work and Derivative Works thereof. 47 + 48 + "Contribution" shall mean any work of authorship, including 49 + the original version of the Work and any modifications or additions 50 + to that Work or Derivative Works thereof, that is intentionally 51 + submitted to Licensor for inclusion in the Work by the copyright owner 52 + or by an individual or Legal Entity authorized to submit on behalf of 53 + the copyright owner. For the purposes of this definition, "submitted" 54 + means any form of electronic, verbal, or written communication sent 55 + to the Licensor or its representatives, including but not limited to 56 + communication on electronic mailing lists, source code control systems, 57 + and issue tracking systems that are managed by, or on behalf of, the 58 + Licensor for the purpose of discussing and improving the Work, but 59 + excluding communication that is conspicuously marked or otherwise 60 + designated in writing by the copyright owner as "Not a Contribution." 61 + 62 + "Contributor" shall mean Licensor and any individual or Legal Entity 63 + on behalf of whom a Contribution has been received by Licensor and 64 + subsequently incorporated within the Work. 65 + 66 + 2. Grant of Copyright License. Subject to the terms and conditions of 67 + this License, each Contributor hereby grants to You a perpetual, 68 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 + copyright license to reproduce, prepare Derivative Works of, 70 + publicly display, publicly perform, sublicense, and distribute the 71 + Work and such Derivative Works in Source or Object form. 72 + 73 + 3. Grant of Patent License. Subject to the terms and conditions of 74 + this License, each Contributor hereby grants to You a perpetual, 75 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 + (except as stated in this section) patent license to make, have made, 77 + use, offer to sell, sell, import, and otherwise transfer the Work, 78 + where such license applies only to those patent claims licensable 79 + by such Contributor that are necessarily infringed by their 80 + Contribution(s) alone or by combination of their Contribution(s) 81 + with the Work to which such Contribution(s) was submitted. If You 82 + institute patent litigation against any entity (including a 83 + cross-claim or counterclaim in a lawsuit) alleging that the Work 84 + or a Contribution incorporated within the Work constitutes direct 85 + or contributory patent infringement, then any patent licenses 86 + granted to You under this License for that Work shall terminate 87 + as of the date such litigation is filed. 88 + 89 + 4. Redistribution. You may reproduce and distribute copies of the 90 + Work or Derivative Works thereof in any medium, with or without 91 + modifications, and in Source or Object form, provided that You 92 + meet the following conditions: 93 + 94 + (a) You must give any other recipients of the Work or 95 + Derivative Works a copy of this License; and 96 + 97 + (b) You must cause any modified files to carry prominent notices 98 + stating that You changed the files; and 99 + 100 + (c) You must retain, in the Source form of any Derivative Works 101 + that You distribute, all copyright, patent, trademark, and 102 + attribution notices from the Source form of the Work, 103 + excluding those notices that do not pertain to any part of 104 + the Derivative Works; and 105 + 106 + (d) If the Work includes a "NOTICE" text file as part of its 107 + distribution, then any Derivative Works that You distribute must 108 + include a readable copy of the attribution notices contained 109 + within such NOTICE file, excluding those notices that do not 110 + pertain to any part of the Derivative Works, in at least one 111 + of the following places: within a NOTICE text file distributed 112 + as part of the Derivative Works; within the Source form or 113 + documentation, if provided along with the Derivative Works; or, 114 + within a display generated by the Derivative Works, if and 115 + wherever such third-party notices normally appear. The contents 116 + of the NOTICE file are for informational purposes only and 117 + do not modify the License. You may add Your own attribution 118 + notices within Derivative Works that You distribute, alongside 119 + or as an addendum to the NOTICE text from the Work, provided 120 + that such additional attribution notices cannot be construed 121 + as modifying the License. 122 + 123 + You may add Your own copyright statement to Your modifications and 124 + may provide additional or different license terms and conditions 125 + for use, reproduction, or distribution of Your modifications, or 126 + for any such Derivative Works as a whole, provided Your use, 127 + reproduction, and distribution of the Work otherwise complies with 128 + the conditions stated in this License. 129 + 130 + 5. Submission of Contributions. Unless You explicitly state otherwise, 131 + any Contribution intentionally submitted for inclusion in the Work 132 + by You to the Licensor shall be under the terms and conditions of 133 + this License, without any additional terms or conditions. 134 + Notwithstanding the above, nothing herein shall supersede or modify 135 + the terms of any separate license agreement you may have executed 136 + with Licensor regarding such Contributions. 137 + 138 + 6. Trademarks. This License does not grant permission to use the trade 139 + names, trademarks, service marks, or product names of the Licensor, 140 + except as required for reasonable and customary use in describing the 141 + origin of the Work and reproducing the content of the NOTICE file. 142 + 143 + 7. Disclaimer of Warranty. Unless required by applicable law or 144 + agreed to in writing, Licensor provides the Work (and each 145 + Contributor provides its Contributions) on an "AS IS" BASIS, 146 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 + implied, including, without limitation, any warranties or conditions 148 + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 + PARTICULAR PURPOSE. You are solely responsible for determining the 150 + appropriateness of using or redistributing the Work and assume any 151 + risks associated with Your exercise of permissions under this License. 152 + 153 + 8. Limitation of Liability. In no event and under no legal theory, 154 + whether in tort (including negligence), contract, or otherwise, 155 + unless required by applicable law (such as deliberate and grossly 156 + negligent acts) or agreed to in writing, shall any Contributor be 157 + liable to You for damages, including any direct, indirect, special, 158 + incidental, or consequential damages of any character arising as a 159 + result of this License or out of the use or inability to use the 160 + Work (including but not limited to damages for loss of goodwill, 161 + work stoppage, computer failure or malfunction, or any and all 162 + other commercial damages or losses), even if such Contributor 163 + has been advised of the possibility of such damages. 164 + 165 + 9. Accepting Warranty or Additional Liability. While redistributing 166 + the Work or Derivative Works thereof, You may choose to offer, 167 + and charge a fee for, acceptance of support, warranty, indemnity, 168 + or other liability obligations and/or rights consistent with this 169 + License. However, in accepting such obligations, You may act only 170 + on Your own behalf and on Your sole responsibility, not on behalf 171 + of any other Contributor, and only if You agree to indemnify, 172 + defend, and hold each Contributor harmless for any liability 173 + incurred by, or claims asserted against, such Contributor by reason 174 + of your accepting any such warranty or additional liability. 175 + 176 + END OF TERMS AND CONDITIONS 177 + 178 + APPENDIX: How to apply the Apache License to your work. 179 + 180 + To apply the Apache License to your work, attach the following 181 + boilerplate notice, with the fields enclosed by brackets "[]" 182 + replaced with your own identifying information. (Don't include 183 + the brackets!) The text should be enclosed in the appropriate 184 + comment syntax for the file format. We also recommend that a 185 + file or class name and description of purpose be included on the 186 + same "printed page" as the copyright notice for easier 187 + identification within third-party archives. 188 + 189 + Copyright [yyyy] [name of copyright owner] 190 + 191 + Licensed under the Apache License, Version 2.0 (the "License"); 192 + you may not use this file except in compliance with the License. 193 + You may obtain a copy of the License at 194 + 195 + http://www.apache.org/licenses/LICENSE-2.0 196 + 197 + Unless required by applicable law or agreed to in writing, software 198 + distributed under the License is distributed on an "AS IS" BASIS, 199 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 + See the License for the specific language governing permissions and 201 + limitations under the License.
+13
README.md
··· 1 + # Tuiter 2006 2 + 3 + Source code for https://tuiter.alwaysdata.net 4 + 5 + ### Screenshots (very early alpha) 6 + 7 + <img width="1126" height="865" alt="tuiter1" src="https://gist.github.com/user-attachments/assets/57c52119-d662-4644-9959-8f3035975153" /> 8 + 9 + <img width="969" height="898" alt="tuiter2" src="https://gist.github.com/user-attachments/assets/c96aaa4e-dc19-4124-a66c-850bd123aec9" /> 10 + 11 + <img width="1126" height="821" alt="tuiter3" src="https://gist.github.com/user-attachments/assets/fa60d0f0-f0c7-43d8-9b92-d8df20a7eeb1" /> 12 + 13 + <img width="1126" height="886" alt="tuiter4" src="https://gist.github.com/user-attachments/assets/6d202ebc-e047-4951-afa2-41886ad2a5a8" />
+187
auth_store.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "crypto/aes" 6 + "crypto/cipher" 7 + "crypto/rand" 8 + "crypto/sha256" 9 + "database/sql" 10 + "encoding/base64" 11 + "encoding/json" 12 + "errors" 13 + "fmt" 14 + "io" 15 + "time" 16 + 17 + _ "modernc.org/sqlite" 18 + 19 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 20 + "github.com/bluesky-social/indigo/atproto/syntax" 21 + ) 22 + 23 + type sqliteStore struct { 24 + db *sql.DB 25 + key []byte 26 + } 27 + 28 + func deriveKeyFromEnv(raw string) ([]byte, error) { 29 + if raw == "" { 30 + return nil, errors.New("SESSION_DB_KEY is required") 31 + } 32 + if decoded, err := base64.StdEncoding.DecodeString(raw); err == nil && len(decoded) >= 16 { 33 + if len(decoded) == 32 { 34 + return decoded, nil 35 + } 36 + h := sha256.Sum256(decoded) 37 + return h[:], nil 38 + } 39 + h := sha256.Sum256([]byte(raw)) 40 + return h[:], nil 41 + } 42 + 43 + func encryptBlob(key, plaintext []byte) ([]byte, error) { 44 + block, err := aes.NewCipher(key) 45 + if err != nil { 46 + return nil, err 47 + } 48 + gcm, err := cipher.NewGCM(block) 49 + if err != nil { 50 + return nil, err 51 + } 52 + nonce := make([]byte, gcm.NonceSize()) 53 + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { 54 + return nil, err 55 + } 56 + ct := gcm.Seal(nil, nonce, plaintext, nil) 57 + out := make([]byte, 0, len(nonce)+len(ct)) 58 + out = append(out, nonce...) 59 + out = append(out, ct...) 60 + return out, nil 61 + } 62 + 63 + func decryptBlob(key, blob []byte) ([]byte, error) { 64 + block, err := aes.NewCipher(key) 65 + if err != nil { 66 + return nil, err 67 + } 68 + gcm, err := cipher.NewGCM(block) 69 + if err != nil { 70 + return nil, err 71 + } 72 + n := gcm.NonceSize() 73 + if len(blob) < n { 74 + return nil, errors.New("ciphertext too short") 75 + } 76 + nonce := blob[:n] 77 + ct := blob[n:] 78 + pt, err := gcm.Open(nil, nonce, ct, nil) 79 + if err != nil { 80 + return nil, err 81 + } 82 + return pt, nil 83 + } 84 + 85 + func NewSQLiteStore(dbPath string, key []byte) (*sqliteStore, error) { 86 + db, err := sql.Open("sqlite", dbPath) 87 + if err != nil { 88 + return nil, err 89 + } 90 + if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil { 91 + _ = db.Close() 92 + return nil, err 93 + } 94 + schema := []string{`CREATE TABLE IF NOT EXISTS sessions( 95 + session_id TEXT PRIMARY KEY, 96 + did TEXT, 97 + data BLOB, 98 + updated_at INTEGER 99 + );`, `CREATE TABLE IF NOT EXISTS auth_requests( 100 + state TEXT PRIMARY KEY, 101 + data BLOB, 102 + updated_at INTEGER 103 + );`} 104 + for _, s := range schema { 105 + if _, err := db.Exec(s); err != nil { 106 + _ = db.Close() 107 + return nil, err 108 + } 109 + } 110 + return &sqliteStore{db: db, key: key}, nil 111 + } 112 + 113 + func (s *sqliteStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 114 + data, err := json.Marshal(sess) 115 + if err != nil { 116 + return err 117 + } 118 + enc, err := encryptBlob(s.key, data) 119 + if err != nil { 120 + return err 121 + } 122 + _, err = s.db.ExecContext(ctx, `INSERT INTO sessions(session_id, did, data, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET did=excluded.did, data=excluded.data, updated_at=excluded.updated_at`, sess.SessionID, sess.AccountDID.String(), enc, time.Now().Unix()) 123 + return err 124 + } 125 + 126 + func (s *sqliteStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 127 + row := s.db.QueryRowContext(ctx, `SELECT data FROM sessions WHERE session_id = ? AND did = ?`, sessionID, did.String()) 128 + var blob []byte 129 + if err := row.Scan(&blob); err != nil { 130 + if errors.Is(err, sql.ErrNoRows) { 131 + return nil, fmt.Errorf("session not found") 132 + } 133 + return nil, err 134 + } 135 + pt, err := decryptBlob(s.key, blob) 136 + if err != nil { 137 + return nil, err 138 + } 139 + var out oauth.ClientSessionData 140 + if err := json.Unmarshal(pt, &out); err != nil { 141 + return nil, err 142 + } 143 + return &out, nil 144 + } 145 + 146 + func (s *sqliteStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 147 + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE session_id = ? AND did = ?`, sessionID, did.String()) 148 + return err 149 + } 150 + 151 + func (s *sqliteStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 152 + data, err := json.Marshal(info) 153 + if err != nil { 154 + return err 155 + } 156 + enc, err := encryptBlob(s.key, data) 157 + if err != nil { 158 + return err 159 + } 160 + _, err = s.db.ExecContext(ctx, `INSERT INTO auth_requests(state, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(state) DO UPDATE SET data=excluded.data, updated_at=excluded.updated_at`, info.State, enc, time.Now().Unix()) 161 + return err 162 + } 163 + 164 + func (s *sqliteStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 165 + row := s.db.QueryRowContext(ctx, `SELECT data FROM auth_requests WHERE state = ?`, state) 166 + var blob []byte 167 + if err := row.Scan(&blob); err != nil { 168 + if errors.Is(err, sql.ErrNoRows) { 169 + return nil, fmt.Errorf("auth request not found") 170 + } 171 + return nil, err 172 + } 173 + pt, err := decryptBlob(s.key, blob) 174 + if err != nil { 175 + return nil, err 176 + } 177 + var out oauth.AuthRequestData 178 + if err := json.Unmarshal(pt, &out); err != nil { 179 + return nil, err 180 + } 181 + return &out, nil 182 + } 183 + 184 + func (s *sqliteStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 185 + _, err := s.db.ExecContext(ctx, `DELETE FROM auth_requests WHERE state = ?`, state) 186 + return err 187 + }
+82
go.mod
··· 1 + module github.com/user/2006 2 + 3 + go 1.24 4 + 5 + toolchain go1.24.5 6 + 7 + require ( 8 + github.com/bluesky-social/indigo v0.0.0-20250813051257-8be102876fb7 9 + github.com/gorilla/sessions v1.4.0 10 + modernc.org/sqlite v1.38.2 11 + ) 12 + 13 + require ( 14 + github.com/beorn7/perks v1.0.1 // indirect 15 + github.com/carlmjohnson/versioninfo v0.22.5 // indirect 16 + github.com/cespare/xxhash/v2 v2.2.0 // indirect 17 + github.com/dustin/go-humanize v1.0.1 // indirect 18 + github.com/felixge/httpsnoop v1.0.4 // indirect 19 + github.com/go-logr/logr v1.4.1 // indirect 20 + github.com/go-logr/stdr v1.2.2 // indirect 21 + github.com/gogo/protobuf v1.3.2 // indirect 22 + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 23 + github.com/google/go-querystring v1.1.0 // indirect 24 + github.com/google/uuid v1.6.0 // indirect 25 + github.com/gorilla/securecookie v1.1.2 // indirect 26 + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 27 + github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 28 + github.com/hashicorp/golang-lru v1.0.2 // indirect 29 + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 30 + github.com/ipfs/bbloom v0.0.4 // indirect 31 + github.com/ipfs/go-block-format v0.2.0 // indirect 32 + github.com/ipfs/go-cid v0.4.1 // indirect 33 + github.com/ipfs/go-datastore v0.6.0 // indirect 34 + github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 35 + github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 36 + github.com/ipfs/go-ipfs-util v0.0.3 // indirect 37 + github.com/ipfs/go-ipld-cbor v0.1.0 // indirect 38 + github.com/ipfs/go-ipld-format v0.6.0 // indirect 39 + github.com/ipfs/go-log v1.0.5 // indirect 40 + github.com/ipfs/go-log/v2 v2.5.1 // indirect 41 + github.com/ipfs/go-metrics-interface v0.0.1 // indirect 42 + github.com/jbenet/goprocess v0.1.4 // indirect 43 + github.com/klauspost/cpuid/v2 v2.2.7 // indirect 44 + github.com/mattn/go-isatty v0.0.20 // indirect 45 + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 46 + github.com/minio/sha256-simd v1.0.1 // indirect 47 + github.com/mr-tron/base58 v1.2.0 // indirect 48 + github.com/multiformats/go-base32 v0.1.0 // indirect 49 + github.com/multiformats/go-base36 v0.2.0 // indirect 50 + github.com/multiformats/go-multibase v0.2.0 // indirect 51 + github.com/multiformats/go-multihash v0.2.3 // indirect 52 + github.com/multiformats/go-varint v0.0.7 // indirect 53 + github.com/ncruces/go-strftime v0.1.9 // indirect 54 + github.com/opentracing/opentracing-go v1.2.0 // indirect 55 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 56 + github.com/prometheus/client_golang v1.17.0 // indirect 57 + github.com/prometheus/client_model v0.5.0 // indirect 58 + github.com/prometheus/common v0.45.0 // indirect 59 + github.com/prometheus/procfs v0.12.0 // indirect 60 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 61 + github.com/spaolacci/murmur3 v1.1.0 // indirect 62 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 63 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 64 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 65 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect 66 + go.opentelemetry.io/otel v1.21.0 // indirect 67 + go.opentelemetry.io/otel/metric v1.21.0 // indirect 68 + go.opentelemetry.io/otel/trace v1.21.0 // indirect 69 + go.uber.org/atomic v1.11.0 // indirect 70 + go.uber.org/multierr v1.11.0 // indirect 71 + go.uber.org/zap v1.26.0 // indirect 72 + golang.org/x/crypto v0.21.0 // indirect 73 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 74 + golang.org/x/sys v0.34.0 // indirect 75 + golang.org/x/time v0.3.0 // indirect 76 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 77 + google.golang.org/protobuf v1.33.0 // indirect 78 + lukechampine.com/blake3 v1.2.1 // indirect 79 + modernc.org/libc v1.66.3 // indirect 80 + modernc.org/mathutil v1.7.1 // indirect 81 + modernc.org/memory v1.11.0 // indirect 82 + )
+294
go.sum
··· 1 + github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 + github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 3 + github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 4 + github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 5 + github.com/bluesky-social/indigo v0.0.0-20250813051257-8be102876fb7 h1:FyoGfQFw/cTkDHdUTIYIHxfyUDgRS12K4o1mYC3ovRs= 6 + github.com/bluesky-social/indigo v0.0.0-20250813051257-8be102876fb7/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 7 + github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 8 + github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 9 + github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 10 + github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 11 + github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 12 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 16 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 17 + github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 18 + github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 19 + github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 20 + github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 21 + github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 22 + github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 23 + github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 24 + github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 25 + github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 26 + github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 27 + github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 28 + github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 29 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 30 + github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 31 + github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 32 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 33 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 34 + github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 35 + github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 36 + github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 37 + github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 38 + github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 39 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 40 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 41 + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 42 + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 43 + github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 44 + github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 45 + github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 46 + github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 47 + github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 48 + github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 49 + github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= 50 + github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 51 + github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= 52 + github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= 53 + github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 54 + github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 55 + github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 56 + github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 57 + github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 58 + github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 59 + github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= 60 + github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= 61 + github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= 62 + github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 63 + github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= 64 + github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= 65 + github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= 66 + github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= 67 + github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= 68 + github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 69 + github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= 70 + github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 71 + github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 72 + github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 73 + github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= 74 + github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= 75 + github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= 76 + github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= 77 + github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 78 + github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 79 + github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= 80 + github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= 81 + github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= 82 + github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= 83 + github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= 84 + github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= 85 + github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 86 + github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 87 + github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 88 + github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 89 + github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 90 + github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 91 + github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 92 + github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 93 + github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 94 + github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 95 + github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 96 + github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 97 + github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 98 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 99 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 100 + github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 101 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 102 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 103 + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= 104 + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= 105 + github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 106 + github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 107 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 108 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 109 + github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 110 + github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 111 + github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 112 + github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 113 + github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 114 + github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 115 + github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 116 + github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 117 + github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 118 + github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 119 + github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 120 + github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 121 + github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 122 + github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 123 + github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 124 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 125 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 126 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 127 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 128 + github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= 129 + github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= 130 + github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 131 + github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 132 + github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= 133 + github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= 134 + github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 135 + github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 136 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 137 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 138 + github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 139 + github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 140 + github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 141 + github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 142 + github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 143 + github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 144 + github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= 145 + github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= 146 + github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 147 + github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 148 + github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 149 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 150 + github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 151 + github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 152 + github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 153 + github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 154 + github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 155 + github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 156 + github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 157 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 158 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 159 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 160 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 161 + github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 162 + github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 163 + github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 164 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 165 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 166 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 167 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 168 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= 169 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= 170 + go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= 171 + go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= 172 + go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= 173 + go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= 174 + go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= 175 + go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= 176 + go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 177 + go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 178 + go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 179 + go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 180 + go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 181 + go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 182 + go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 183 + go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 184 + go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 185 + go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 186 + go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 187 + go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 188 + go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 189 + go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 190 + go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 191 + go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 192 + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 193 + golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 194 + golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 195 + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 196 + golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 197 + golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 198 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 199 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 200 + golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 201 + golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 202 + golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 203 + golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 204 + golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 205 + golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= 206 + golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 207 + golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 208 + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 209 + golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 210 + golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 211 + golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 212 + golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 213 + golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 214 + golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 215 + golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 216 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 217 + golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 218 + golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 219 + golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 220 + golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 221 + golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 222 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 223 + golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 224 + golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 225 + golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 226 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 227 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 228 + golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 229 + golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 230 + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 231 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 232 + golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 233 + golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 234 + golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 235 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 236 + golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 237 + golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 238 + golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 239 + golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 240 + golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 241 + golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 242 + golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 243 + golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 244 + golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 245 + golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= 246 + golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= 247 + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 248 + golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 249 + golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 250 + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 251 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 252 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 253 + google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 254 + google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 255 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 256 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 257 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 258 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 259 + gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 260 + gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 261 + gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 262 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 263 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 264 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 265 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 266 + honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 267 + lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= 268 + lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= 269 + modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= 270 + modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 271 + modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= 272 + modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= 273 + modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= 274 + modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 275 + modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 276 + modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 277 + modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= 278 + modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 279 + modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= 280 + modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= 281 + modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 282 + modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 283 + modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 284 + modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 285 + modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 286 + modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 287 + modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 288 + modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 289 + modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= 290 + modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= 291 + modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 292 + modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 293 + modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 294 + modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+25
handlers.go
··· 1 + package main 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + ) 7 + 8 + func handleIndex(w http.ResponseWriter, r *http.Request) { 9 + log.Printf("DEBUG: handleIndex called - Method: %s, URL: %s", r.Method, r.URL.Path) 10 + session, _ := store.Get(r, sessionName) 11 + if session.Values["did"] == nil { 12 + http.Redirect(w, r, "/signin", http.StatusFound) 13 + return 14 + } 15 + http.Redirect(w, r, "/timeline", http.StatusFound) 16 + } 17 + 18 + func handleSignin(w http.ResponseWriter, r *http.Request) { 19 + executeTemplate(w, "signin.html", nil) 20 + } 21 + 22 + func handleAbout(w http.ResponseWriter, r *http.Request) { 23 + log.Printf("DEBUG: handleAbout called - Method: %s, URL: %s", r.Method, r.URL.Path) 24 + executeTemplate(w, "about.html", nil) 25 + }
+73
handlers_auth.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + func handleLogin(w http.ResponseWriter, r *http.Request) { 11 + ctx := r.Context() 12 + identifier := r.FormValue("identifier") 13 + if identifier == "" { 14 + http.Error(w, "identifier is required", http.StatusBadRequest) 15 + return 16 + } 17 + redirectURL, err := oauthApp.StartAuthFlow(ctx, identifier) 18 + if err != nil { 19 + http.Error(w, err.Error(), http.StatusInternalServerError) 20 + return 21 + } 22 + http.Redirect(w, r, redirectURL, http.StatusFound) 23 + } 24 + 25 + func handleLogout(w http.ResponseWriter, r *http.Request) { 26 + session, _ := store.Get(r, sessionName) 27 + 28 + didStr, ok := session.Values["did"].(string) 29 + if ok { 30 + did, err := syntax.ParseDID(didStr) 31 + if err == nil { 32 + sessionID, ok := session.Values["session_id"].(string) 33 + if ok { 34 + oauthApp.Store.DeleteSession(r.Context(), did, sessionID) 35 + } 36 + } 37 + } 38 + 39 + session.Values["did"] = nil 40 + session.Values["session_id"] = nil 41 + session.Save(r, w) 42 + http.Redirect(w, r, "/signin", http.StatusFound) 43 + } 44 + 45 + func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { 46 + ctx := r.Context() 47 + 48 + sessData, err := oauthApp.ProcessCallback(ctx, r.URL.Query()) 49 + if err != nil { 50 + http.Error(w, err.Error(), http.StatusInternalServerError) 51 + return 52 + } 53 + 54 + session, _ := store.Get(r, sessionName) 55 + session.Values["did"] = sessData.AccountDID.String() 56 + session.Values["session_id"] = sessData.SessionID 57 + err = session.Save(r, w) 58 + if err != nil { 59 + http.Error(w, err.Error(), http.StatusInternalServerError) 60 + return 61 + } 62 + 63 + http.Redirect(w, r, "/timeline", http.StatusFound) 64 + } 65 + 66 + func handleClientMetadata(w http.ResponseWriter, r *http.Request) { 67 + doc := oauthApp.Config.ClientMetadata() 68 + w.Header().Set("Content-Type", "application/json") 69 + if err := json.NewEncoder(w).Encode(doc); err != nil { 70 + http.Error(w, err.Error(), http.StatusInternalServerError) 71 + return 72 + } 73 + }
+256
handlers_helpers.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "net/http" 8 + "strings" 9 + "time" 10 + 11 + bsky "github.com/bluesky-social/indigo/api/bsky" 12 + "github.com/bluesky-social/indigo/atproto/client" 13 + ) 14 + 15 + // buildPostURIFromRequest extracts the post URI either from query param `uri` or 16 + // by resolving a /post/{handle}/{postID} style path to an at:// URI. 17 + func buildPostURIFromRequest(ctx context.Context, r *http.Request, c *client.APIClient) (string, error) { 18 + if v := r.URL.Query().Get("uri"); v != "" { 19 + return v, nil 20 + } 21 + pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/post/"), "/") 22 + if len(pathParts) >= 2 { 23 + handle := pathParts[0] 24 + postID := pathParts[1] 25 + authorDID, err := resolveHandleToDID(ctx, c, handle) 26 + if err != nil { 27 + return "", err 28 + } 29 + return "at://" + authorDID + "/app.bsky.feed.post/" + postID, nil 30 + } 31 + return "", fmt.Errorf("post uri not found") 32 + } 33 + 34 + // fetchThreadAndExtract fetches a post thread and returns the main post, any replies, and the thread root node. 35 + func fetchThreadAndExtract(ctx context.Context, c *client.APIClient, postURI string) (*bsky.FeedDefs_PostView, []*bsky.FeedDefs_PostView, *bsky.FeedDefs_ThreadViewPost, error) { 36 + thread, err := bsky.FeedGetPostThread(ctx, c, 100, 0, postURI) 37 + if err != nil { 38 + return nil, nil, nil, err 39 + } 40 + var main *bsky.FeedDefs_PostView 41 + var replies []*bsky.FeedDefs_PostView 42 + var root *bsky.FeedDefs_ThreadViewPost 43 + if thread.Thread != nil && thread.Thread.FeedDefs_ThreadViewPost != nil { 44 + main = thread.Thread.FeedDefs_ThreadViewPost.Post 45 + root = thread.Thread.FeedDefs_ThreadViewPost 46 + 47 + // recursive collector to gather all descendant replies 48 + var collect func(node *bsky.FeedDefs_ThreadViewPost) 49 + collect = func(node *bsky.FeedDefs_ThreadViewPost) { 50 + if node == nil || node.Replies == nil { 51 + return 52 + } 53 + for _, r := range node.Replies { 54 + if r == nil || r.FeedDefs_ThreadViewPost == nil || r.FeedDefs_ThreadViewPost.Post == nil { 55 + continue 56 + } 57 + p := r.FeedDefs_ThreadViewPost.Post 58 + replies = append(replies, p) 59 + // recurse into this reply's nested replies 60 + collect(r.FeedDefs_ThreadViewPost) 61 + } 62 + } 63 + collect(thread.Thread.FeedDefs_ThreadViewPost) 64 + } 65 + return main, replies, root, nil 66 + } 67 + 68 + // fetchAuthorDetails resolves an author handle to DID, fetches profile and follows. 69 + func fetchAuthorDetails(ctx context.Context, c *client.APIClient, authorHandle string) (*bsky.ActorDefs_ProfileViewDetailed, []*bsky.ActorDefs_ProfileView, error) { 70 + if authorHandle == "" { 71 + return nil, nil, fmt.Errorf("empty author handle") 72 + } 73 + did, err := resolveHandleToDID(ctx, c, authorHandle) 74 + if err != nil { 75 + return nil, nil, err 76 + } 77 + p, err := bsky.ActorGetProfile(ctx, c, did) 78 + if err != nil { 79 + return nil, nil, err 80 + } 81 + follows := fetchFollows(ctx, c, did, 50) 82 + return p, follows, nil 83 + } 84 + 85 + // extractReplyParentURI returns the parent URI if the post record contains a reply ref. 86 + func extractReplyParentURI(pv *bsky.FeedDefs_PostView) string { 87 + if pv == nil || pv.Record == nil || pv.Record.Val == nil { 88 + return "" 89 + } 90 + if post, ok := pv.Record.Val.(*bsky.FeedPost); ok && post != nil && post.Reply != nil { 91 + if post.Reply.Parent != nil && post.Reply.Parent.Uri != "" { 92 + return post.Reply.Parent.Uri 93 + } 94 + if post.Reply.Root != nil && post.Reply.Root.Uri != "" { 95 + return post.Reply.Root.Uri 96 + } 97 + } 98 + return "" 99 + } 100 + 101 + // fetchPostsBatch fetches posts for the provided URIs and returns a map uri->postView 102 + func fetchPostsBatch(ctx context.Context, c *client.APIClient, uris []string) (map[string]*bsky.FeedDefs_PostView, error) { 103 + if len(uris) == 0 { 104 + return nil, nil 105 + } 106 + resp, err := bsky.FeedGetPosts(ctx, c, uris) 107 + if err != nil { 108 + return nil, fmt.Errorf("FeedGetPosts error: %w", err) 109 + } 110 + m := make(map[string]*bsky.FeedDefs_PostView) 111 + for _, p := range resp.Posts { 112 + m[p.Uri] = p 113 + } 114 + return m, nil 115 + } 116 + 117 + // buildParentChain walks from an immediate parent up to the reply root (or stops at maxDepth) 118 + // Returns chain ordered from root ... parent (chronological ancestor order) 119 + func buildParentChain(ctx context.Context, c *client.APIClient, startURI string, maxDepth int) ([]*bsky.FeedDefs_PostView, error) { 120 + var chain []*bsky.FeedDefs_PostView 121 + currentURI := startURI 122 + seen := map[string]bool{} 123 + for depth := 0; depth < maxDepth && currentURI != ""; depth++ { 124 + if seen[currentURI] { 125 + break 126 + } 127 + seen[currentURI] = true 128 + 129 + // fetch current 130 + postsMap, err := fetchPostsBatch(ctx, c, []string{currentURI}) 131 + if err != nil { 132 + return chain, err 133 + } 134 + p, ok := postsMap[currentURI] 135 + if !ok || p == nil { 136 + // not found, stop walking 137 + break 138 + } 139 + // prepend to chain later; collect in reverse order first 140 + chain = append([]*bsky.FeedDefs_PostView{p}, chain...) 141 + 142 + // determine next parent 143 + next := extractReplyParentURI(p) 144 + if next == "" || next == currentURI { 145 + break 146 + } 147 + currentURI = next 148 + // small sleep to avoid hammering remote servers in pathological loops 149 + time.Sleep(20 * time.Millisecond) 150 + } 151 + return chain, nil 152 + } 153 + 154 + // preparePostPageData performs the steps required to assemble PostPageData for templates. 155 + func preparePostPageData(ctx context.Context, r *http.Request, c *client.APIClient, myDid string) (PostPageData, error) { 156 + postURI, err := buildPostURIFromRequest(ctx, r, c) 157 + if err != nil { 158 + return PostPageData{}, err 159 + } 160 + mainPost, replies, threadRoot, err := fetchThreadAndExtract(ctx, c, postURI) 161 + if err != nil { 162 + return PostPageData{}, err 163 + } 164 + profile, err := fetchProfile(ctx, c, myDid) 165 + if err != nil { 166 + return PostPageData{}, err 167 + } 168 + 169 + var parentChain []*bsky.FeedDefs_PostView 170 + // if mainPost itself is a reply, walk up to root 171 + if mainPost != nil { 172 + parentURI := extractReplyParentURI(mainPost) 173 + if parentURI != "" { 174 + chain, err := buildParentChain(ctx, c, parentURI, 20) 175 + if err != nil { 176 + // log and continue with what we have 177 + fmt.Printf("DEBUG: preparePostPageData - error building parent chain: %v\n", err) 178 + } else { 179 + parentChain = chain 180 + } 181 + } 182 + } 183 + 184 + var postAuthor *bsky.ActorDefs_ProfileViewDetailed 185 + var postAuthorFollows []*bsky.ActorDefs_ProfileView 186 + if mainPost != nil && mainPost.Author != nil { 187 + p, follows, err := fetchAuthorDetails(ctx, c, mainPost.Author.Handle) 188 + if err == nil { 189 + postAuthor = p 190 + postAuthorFollows = follows 191 + } 192 + } 193 + 194 + // Debug logging: counts and sample URIs 195 + if replies != nil { 196 + firstReply := "" 197 + if len(replies) > 0 && replies[0] != nil { 198 + firstReply = replies[0].Uri 199 + } 200 + log.Printf("DEBUG: preparePostPageData - ViewedURI=%s Replies=%d firstReply=%s ThreadRootPresent=%t ParentChainLen=%d", postURI, len(replies), firstReply, threadRoot != nil, len(parentChain)) 201 + } else { 202 + log.Printf("DEBUG: preparePostPageData - ViewedURI=%s Replies=0 ThreadRootPresent=%t ParentChainLen=%d", postURI, threadRoot != nil, len(parentChain)) 203 + } 204 + 205 + data := PostPageData{ 206 + Title: "Post - Tuiter 2006", 207 + Post: mainPost, 208 + Replies: replies, 209 + ParentChain: parentChain, 210 + ViewedURI: postURI, 211 + ThreadRoot: threadRoot, 212 + CurrentUser: profile, 213 + PostAuthor: postAuthor, 214 + PostAuthorFollows: postAuthorFollows, 215 + // SignedIn is the profile of the currently authenticated user 216 + SignedIn: profile, 217 + } 218 + return data, nil 219 + } 220 + 221 + // prepareProfilePageData assembles ProfilePageData for rendering a user's profile page. 222 + func prepareProfilePageData(ctx context.Context, c *client.APIClient, myDid string, profileHandle string) (ProfilePageData, error) { 223 + profileView, err := fetchProfile(ctx, c, profileHandle) 224 + if err != nil { 225 + return ProfilePageData{}, err 226 + } 227 + if profileView == nil { 228 + return ProfilePageData{}, fmt.Errorf("profile not found") 229 + } 230 + authorFeed, err := bsky.FeedGetAuthorFeed(ctx, c, profileView.Did, "", "", false, 50) 231 + if err != nil { 232 + return ProfilePageData{}, err 233 + } 234 + myProfile, err := fetchProfile(ctx, c, myDid) 235 + if err != nil { 236 + return ProfilePageData{}, err 237 + } 238 + followsList := fetchFollows(ctx, c, profileView.Did, 50) 239 + 240 + postBoxHandle := "" 241 + if profileView.Handle != "" { 242 + postBoxHandle = profileView.Handle 243 + } 244 + 245 + data := ProfilePageData{ 246 + Title: "Profile - Tuiter 2006", 247 + Profile: profileView, 248 + Feed: authorFeed, 249 + Follows: followsList, 250 + Posts: PostsList{Items: authorFeed.Feed, Cursor: getCursorFromAuthorFeed(authorFeed)}, 251 + PostBoxHandle: postBoxHandle, 252 + // SignedIn is the currently authenticated profile 253 + SignedIn: myProfile, 254 + } 255 + return data, nil 256 + }
+224
handlers_htmx.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + 8 + bsky "github.com/bluesky-social/indigo/api/bsky" 9 + ) 10 + 11 + func htmxTimelineFeed(w http.ResponseWriter, r *http.Request) { 12 + c, _, err := getClientFromSession(r.Context(), r) 13 + if err != nil { 14 + w.WriteHeader(http.StatusUnauthorized) 15 + return 16 + } 17 + 18 + cursor := r.URL.Query().Get("cursor") 19 + timeline, err := bsky.FeedGetTimeline(r.Context(), c, "", cursor, 50) 20 + if err != nil { 21 + log.Printf("DEBUG: htmxTimelineFeed - Error fetching timeline: %v", err) 22 + http.Error(w, "Failed to load timeline", http.StatusInternalServerError) 23 + return 24 + } 25 + 26 + // Collect parent URIs and batch-fetch previews 27 + parentURIsSet := map[string]struct{}{} 28 + if timeline != nil && timeline.Feed != nil { 29 + for _, fv := range timeline.Feed { 30 + if fv == nil || fv.Post == nil { 31 + continue 32 + } 33 + if uri := extractReplyParentURI(fv.Post); uri != "" { 34 + parentURIsSet[uri] = struct{}{} 35 + } 36 + chain := GetReplyChainInfos(fv.Post) 37 + for _, pi := range chain { 38 + if pi.Uri != "" { 39 + parentURIsSet[pi.Uri] = struct{}{} 40 + } 41 + } 42 + } 43 + } 44 + 45 + var parentURIs []string 46 + for u := range parentURIsSet { 47 + parentURIs = append(parentURIs, u) 48 + } 49 + 50 + parentPreviews := map[string]ParentInfo{} 51 + if len(parentURIs) > 0 { 52 + const batchSize = 25 53 + for i := 0; i < len(parentURIs); i += batchSize { 54 + end := i + batchSize 55 + if end > len(parentURIs) { 56 + end = len(parentURIs) 57 + } 58 + batch := parentURIs[i:end] 59 + postsMap, err := fetchPostsBatch(r.Context(), c, batch) 60 + if err != nil { 61 + log.Printf("DEBUG: htmxTimelineFeed - fetchPostsBatch error: %v", err) 62 + continue 63 + } 64 + for uri, pv := range postsMap { 65 + if pv == nil { 66 + continue 67 + } 68 + pi := ParentInfo{Uri: uri} 69 + if pv.Author != nil { 70 + if pv.Author.DisplayName != nil && *pv.Author.DisplayName != "" { 71 + pi.AuthorName = *pv.Author.DisplayName 72 + } else if pv.Author.Handle != "" { 73 + pi.AuthorName = pv.Author.Handle 74 + } 75 + if pv.Author.Handle != "" { 76 + pi.AuthorHandle = pv.Author.Handle 77 + } 78 + if pv.Author.Avatar != nil { 79 + pi.Avatar = *pv.Author.Avatar 80 + } 81 + } 82 + pi.Text = getPostText(pv.Record) 83 + if pv.Uri != "" { 84 + pi.PostURL = getPostURL(pv) 85 + } 86 + pi.IndexedAt = pv.IndexedAt 87 + if m := GetPostMedia(pv); m != nil { 88 + pi.Media = m 89 + } 90 + // like count if available 91 + if pv.LikeCount != nil { 92 + pi.LikeCount = int(*pv.LikeCount) 93 + } 94 + pi.ReplyCount = int(*pv.ReplyCount) 95 + pi.RepostCount = int(*pv.RepostCount) 96 + pi.IsFav = getIsFav(pv) 97 + parentPreviews[uri] = pi 98 + } 99 + } 100 + } 101 + 102 + w.Header().Set("Content-Type", "text/html") 103 + data := TimelinePartialData{Timeline: timeline, Posts: PostsList{Items: timeline.Feed, Cursor: getCursorFromTimeline(timeline), ParentPreviews: parentPreviews}} 104 + if err := tpl.ExecuteTemplate(w, "timeline_posts_partial.html", data); err != nil { 105 + log.Printf("DEBUG: htmxTimelineFeed - Template error: %v", err) 106 + http.Error(w, "Internal server error", http.StatusInternalServerError) 107 + return 108 + } 109 + 110 + nextCursor := getCursorFromTimeline(timeline) 111 + tmplData := struct{ Cursor string }{Cursor: nextCursor} 112 + if err := tpl.ExecuteTemplate(w, "timeline_more.html", tmplData); err != nil { 113 + log.Printf("DEBUG: htmxTimelineFeed - failed to execute timeline_more template: %v", err) 114 + fmt.Fprint(w, `<div id="timeline-more" hx-swap-oob="innerHTML"></div>`) 115 + } 116 + } 117 + 118 + func htmxProfileFeed(w http.ResponseWriter, r *http.Request) { 119 + c, _, err := getClientFromSession(r.Context(), r) 120 + if err != nil { 121 + w.WriteHeader(http.StatusUnauthorized) 122 + return 123 + } 124 + 125 + did := r.URL.Query().Get("did") 126 + cursor := r.URL.Query().Get("cursor") 127 + 128 + feed, err := bsky.FeedGetAuthorFeed(r.Context(), c, did, "", cursor, false, 50) 129 + if err != nil { 130 + log.Printf("DEBUG: htmxProfileFeed - Error fetching author feed for %s: %v", did, err) 131 + http.Error(w, "Failed to load profile posts", http.StatusInternalServerError) 132 + return 133 + } 134 + 135 + // Collect parent URIs for this author feed and batch-fetch previews 136 + parentURIsSet := map[string]struct{}{} 137 + if feed != nil && feed.Feed != nil { 138 + for _, fv := range feed.Feed { 139 + if fv == nil || fv.Post == nil { 140 + continue 141 + } 142 + if uri := extractReplyParentURI(fv.Post); uri != "" { 143 + parentURIsSet[uri] = struct{}{} 144 + } 145 + chain := GetReplyChainInfos(fv.Post) 146 + for _, pi := range chain { 147 + if pi.Uri != "" { 148 + parentURIsSet[pi.Uri] = struct{}{} 149 + } 150 + } 151 + } 152 + } 153 + 154 + var parentURIs []string 155 + for u := range parentURIsSet { 156 + parentURIs = append(parentURIs, u) 157 + } 158 + 159 + parentPreviews := map[string]ParentInfo{} 160 + if len(parentURIs) > 0 { 161 + const batchSize = 25 162 + for i := 0; i < len(parentURIs); i += batchSize { 163 + end := i + batchSize 164 + if end > len(parentURIs) { 165 + end = len(parentURIs) 166 + } 167 + batch := parentURIs[i:end] 168 + postsMap, err := fetchPostsBatch(r.Context(), c, batch) 169 + if err != nil { 170 + log.Printf("DEBUG: htmxProfileFeed - fetchPostsBatch error: %v", err) 171 + continue 172 + } 173 + for uri, pv := range postsMap { 174 + if pv == nil { 175 + continue 176 + } 177 + pi := ParentInfo{Uri: uri} 178 + if pv.Author != nil { 179 + if pv.Author.DisplayName != nil && *pv.Author.DisplayName != "" { 180 + pi.AuthorName = *pv.Author.DisplayName 181 + } else if pv.Author.Handle != "" { 182 + pi.AuthorName = pv.Author.Handle 183 + } 184 + if pv.Author.Handle != "" { 185 + pi.AuthorHandle = pv.Author.Handle 186 + } 187 + if pv.Author.Avatar != nil { 188 + pi.Avatar = *pv.Author.Avatar 189 + } 190 + } 191 + pi.Text = getPostText(pv.Record) 192 + if pv.Uri != "" { 193 + pi.PostURL = getPostURL(pv) 194 + } 195 + pi.IndexedAt = pv.IndexedAt 196 + if m := GetPostMedia(pv); m != nil { 197 + pi.Media = m 198 + } 199 + // like count if available 200 + if pv.LikeCount != nil { 201 + pi.LikeCount = int(*pv.LikeCount) 202 + } 203 + pi.ReplyCount = int(*pv.ReplyCount) 204 + pi.RepostCount = int(*pv.RepostCount) 205 + pi.IsFav = getIsFav(pv) 206 + parentPreviews[uri] = pi 207 + } 208 + } 209 + } 210 + 211 + w.Header().Set("Content-Type", "text/html") 212 + postsData := PostsList{Items: feed.Feed, Cursor: getCursorFromAuthorFeed(feed), ParentPreviews: parentPreviews} 213 + if err := tpl.ExecuteTemplate(w, "posts_list_partial.html", postsData); err != nil { 214 + log.Printf("DEBUG: htmxProfileFeed - Template error: %v", err) 215 + http.Error(w, "Internal server error", http.StatusInternalServerError) 216 + return 217 + } 218 + 219 + tmplData := struct{ Did, Cursor string }{Did: did, Cursor: postsData.Cursor} 220 + if err := tpl.ExecuteTemplate(w, "profile_more.html", tmplData); err != nil { 221 + log.Printf("DEBUG: htmxProfileFeed - failed to execute profile_more template: %v", err) 222 + fmt.Fprint(w, `<div id="profile-more" hx-swap-oob="innerHTML"></div>`) 223 + } 224 + }
+171
handlers_media.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "io" 8 + "log" 9 + "net/http" 10 + "strconv" 11 + "strings" 12 + 13 + "github.com/bluesky-social/indigo/api/atproto" 14 + ) 15 + 16 + // handleVideo serves video blobs. Preferred path: fetch the blob from the user's 17 + // PDS via com.atproto.sync.getBlob (SyncGetBlob) using the authenticated API client 18 + // (getClientFromSession). This returns the raw blob bytes which we serve directly 19 + // and implement Range support by slicing the blob. If that fails (no session or 20 + // error), fall back to proxying a public IPFS gateway (legacy behavior). 21 + func handleVideo(w http.ResponseWriter, r *http.Request) { 22 + // Support URL shapes: /video/{cid} (legacy) and /video/{did}/{cid} (owner-aware) 23 + path := strings.TrimPrefix(r.URL.Path, "/video/") 24 + path = strings.TrimSpace(path) 25 + if path == "" { 26 + http.Error(w, "missing video id", http.StatusBadRequest) 27 + return 28 + } 29 + segments := strings.SplitN(path, "/", 2) 30 + ownerDid := "" 31 + cid := "" 32 + if len(segments) == 1 { 33 + cid = segments[0] 34 + } else { 35 + ownerDid = segments[0] 36 + cid = segments[1] 37 + } 38 + 39 + // Try to get authenticated client and session DID from session 40 + c, sessionDid, err := getClientFromSession(r.Context(), r) 41 + var blob []byte 42 + if err == nil { 43 + // Determine which DID to pass to SyncGetBlob: prefer explicit ownerDid from URL 44 + didToUse := sessionDid 45 + if ownerDid != "" { 46 + didToUse = ownerDid 47 + } 48 + // Attempt to fetch the blob via AT Protocol sync.getBlob 49 + blob, err = atproto.SyncGetBlob(context.Background(), c, cid, didToUse) 50 + if err != nil { 51 + log.Printf("DEBUG: handleVideo - SyncGetBlob error for cid=%s did=%s: %v", cid, didToUse, err) 52 + blob = nil 53 + } 54 + } else { 55 + log.Printf("DEBUG: handleVideo - no authenticated session: %v", err) 56 + } 57 + 58 + // If we have the blob bytes from the PDS, serve it (with Range support) 59 + if blob != nil { 60 + size := int64(len(blob)) 61 + w.Header().Set("Accept-Ranges", "bytes") 62 + // Try to honor client's Range header 63 + rh := r.Header.Get("Range") 64 + // Default content-type fallback 65 + w.Header().Set("Content-Type", "video/mp4") 66 + 67 + if rh == "" { 68 + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) 69 + w.WriteHeader(http.StatusOK) 70 + if _, err := io.Copy(w, bytes.NewReader(blob)); err != nil { 71 + log.Printf("DEBUG: handleVideo - error streaming blob cid=%s: %v", cid, err) 72 + } 73 + return 74 + } 75 + 76 + // Parse single-range header of form: bytes=start-end 77 + if !strings.HasPrefix(rh, "bytes=") { 78 + http.Error(w, "invalid range", http.StatusBadRequest) 79 + return 80 + } 81 + rangeSpec := strings.TrimPrefix(rh, "bytes=") 82 + parts := strings.Split(rangeSpec, "-") 83 + if len(parts) != 2 { 84 + http.Error(w, "invalid range", http.StatusBadRequest) 85 + return 86 + } 87 + 88 + start, err1 := strconv.ParseInt(parts[0], 10, 64) 89 + var end int64 = size - 1 90 + var err2 error 91 + if parts[1] != "" { 92 + end, err2 = strconv.ParseInt(parts[1], 10, 64) 93 + if err2 != nil { 94 + end = size - 1 95 + } 96 + } 97 + if err1 != nil || start < 0 || start > end || start >= size { 98 + // RFC 7233: respond 416 Range Not Satisfiable and include Content-Range */size 99 + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size)) 100 + http.Error(w, "Requested Range Not Satisfiable", http.StatusRequestedRangeNotSatisfiable) 101 + return 102 + } 103 + 104 + if end >= size { 105 + end = size - 1 106 + } 107 + length := end - start + 1 108 + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size)) 109 + w.Header().Set("Content-Length", strconv.FormatInt(length, 10)) 110 + w.WriteHeader(http.StatusPartialContent) 111 + if _, err := io.Copy(w, bytes.NewReader(blob[start:end+1])); err != nil { 112 + log.Printf("DEBUG: handleVideo - error streaming ranged blob cid=%s: %v", cid, err) 113 + } 114 + return 115 + } 116 + 117 + // Fallback: proxy to public IPFS gateway (existing behavior) 118 + gateway := "https://dweb.link/ipfs/" 119 + url := gateway + cid 120 + 121 + // Build request to gateway. Forward Range header if present so gateway can respond with 206. 122 + req, err := http.NewRequest("GET", url, nil) 123 + if err != nil { 124 + log.Printf("DEBUG: handleVideo - new request error for %s: %v", url, err) 125 + http.Error(w, "failed to fetch media", http.StatusBadGateway) 126 + return 127 + } 128 + if rh := r.Header.Get("Range"); rh != "" { 129 + req.Header.Set("Range", rh) 130 + } 131 + 132 + resp, err := http.DefaultClient.Do(req) 133 + if err != nil { 134 + log.Printf("DEBUG: handleVideo - error fetching %s: %v", url, err) 135 + http.Error(w, "failed to fetch media", http.StatusBadGateway) 136 + return 137 + } 138 + defer resp.Body.Close() 139 + 140 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { 141 + log.Printf("DEBUG: handleVideo - gateway returned status %d for %s", resp.StatusCode, url) 142 + if resp.StatusCode == http.StatusNotFound { 143 + http.Error(w, "media not found", http.StatusNotFound) 144 + } else { 145 + http.Error(w, "failed to fetch media", http.StatusBadGateway) 146 + } 147 + return 148 + } 149 + 150 + // Forward relevant headers 151 + w.Header().Set("Accept-Ranges", "bytes") 152 + if ct := resp.Header.Get("Content-Type"); ct != "" { 153 + w.Header().Set("Content-Type", ct) 154 + } else { 155 + w.Header().Set("Content-Type", "video/mp4") 156 + } 157 + if cr := resp.Header.Get("Content-Range"); cr != "" { 158 + w.Header().Set("Content-Range", cr) 159 + } 160 + if cl := resp.Header.Get("Content-Length"); cl != "" { 161 + w.Header().Set("Content-Length", cl) 162 + } 163 + 164 + // Mirror status code (200 or 206) 165 + w.WriteHeader(resp.StatusCode) 166 + 167 + // Stream response body directly to client 168 + if _, err := io.Copy(w, resp.Body); err != nil { 169 + log.Printf("DEBUG: handleVideo - error streaming body for %s: %v", url, err) 170 + } 171 + }
+101
handlers_post.go
··· 1 + package main 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + 7 + "github.com/bluesky-social/indigo/api/atproto" 8 + bsky "github.com/bluesky-social/indigo/api/bsky" 9 + "github.com/bluesky-social/indigo/lex/util" 10 + ) 11 + 12 + func handlePostStatus(w http.ResponseWriter, r *http.Request) { 13 + c, didStr, err := getClientFromSession(r.Context(), r) 14 + if err != nil { 15 + http.Redirect(w, r, "/signin", http.StatusFound) 16 + return 17 + } 18 + 19 + if r.Method == http.MethodPost { 20 + status := r.FormValue("status") 21 + if status != "" { 22 + post := &bsky.FeedPost{Text: status} 23 + if _, err := atproto.RepoCreateRecord(r.Context(), c, &atproto.RepoCreateRecord_Input{ 24 + Collection: "app.bsky.feed.post", 25 + Repo: didStr, 26 + Record: &util.LexiconTypeDecoder{Val: post}, 27 + }); err != nil { 28 + log.Printf("DEBUG: handlePostStatus - Error creating post: %v", err) 29 + http.Error(w, err.Error(), http.StatusInternalServerError) 30 + return 31 + } 32 + } 33 + http.Redirect(w, r, "/timeline", http.StatusFound) 34 + return 35 + } 36 + 37 + profile, err := fetchProfile(r.Context(), c, didStr) 38 + if err != nil { 39 + http.Error(w, err.Error(), http.StatusInternalServerError) 40 + return 41 + } 42 + 43 + followsList := fetchFollows(r.Context(), c, didStr, 50) 44 + 45 + data := PostStatusPageData{ 46 + Title: "What are you doing? - Tuiter 2006", 47 + CurrentUser: profile, 48 + Profile: profile, 49 + Follows: followsList, 50 + SignedIn: profile, 51 + } 52 + 53 + executeTemplate(w, "post-status.html", data) 54 + } 55 + 56 + func handleTimelinePost(w http.ResponseWriter, r *http.Request) { 57 + if r.Method != http.MethodPost { 58 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 59 + return 60 + } 61 + 62 + c, didStr, err := getClientFromSession(r.Context(), r) 63 + if err != nil { 64 + http.Error(w, "not logged in", http.StatusUnauthorized) 65 + return 66 + } 67 + 68 + status := r.FormValue("status") 69 + if status == "" { 70 + http.Error(w, "Status cannot be empty", http.StatusBadRequest) 71 + return 72 + } 73 + 74 + post := &bsky.FeedPost{Text: status} 75 + resp, err := atproto.RepoCreateRecord(r.Context(), c, &atproto.RepoCreateRecord_Input{ 76 + Collection: "app.bsky.feed.post", 77 + Repo: didStr, 78 + Record: &util.LexiconTypeDecoder{Val: post}, 79 + }) 80 + if err != nil { 81 + http.Error(w, err.Error(), http.StatusInternalServerError) 82 + return 83 + } 84 + log.Println("Created post:", resp.Uri) 85 + 86 + w.Header().Set("Content-Type", "text/html") 87 + timeline, err := bsky.FeedGetTimeline(r.Context(), c, "", "", 50) 88 + if err != nil { 89 + http.Error(w, "Failed to load timeline", http.StatusInternalServerError) 90 + return 91 + } 92 + 93 + // fetch signed-in profile for template context 94 + signedInProfile, _ := fetchProfile(r.Context(), c, didStr) 95 + 96 + data := TimelinePartialData{Timeline: timeline, Posts: PostsList{Items: timeline.Feed, Cursor: getCursorFromTimeline(timeline)}, SignedIn: signedInProfile} 97 + if err := tpl.ExecuteTemplate(w, "timeline_posts_partial.html", data); err != nil { 98 + http.Error(w, "Internal server error", http.StatusInternalServerError) 99 + return 100 + } 101 + }
+186
handlers_timeline.go
··· 1 + package main 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + 7 + "github.com/bluesky-social/indigo/api/atproto" 8 + bsky "github.com/bluesky-social/indigo/api/bsky" 9 + "github.com/bluesky-social/indigo/lex/util" 10 + ) 11 + 12 + func handleTimeline(w http.ResponseWriter, r *http.Request) { 13 + log.Printf("DEBUG: handleTimeline called - Method: %s, URL: %s", r.Method, r.URL.Path) 14 + c, didStr, err := getClientFromSession(r.Context(), r) 15 + if err != nil { 16 + http.Redirect(w, r, "/signin", http.StatusFound) 17 + return 18 + } 19 + 20 + profile, err := fetchProfile(r.Context(), c, didStr) 21 + if err != nil { 22 + http.Error(w, err.Error(), http.StatusInternalServerError) 23 + return 24 + } 25 + 26 + timeline, err := bsky.FeedGetTimeline(r.Context(), c, "", "", 50) 27 + if err != nil { 28 + http.Error(w, err.Error(), http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + followsList := fetchFollows(r.Context(), c, didStr, 50) 33 + 34 + // Collect all unique parent URIs referenced in the timeline so we can batch-fetch them once 35 + parentURIsSet := map[string]struct{}{} 36 + if timeline != nil && timeline.Feed != nil { 37 + for _, fv := range timeline.Feed { 38 + if fv == nil || fv.Post == nil { 39 + continue 40 + } 41 + if uri := extractReplyParentURI(fv.Post); uri != "" { 42 + parentURIsSet[uri] = struct{}{} 43 + } 44 + // also include any root refs from the post record if present 45 + chain := GetReplyChainInfos(fv.Post) 46 + for _, pi := range chain { 47 + if pi.Uri != "" { 48 + parentURIsSet[pi.Uri] = struct{}{} 49 + } 50 + } 51 + } 52 + } 53 + 54 + var parentURIs []string 55 + for u := range parentURIsSet { 56 + parentURIs = append(parentURIs, u) 57 + } 58 + 59 + // Batch fetch parent posts (API limits 25 URIs per request) 60 + parentPreviews := map[string]ParentInfo{} 61 + if len(parentURIs) > 0 { 62 + const batchSize = 25 63 + for i := 0; i < len(parentURIs); i += batchSize { 64 + end := i + batchSize 65 + if end > len(parentURIs) { 66 + end = len(parentURIs) 67 + } 68 + batch := parentURIs[i:end] 69 + postsMap, err := fetchPostsBatch(r.Context(), c, batch) 70 + if err != nil { 71 + log.Printf("DEBUG: handleTimeline - fetchPostsBatch error: %v", err) 72 + continue 73 + } 74 + for uri, pv := range postsMap { 75 + if pv == nil { 76 + continue 77 + } 78 + pi := ParentInfo{Uri: uri} 79 + // fill author handle/name if present 80 + if pv.Author != nil { 81 + if pv.Author.DisplayName != nil && *pv.Author.DisplayName != "" { 82 + pi.AuthorName = *pv.Author.DisplayName 83 + } else if pv.Author.Handle != "" { 84 + pi.AuthorName = pv.Author.Handle 85 + } 86 + if pv.Author.Handle != "" { 87 + pi.AuthorHandle = pv.Author.Handle 88 + } 89 + // populate avatar if available 90 + if pv.Author.Avatar != nil { 91 + pi.Avatar = *pv.Author.Avatar 92 + } 93 + } 94 + // fill text 95 + pi.Text = getPostText(pv.Record) 96 + // link URL and posted time if available 97 + if pv.Uri != "" { 98 + pi.PostURL = getPostURL(pv) 99 + } 100 + pi.IndexedAt = pv.IndexedAt 101 + if m := GetPostMedia(pv); m != nil { 102 + pi.Media = m 103 + } 104 + // like count if available 105 + if pv.LikeCount != nil { 106 + pi.LikeCount = int(*pv.LikeCount) 107 + } 108 + // viewer state: whether the signed-in viewer liked this post 109 + pi.IsFav = getIsFav(pv) 110 + parentPreviews[uri] = pi 111 + } 112 + } 113 + } 114 + 115 + postsList := PostsList{Items: timeline.Feed, Cursor: getCursorFromTimeline(timeline), ParentPreviews: parentPreviews} 116 + 117 + data := TimelinePageData{ 118 + Title: "Timeline - Tuiter 2006", 119 + CurrentUser: profile, 120 + Profile: profile, 121 + Timeline: timeline, 122 + Follows: followsList, 123 + Posts: postsList, 124 + PostBoxHandle: "", 125 + // SignedIn should point to the logged-in profile 126 + SignedIn: profile, 127 + } 128 + 129 + executeTemplate(w, "timeline.html", data) 130 + } 131 + 132 + func handleReply(w http.ResponseWriter, r *http.Request) { 133 + log.Printf("DEBUG: handleReply called - Method: %s, URL: %s", r.Method, r.URL.Path) 134 + if r.Method != http.MethodPost { 135 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 136 + return 137 + } 138 + 139 + c, didStr, err := getClientFromSession(r.Context(), r) 140 + if err != nil { 141 + http.Redirect(w, r, "/signin", http.StatusFound) 142 + return 143 + } 144 + 145 + if err := r.ParseForm(); err != nil { 146 + http.Error(w, "Failed to parse form data", http.StatusInternalServerError) 147 + return 148 + } 149 + 150 + replyTo := r.FormValue("reply-to") 151 + status := r.FormValue("status") 152 + if replyTo == "" || status == "" { 153 + http.Error(w, "reply-to and status are required", http.StatusBadRequest) 154 + return 155 + } 156 + 157 + postsView, err := bsky.FeedGetPosts(r.Context(), c, []string{replyTo}) 158 + if err != nil || len(postsView.Posts) == 0 { 159 + log.Printf("DEBUG: handleReply - Error fetching original post: %v", err) 160 + http.Error(w, "Original post not found", http.StatusNotFound) 161 + return 162 + } 163 + 164 + postView := postsView.Posts[0] 165 + post := &bsky.FeedPost{ 166 + Text: status, 167 + CreatedAt: "", 168 + Reply: &bsky.FeedPost_ReplyRef{ 169 + Root: &atproto.RepoStrongRef{Uri: postView.Uri, Cid: postView.Cid}, 170 + Parent: &atproto.RepoStrongRef{Uri: postView.Uri, Cid: postView.Cid}, 171 + }, 172 + } 173 + 174 + resp, err := atproto.RepoCreateRecord(r.Context(), c, &atproto.RepoCreateRecord_Input{ 175 + Collection: "app.bsky.feed.post", 176 + Repo: didStr, 177 + Record: &util.LexiconTypeDecoder{Val: post}, 178 + }) 179 + if err != nil { 180 + http.Error(w, "Failed to create reply: "+err.Error(), http.StatusInternalServerError) 181 + return 182 + } 183 + 184 + log.Println("Created reply:", resp.Uri) 185 + http.Redirect(w, r, "/post?uri="+replyTo, http.StatusFound) 186 + }
+156
handlers_view.go
··· 1 + package main 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "strings" 7 + 8 + bsky "github.com/bluesky-social/indigo/api/bsky" 9 + ) 10 + 11 + func handlePost(w http.ResponseWriter, r *http.Request) { 12 + c, didStr, err := getClientFromSession(r.Context(), r) 13 + if err != nil { 14 + http.Redirect(w, r, "/signin", http.StatusFound) 15 + return 16 + } 17 + 18 + data, err := preparePostPageData(r.Context(), r, c, didStr) 19 + if err != nil { 20 + http.Error(w, "Failed to prepare post page: "+err.Error(), http.StatusInternalServerError) 21 + return 22 + } 23 + 24 + executeTemplate(w, "post.html", data) 25 + } 26 + 27 + func handleProfile(w http.ResponseWriter, r *http.Request) { 28 + c, myDid, err := getClientFromSession(r.Context(), r) 29 + if err != nil { 30 + http.Redirect(w, r, "/signin", http.StatusFound) 31 + return 32 + } 33 + 34 + path := strings.TrimPrefix(r.URL.Path, "/profile/") 35 + profileHandle := path 36 + if profileHandle == "" { 37 + profileHandle = myDid 38 + } 39 + 40 + profileView, err := fetchProfile(r.Context(), c, profileHandle) 41 + if err != nil { 42 + http.Error(w, err.Error(), http.StatusInternalServerError) 43 + return 44 + } 45 + if profileView == nil { 46 + http.Error(w, "Profile not found", http.StatusNotFound) 47 + return 48 + } 49 + 50 + authorFeed, err := bsky.FeedGetAuthorFeed(r.Context(), c, profileView.Did, "", "", false, 50) 51 + if err != nil { 52 + http.Error(w, err.Error(), http.StatusInternalServerError) 53 + return 54 + } 55 + 56 + myProfile, err := fetchProfile(r.Context(), c, myDid) 57 + if err != nil { 58 + http.Error(w, err.Error(), http.StatusInternalServerError) 59 + return 60 + } 61 + 62 + followsList := fetchFollows(r.Context(), c, profileView.Did, 50) 63 + 64 + postBoxHandle := "" 65 + if profileView.Handle != "" { 66 + postBoxHandle = profileView.Handle 67 + } 68 + 69 + // Collect parent URIs from the author's feed and batch-fetch previews 70 + parentURIsSet := map[string]struct{}{} 71 + if authorFeed != nil && authorFeed.Feed != nil { 72 + for _, fv := range authorFeed.Feed { 73 + if fv == nil || fv.Post == nil { 74 + continue 75 + } 76 + if uri := extractReplyParentURI(fv.Post); uri != "" { 77 + parentURIsSet[uri] = struct{}{} 78 + } 79 + chain := GetReplyChainInfos(fv.Post) 80 + for _, pi := range chain { 81 + if pi.Uri != "" { 82 + parentURIsSet[pi.Uri] = struct{}{} 83 + } 84 + } 85 + } 86 + } 87 + 88 + var parentURIs []string 89 + for u := range parentURIsSet { 90 + parentURIs = append(parentURIs, u) 91 + } 92 + 93 + parentPreviews := map[string]ParentInfo{} 94 + if len(parentURIs) > 0 { 95 + const batchSize = 25 96 + for i := 0; i < len(parentURIs); i += batchSize { 97 + end := i + batchSize 98 + if end > len(parentURIs) { 99 + end = len(parentURIs) 100 + } 101 + batch := parentURIs[i:end] 102 + postsMap, err := fetchPostsBatch(r.Context(), c, batch) 103 + if err != nil { 104 + log.Printf("DEBUG: handleProfile - fetchPostsBatch error: %v", err) 105 + continue 106 + } 107 + for uri, pv := range postsMap { 108 + if pv == nil { 109 + continue 110 + } 111 + pi := ParentInfo{Uri: uri} 112 + if pv.Author != nil { 113 + if pv.Author.DisplayName != nil && *pv.Author.DisplayName != "" { 114 + pi.AuthorName = *pv.Author.DisplayName 115 + } else if pv.Author.Handle != "" { 116 + pi.AuthorName = pv.Author.Handle 117 + } 118 + if pv.Author.Handle != "" { 119 + pi.AuthorHandle = pv.Author.Handle 120 + } 121 + if pv.Author.Avatar != nil { 122 + pi.Avatar = *pv.Author.Avatar 123 + } 124 + } 125 + pi.Text = getPostText(pv.Record) 126 + if pv.Uri != "" { 127 + pi.PostURL = getPostURL(pv) 128 + } 129 + pi.IndexedAt = pv.IndexedAt 130 + // populate media preview if present 131 + if m := GetPostMedia(pv); m != nil { 132 + pi.Media = m 133 + } 134 + // like count if available 135 + if pv.LikeCount != nil { 136 + pi.LikeCount = int(*pv.LikeCount) 137 + } 138 + pi.IsFav = getIsFav(pv) 139 + parentPreviews[uri] = pi 140 + } 141 + } 142 + } 143 + 144 + data := ProfilePageData{ 145 + Title: "Profile - Tuiter 2006", 146 + Profile: profileView, 147 + Feed: authorFeed, 148 + Follows: followsList, 149 + Posts: PostsList{Items: authorFeed.Feed, Cursor: getCursorFromAuthorFeed(authorFeed), ParentPreviews: parentPreviews}, 150 + PostBoxHandle: postBoxHandle, 151 + // provide the signed-in profile explicitly 152 + SignedIn: myProfile, 153 + } 154 + 155 + executeTemplate(w, "profile.html", data) 156 + }
+840
helpers.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "net/http" 8 + "regexp" 9 + "strings" 10 + 11 + "github.com/bluesky-social/indigo/api/atproto" 12 + bsky "github.com/bluesky-social/indigo/api/bsky" 13 + "github.com/bluesky-social/indigo/atproto/client" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/bluesky-social/indigo/lex/util" 16 + ) 17 + 18 + // PostsList is a small, type-safe wrapper passed to templates to render lists of posts 19 + // and provide optional pagination cursor in a single place. 20 + type PostsList struct { 21 + Items []*bsky.FeedDefs_FeedViewPost 22 + Cursor string 23 + // ParentPreviews holds pre-fetched ParentInfo keyed by parent URI. Handlers should populate 24 + // this map by collecting all reply-ref URIs and calling fetchPostsBatch once. 25 + ParentPreviews map[string]ParentInfo 26 + } 27 + 28 + func getPostText(record *util.LexiconTypeDecoder) string { 29 + if record == nil || record.Val == nil { 30 + log.Printf("DEBUG: getPostText - record or record.Val is nil") 31 + return "[Post content unavailable]" 32 + } 33 + if post, ok := record.Val.(*bsky.FeedPost); ok && post != nil { 34 + return post.Text 35 + } 36 + log.Printf("DEBUG: getPostText - unable to extract text from record type: %T", record.Val) 37 + return "[Post content unavailable]" 38 + } 39 + 40 + func resolveHandleToDID(ctx context.Context, c *client.APIClient, identifier string) (string, error) { 41 + if strings.HasPrefix(identifier, "did:") { 42 + return identifier, nil 43 + } 44 + profile, err := bsky.ActorGetProfile(ctx, c, identifier) 45 + if err != nil { 46 + log.Printf("DEBUG: resolveHandleToDID - error resolving handle %s: %v", identifier, err) 47 + return "", err 48 + } 49 + return profile.Did, nil 50 + } 51 + 52 + func executeTemplate(w http.ResponseWriter, templateName string, data interface{}) { 53 + // Ensure templates that reference .SignedIn won't panic when handlers pass nil 54 + if data == nil { 55 + // minimal typed wrapper with SignedIn nil 56 + data = struct { 57 + SignedIn *bsky.ActorDefs_ProfileViewDetailed 58 + }{} 59 + } 60 + if err := tpl.ExecuteTemplate(w, templateName, data); err != nil { 61 + log.Printf("Template execution error for %s: %v", templateName, err) 62 + http.Error(w, "Internal server error", http.StatusInternalServerError) 63 + } 64 + } 65 + 66 + func getIntFromProfile(obj interface{}, keys []string) int { 67 + if obj == nil { 68 + return 0 69 + } 70 + switch p := obj.(type) { 71 + case *bsky.ActorDefs_ProfileViewDetailed: 72 + if p == nil { 73 + return 0 74 + } 75 + for _, k := range keys { 76 + switch k { 77 + case "followersCount", "followers_count", "followers": 78 + if p.FollowersCount != nil { 79 + return int(*p.FollowersCount) 80 + } 81 + case "followsCount", "follows_count", "following", "follows": 82 + if p.FollowsCount != nil { 83 + return int(*p.FollowsCount) 84 + } 85 + case "postsCount", "posts_count", "posts": 86 + if p.PostsCount != nil { 87 + return int(*p.PostsCount) 88 + } 89 + } 90 + } 91 + default: 92 + return 0 93 + } 94 + return 0 95 + } 96 + 97 + func getDisplayNameFromProfile(obj interface{}) string { 98 + if obj == nil { 99 + return "" 100 + } 101 + switch p := obj.(type) { 102 + case *bsky.ActorDefs_ProfileViewDetailed: 103 + if p == nil { 104 + return "" 105 + } 106 + if p.DisplayName != nil && *p.DisplayName != "" { 107 + return *p.DisplayName 108 + } 109 + if p.Handle != "" { 110 + return p.Handle 111 + } 112 + return "" 113 + case *bsky.ActorDefs_ProfileView: 114 + if p == nil { 115 + return "" 116 + } 117 + if p.DisplayName != nil && *p.DisplayName != "" { 118 + return *p.DisplayName 119 + } 120 + if p.Handle != "" { 121 + return p.Handle 122 + } 123 + return "" 124 + case *bsky.ActorDefs_ProfileViewBasic: 125 + if p == nil { 126 + return "" 127 + } 128 + if p.DisplayName != nil && *p.DisplayName != "" { 129 + return *p.DisplayName 130 + } 131 + if p.Handle != "" { 132 + return p.Handle 133 + } 134 + return "" 135 + default: 136 + return "" 137 + } 138 + } 139 + 140 + func getClientFromSession(ctx context.Context, r *http.Request) (*client.APIClient, string, error) { 141 + session, _ := store.Get(r, sessionName) 142 + didStr, ok := session.Values["did"].(string) 143 + if !ok || didStr == "" { 144 + return nil, "", fmt.Errorf("not logged in") 145 + } 146 + sessionID, ok := session.Values["session_id"].(string) 147 + if !ok || sessionID == "" { 148 + return nil, "", fmt.Errorf("not logged in") 149 + } 150 + did, err := syntax.ParseDID(didStr) 151 + if err != nil { 152 + return nil, "", err 153 + } 154 + sess, err := oauthApp.ResumeSession(ctx, did, sessionID) 155 + if err != nil { 156 + return nil, "", err 157 + } 158 + return sess.APIClient(), didStr, nil 159 + } 160 + 161 + func fetchFollows(ctx context.Context, c *client.APIClient, did string, limit int64) []*bsky.ActorDefs_ProfileView { 162 + follows, err := bsky.GraphGetFollows(ctx, c, did, "", limit) 163 + if err != nil { 164 + log.Printf("DEBUG: fetchFollows - error fetching follows for %s: %v", did, err) 165 + return nil 166 + } 167 + if follows == nil || follows.Follows == nil { 168 + return nil 169 + } 170 + return follows.Follows 171 + } 172 + 173 + func fetchProfile(ctx context.Context, c *client.APIClient, idOrHandle string) (*bsky.ActorDefs_ProfileViewDetailed, error) { 174 + if idOrHandle == "" { 175 + return nil, fmt.Errorf("empty identifier") 176 + } 177 + if !strings.HasPrefix(idOrHandle, "did:") { 178 + resolved, err := resolveHandleToDID(ctx, c, idOrHandle) 179 + if err != nil { 180 + return nil, err 181 + } 182 + idOrHandle = resolved 183 + } 184 + profile, err := bsky.ActorGetProfile(ctx, c, idOrHandle) 185 + if err != nil { 186 + return nil, err 187 + } 188 + return profile, nil 189 + } 190 + 191 + func getCursorFromTimeline(t *bsky.FeedGetTimeline_Output) string { 192 + if t == nil { 193 + return "" 194 + } 195 + if t.Cursor != nil { 196 + return *t.Cursor 197 + } 198 + return "" 199 + } 200 + 201 + func getCursorFromAuthorFeed(f *bsky.FeedGetAuthorFeed_Output) string { 202 + if f == nil { 203 + return "" 204 + } 205 + if f.Cursor != nil { 206 + return *f.Cursor 207 + } 208 + return "" 209 + } 210 + 211 + func getCursorFromAny(v interface{}) string { 212 + switch t := v.(type) { 213 + case *bsky.FeedGetTimeline_Output: 214 + return getCursorFromTimeline(t) 215 + case *bsky.FeedGetAuthorFeed_Output: 216 + return getCursorFromAuthorFeed(t) 217 + default: 218 + return "" 219 + } 220 + } 221 + 222 + func getProfileURL(actor interface{}) string { 223 + switch a := actor.(type) { 224 + case *bsky.ActorDefs_ProfileView: 225 + if a != nil && a.Handle != "" { 226 + return "/profile/" + a.Handle 227 + } 228 + case *bsky.ActorDefs_ProfileViewBasic: 229 + if a != nil && a.Handle != "" { 230 + return "/profile/" + a.Handle 231 + } 232 + case *bsky.ActorDefs_ProfileViewDetailed: 233 + if a != nil && a.Handle != "" { 234 + return "/profile/" + a.Handle 235 + } 236 + } 237 + return "#" 238 + } 239 + 240 + func getPostURL(post *bsky.FeedDefs_PostView) string { 241 + if post != nil && post.Author != nil && post.Author.Handle != "" && post.Uri != "" { 242 + uriParts := strings.Split(post.Uri, "/") 243 + if len(uriParts) >= 4 { 244 + postID := uriParts[len(uriParts)-1] 245 + return "/post/" + post.Author.Handle + "/" + postID 246 + } 247 + } 248 + return "#" 249 + } 250 + 251 + func getFollowingCount(actor interface{}) int { 252 + return getIntFromProfile(actor, []string{"followsCount", "follows_count", "following", "follows"}) 253 + } 254 + func getFollowersCount(actor interface{}) int { 255 + return getIntFromProfile(actor, []string{"followersCount", "followers_count", "followers"}) 256 + } 257 + func getPostsCount(actor interface{}) int { 258 + return getIntFromProfile(actor, []string{"postsCount", "posts_count", "posts"}) 259 + } 260 + 261 + // Post type helpers 262 + 263 + type PostType int 264 + 265 + const ( 266 + PostTypeAuthored PostType = iota 267 + PostTypeRetweet 268 + PostTypeQuote 269 + ) 270 + 271 + func GetPostType(fvp *bsky.FeedDefs_FeedViewPost) PostType { 272 + if fvp == nil || fvp.Post == nil { 273 + return PostTypeAuthored 274 + } 275 + if fvp.Reason != nil && fvp.Reason.FeedDefs_ReasonRepost != nil { 276 + return PostTypeRetweet 277 + } 278 + if fvp.Post.Embed != nil && fvp.Post.Embed.EmbedRecord_View != nil { 279 + return PostTypeQuote 280 + } 281 + return PostTypeAuthored 282 + } 283 + 284 + func GetPostPrefix(fvp *bsky.FeedDefs_FeedViewPost) string { 285 + switch GetPostType(fvp) { 286 + case PostTypeRetweet: 287 + return "RT" 288 + case PostTypeQuote: 289 + return "QT" 290 + default: 291 + return "" 292 + } 293 + } 294 + 295 + // Convenience boolean helpers for templates 296 + func IsPostRetweet(item interface{}) bool { 297 + switch it := item.(type) { 298 + case *bsky.FeedDefs_FeedViewPost: 299 + return GetPostType(it) == PostTypeRetweet 300 + default: 301 + return false 302 + } 303 + } 304 + 305 + func IsPostQuote(item interface{}) bool { 306 + switch it := item.(type) { 307 + case *bsky.FeedDefs_FeedViewPost: 308 + return GetPostType(it) == PostTypeQuote 309 + default: 310 + return false 311 + } 312 + } 313 + 314 + // Embed helpers 315 + 316 + type EmbedRecordViewRecord struct { 317 + Author interface{} 318 + Value *util.LexiconTypeDecoder 319 + } 320 + 321 + func GetEmbedRecord(post *bsky.FeedDefs_PostView) *EmbedRecordViewRecord { 322 + if post == nil || post.Embed == nil || post.Embed.EmbedRecord_View == nil || post.Embed.EmbedRecord_View.Record == nil { 323 + return nil 324 + } 325 + recordWrapper := post.Embed.EmbedRecord_View.Record 326 + if recordWrapper.EmbedRecord_ViewRecord == nil { 327 + return nil 328 + } 329 + rr := recordWrapper.EmbedRecord_ViewRecord 330 + return &EmbedRecordViewRecord{Author: rr.Author, Value: rr.Value} 331 + } 332 + 333 + type EmbedTemplateContext struct { 334 + Parent *bsky.FeedDefs_PostView 335 + Embed *EmbedRecordViewRecord 336 + } 337 + 338 + func embedContext(parent *bsky.FeedDefs_PostView, embed *EmbedRecordViewRecord) *EmbedTemplateContext { 339 + return &EmbedTemplateContext{Parent: parent, Embed: embed} 340 + } 341 + 342 + // Small avatar/banner helpers to keep templates simple and avoid repeating conditionals. 343 + func AvatarURL(actor interface{}) string { 344 + switch a := actor.(type) { 345 + case *bsky.ActorDefs_ProfileView: 346 + if a != nil && a.Avatar != nil { 347 + return *a.Avatar 348 + } 349 + case *bsky.ActorDefs_ProfileViewBasic: 350 + if a != nil && a.Avatar != nil { 351 + return *a.Avatar 352 + } 353 + case *bsky.ActorDefs_ProfileViewDetailed: 354 + if a != nil && a.Avatar != nil { 355 + return *a.Avatar 356 + } 357 + case *bsky.FeedDefs_PostView: 358 + // allow passing a PostView directly 359 + if a != nil && a.Author != nil && a.Author.Avatar != nil { 360 + return *a.Author.Avatar 361 + } 362 + } 363 + return "" 364 + } 365 + 366 + func HasAvatar(actor interface{}) bool { 367 + return AvatarURL(actor) != "" 368 + } 369 + 370 + func BannerURL(actor interface{}) string { 371 + switch a := actor.(type) { 372 + case *bsky.ActorDefs_ProfileViewDetailed: 373 + if a != nil && a.Banner != nil { 374 + return *a.Banner 375 + } 376 + } 377 + return "" 378 + } 379 + 380 + // Post box helpers 381 + func PostBoxInitial(handle string) string { 382 + if handle == "" { 383 + return "" 384 + } 385 + return "@" + handle + " " 386 + } 387 + 388 + func PostBoxPlaceholder(handle string) string { 389 + if handle == "" { 390 + return "What are you doing?" 391 + } 392 + return "Mention " + handle 393 + } 394 + 395 + // PostVM is a small, template-friendly view model for posts. 396 + type PostVM struct { 397 + AuthorDisplayName string 398 + AuthorHandle string 399 + AuthorAvatar string 400 + Text string 401 + PostURL string 402 + IndexedAt string 403 + ReplyCount int 404 + IsQuote bool 405 + IsRetweet bool 406 + ParentPost *bsky.FeedDefs_PostView 407 + EmbedRecord *EmbedRecordViewRecord 408 + Raw *bsky.FeedDefs_FeedViewPost // keep raw for advanced helpers if needed 409 + } 410 + 411 + // BuildPostVM converts a typed feed view post into a PostVM for templates. 412 + func BuildPostVM(ctx context.Context, item *bsky.FeedDefs_FeedViewPost) *PostVM { 413 + if item == nil || item.Post == nil { 414 + return nil 415 + } 416 + post := item.Post 417 + vm := &PostVM{Raw: item} 418 + // author 419 + if post.Author != nil { 420 + if post.Author.Handle != "" { 421 + vm.AuthorHandle = post.Author.Handle 422 + } 423 + if post.Author.DisplayName != nil && *post.Author.DisplayName != "" { 424 + vm.AuthorDisplayName = *post.Author.DisplayName 425 + } else if post.Author.Handle != "" { 426 + vm.AuthorDisplayName = post.Author.Handle 427 + } 428 + if post.Author.Avatar != nil && *post.Author.Avatar != "" { 429 + vm.AuthorAvatar = *post.Author.Avatar 430 + } 431 + } 432 + // text and metadata 433 + vm.Text = getPostText(post.Record) 434 + vm.PostURL = getPostURL(post) 435 + vm.IndexedAt = post.IndexedAt 436 + if post.ReplyCount != nil { 437 + vm.ReplyCount = int(*post.ReplyCount) 438 + } 439 + // embed / type 440 + vm.IsRetweet = item.Reason != nil && item.Reason.FeedDefs_ReasonRepost != nil 441 + vm.IsQuote = post.Embed != nil && post.Embed.EmbedRecord_View != nil 442 + // parent and embed record 443 + if post != nil { 444 + vm.ParentPost = post 445 + } 446 + if vm.IsQuote { 447 + vm.EmbedRecord = GetEmbedRecord(post) 448 + } 449 + return vm 450 + } 451 + 452 + // Helper wrapper exposed to templates: convert interface{} (feed item) to *PostVM 453 + func buildPostVMForTemplate(item interface{}) *PostVM { 454 + switch it := item.(type) { 455 + case *bsky.FeedDefs_FeedViewPost: 456 + return BuildPostVM(context.Background(), it) 457 + default: 458 + log.Printf("DEBUG: buildPostVMForTemplate - unexpected type %T", item) 459 + return nil 460 + } 461 + } 462 + 463 + // Add helper to expose LikeCount safely to templates. 464 + func getLikeCount(post *bsky.FeedDefs_PostView) int { 465 + if post == nil || post.LikeCount == nil { 466 + return 0 467 + } 468 + return int(*post.LikeCount) 469 + } 470 + 471 + // MakeElementID converts an at:// URI into a safe DOM id (alphanumeric and dashes) 472 + func MakeElementID(uri string) string { 473 + if uri == "" { 474 + return "" 475 + } 476 + // remove scheme prefix if present 477 + uri = strings.TrimPrefix(uri, "at://") 478 + // replace non-alphanumeric characters with dash 479 + re := regexp.MustCompile(`[^a-zA-Z0-9]+`) 480 + id := re.ReplaceAllString(uri, "-") 481 + // ensure doesn't start with digit-only? keep as-is 482 + return "post-" + strings.Trim(id, "-") 483 + } 484 + 485 + // ThreadNodeWrapper bundles a ThreadViewPost with the ViewedURI so templates can access both typed values safely. 486 + type ThreadNodeWrapper struct { 487 + Post *bsky.FeedDefs_ThreadViewPost 488 + ViewedURI string 489 + } 490 + 491 + // wrapThread is a template helper that wraps a ThreadViewPost with the current viewed URI. 492 + func wrapThread(n *bsky.FeedDefs_ThreadViewPost, viewedURI string) ThreadNodeWrapper { 493 + return ThreadNodeWrapper{Post: n, ViewedURI: viewedURI} 494 + } 495 + 496 + // HasItems is a tiny helper to ask if a PostsList has items; keeps templates readable. 497 + func HasItems(pl *PostsList) bool { 498 + return pl != nil && len(pl.Items) > 0 499 + } 500 + 501 + // Media view models for templates 502 + type ImageVM struct { 503 + Thumb string 504 + Full string 505 + Alt string 506 + } 507 + 508 + type VideoVM struct { 509 + Thumb string 510 + Cid string 511 + Playlist string 512 + OwnerDid string 513 + } 514 + 515 + type ExternalVM struct { 516 + Title string 517 + Description string 518 + Thumb string 519 + Uri string 520 + } 521 + 522 + type MediaVM struct { 523 + Images []ImageVM 524 + Video *VideoVM 525 + External *ExternalVM 526 + } 527 + 528 + // GetPostMedia inspects a post's embed fields and returns a small, typed 529 + // MediaVM suitable for templates. It supports images, videos (thumbnail only) 530 + // and external link previews. Returns nil if no media present. 531 + func GetPostMedia(post *bsky.FeedDefs_PostView) *MediaVM { 532 + if post == nil || post.Embed == nil { 533 + return nil 534 + } 535 + m := &MediaVM{} 536 + 537 + // top-level images 538 + if post.Embed.EmbedImages_View != nil && post.Embed.EmbedImages_View.Images != nil { 539 + for _, im := range post.Embed.EmbedImages_View.Images { 540 + if im == nil { 541 + continue 542 + } 543 + m.Images = append(m.Images, ImageVM{Thumb: im.Thumb, Full: im.Fullsize, Alt: im.Alt}) 544 + } 545 + } 546 + 547 + // recordWithMedia (nested media inside an embedded record) 548 + if post.Embed.EmbedRecordWithMedia_View != nil && post.Embed.EmbedRecordWithMedia_View.Media != nil { 549 + mm := post.Embed.EmbedRecordWithMedia_View.Media 550 + if mm.EmbedImages_View != nil && mm.EmbedImages_View.Images != nil { 551 + for _, im := range mm.EmbedImages_View.Images { 552 + if im == nil { 553 + continue 554 + } 555 + m.Images = append(m.Images, ImageVM{Thumb: im.Thumb, Full: im.Fullsize, Alt: im.Alt}) 556 + } 557 + } 558 + if mm.EmbedVideo_View != nil { 559 + v := mm.EmbedVideo_View 560 + var thumb string 561 + if v.Thumbnail != nil { 562 + thumb = *v.Thumbnail 563 + } 564 + ownerDid := "" 565 + if post.Author != nil { 566 + ownerDid = post.Author.Did 567 + } 568 + m.Video = &VideoVM{Thumb: thumb, Cid: v.Cid, Playlist: v.Playlist, OwnerDid: ownerDid} 569 + } 570 + if mm.EmbedExternal_View != nil && mm.EmbedExternal_View.External != nil { 571 + ex := mm.EmbedExternal_View.External 572 + extVM := &ExternalVM{Title: ex.Title, Description: ex.Description, Uri: ex.Uri} 573 + if ex.Thumb != nil { 574 + extVM.Thumb = *ex.Thumb 575 + } 576 + m.External = extVM 577 + } 578 + } 579 + 580 + // top-level external 581 + if post.Embed.EmbedExternal_View != nil && post.Embed.EmbedExternal_View.External != nil { 582 + ex := post.Embed.EmbedExternal_View.External 583 + extVM := &ExternalVM{Title: ex.Title, Description: ex.Description, Uri: ex.Uri} 584 + if ex.Thumb != nil { 585 + extVM.Thumb = *ex.Thumb 586 + } 587 + m.External = extVM 588 + } 589 + 590 + // top-level video view 591 + if post.Embed.EmbedVideo_View != nil { 592 + v := post.Embed.EmbedVideo_View 593 + var thumb string 594 + if v.Thumbnail != nil { 595 + thumb = *v.Thumbnail 596 + } 597 + ownerDid := "" 598 + if post.Author != nil { 599 + ownerDid = post.Author.Did 600 + } 601 + m.Video = &VideoVM{Thumb: thumb, Cid: v.Cid, Playlist: v.Playlist, OwnerDid: ownerDid} 602 + } 603 + 604 + if len(m.Images) == 0 && m.Video == nil && m.External == nil { 605 + return nil 606 + } 607 + return m 608 + } 609 + 610 + // GetMediaForTemplate accepts either a *bsky.FeedDefs_PostView or a *MediaVM and returns a *MediaVM 611 + // This lets templates call a single helper when they may have either the full PostView or a precomputed MediaVM. 612 + func GetMediaForTemplate(v interface{}) *MediaVM { 613 + if v == nil { 614 + return nil 615 + } 616 + switch t := v.(type) { 617 + case *bsky.FeedDefs_PostView: 618 + return GetPostMedia(t) 619 + case *MediaVM: 620 + return t 621 + default: 622 + return nil 623 + } 624 + } 625 + 626 + // IsPostReply reports whether the given feed item is a reply (has a Reply ref). 627 + func IsPostReply(item interface{}) bool { 628 + switch it := item.(type) { 629 + case *bsky.FeedDefs_FeedViewPost: 630 + if it == nil || it.Post == nil { 631 + return false 632 + } 633 + // consider it a reply only if a parent/root URI is present 634 + parentURI := extractReplyParentURI(it.Post) 635 + return parentURI != "" 636 + default: 637 + return false 638 + } 639 + } 640 + 641 + // ReplyParentURI returns the parent URI for a post's reply reference, or empty string. 642 + func ReplyParentURI(pv *bsky.FeedDefs_PostView) string { 643 + return extractReplyParentURI(pv) 644 + } 645 + 646 + // ShortURI returns a compact representation of an at:// post URI (did/postid) or the original string. 647 + func ShortURI(uri string) string { 648 + if uri == "" { 649 + return "" 650 + } 651 + // expected form: at://did/app.bsky.feed.post/postid 652 + if strings.HasPrefix(uri, "at://") { 653 + parts := strings.Split(strings.TrimPrefix(uri, "at://"), "/") 654 + if len(parts) >= 3 { 655 + // parts[0]=did, parts[1]=app.bsky.feed.post, parts[2]=postid 656 + return parts[0] + "/" + parts[len(parts)-1] 657 + } 658 + } 659 + return uri 660 + } 661 + 662 + // ParentInfo captures lightweight parent details available from a ReplyRef without fetching the parent post. 663 + type ParentInfo struct { 664 + AuthorName string 665 + AuthorHandle string 666 + Text string 667 + Uri string 668 + Avatar string 669 + PostURL string 670 + IndexedAt string 671 + Media *MediaVM 672 + // whether the signed-in viewer has liked this post (from PostView.Viewer.Like) 673 + IsFav bool 674 + // like count for the parent post (populated by handlers from PostView.LikeCount) 675 + LikeCount int 676 + ReplyCount int 677 + RepostCount int 678 + } 679 + 680 + // GetParentInfo extracts whatever metadata is present in the ReplyRef.Parent or ReplyRef.Root 681 + // using concrete, type-safe assertions (no reflection). It prefers Parent over Root and 682 + // only extracts the Uri when available from known concrete types. 683 + func GetParentInfo(pv *bsky.FeedDefs_PostView) ParentInfo { 684 + pi := ParentInfo{} 685 + if pv == nil || pv.Record == nil || pv.Record.Val == nil { 686 + return pi 687 + } 688 + post, ok := pv.Record.Val.(*bsky.FeedPost) 689 + if !ok || post == nil || post.Reply == nil { 690 + return pi 691 + } 692 + // prefer Parent over Root 693 + var ref interface{} 694 + if post.Reply.Parent != nil { 695 + ref = post.Reply.Parent 696 + } else if post.Reply.Root != nil { 697 + ref = post.Reply.Root 698 + } 699 + if ref == nil { 700 + return pi 701 + } 702 + // set Uri if available via existing helper 703 + pi.Uri = extractReplyParentURI(pv) 704 + 705 + // Try known concrete types (atproto.RepoStrongRef) to extract Uri 706 + if sr, ok := ref.(*atproto.RepoStrongRef); ok { 707 + if sr.Uri != "" { 708 + pi.Uri = sr.Uri 709 + } 710 + return pi 711 + } 712 + 713 + // If other concrete types are introduced by the API, avoid reflection and return what we have. 714 + return pi 715 + } 716 + 717 + // GetReplyChainInfos extracts available reply-ref metadata from a PostView without performing network fetches. 718 + // It returns a slice of ParentInfo ordered from root (top-most ancestor) to immediate parent. 719 + // This implementation is type-safe and only uses concrete types; it will populate Uri when available. 720 + // NOTE: Reply refs carry only lightweight references (Uri/Cid). To display author handles, display names 721 + // and text previews for ancestors, handlers should collect all referenced URIs and call fetchPostsBatch 722 + // once to obtain full PostView objects, then populate a ParentInfo map passed into templates. Helpers 723 + // must not perform network I/O (per project rules), so this function intentionally avoids fetching. 724 + func GetReplyChainInfos(pv *bsky.FeedDefs_PostView) []ParentInfo { 725 + var out []ParentInfo 726 + if pv == nil || pv.Record == nil || pv.Record.Val == nil { 727 + return out 728 + } 729 + post, ok := pv.Record.Val.(*bsky.FeedPost) 730 + if !ok || post == nil || post.Reply == nil { 731 + return out 732 + } 733 + 734 + // helper to extract info from a reply-ref struct (root or parent) 735 + extract := func(ref interface{}) ParentInfo { 736 + pi := ParentInfo{} 737 + if ref == nil { 738 + return pi 739 + } 740 + // If the concrete type is a RepoStrongRef, extract Uri 741 + if sr, ok := ref.(*atproto.RepoStrongRef); ok { 742 + if sr.Uri != "" { 743 + pi.Uri = sr.Uri 744 + } 745 + return pi 746 + } 747 + // Unknown concrete type: avoid reflection and return empty 748 + return pi 749 + } 750 + 751 + // prefer root then parent to produce top-down order 752 + if post.Reply.Root != nil { 753 + rootInfo := extract(post.Reply.Root) 754 + out = append(out, rootInfo) 755 + } 756 + if post.Reply.Parent != nil { 757 + parentInfo := extract(post.Reply.Parent) 758 + // avoid duplicating the same Uri twice 759 + if !(len(out) > 0 && out[len(out)-1].Uri != "" && parentInfo.Uri != "" && out[len(out)-1].Uri == parentInfo.Uri) { 760 + out = append(out, parentInfo) 761 + } 762 + } 763 + return out 764 + } 765 + 766 + // GetEmbeddedParentInfo inspects a post's embed record (if it's an embedded record view) 767 + // and returns a ParentInfo constructed from the embedded record's author and value fields. 768 + // This is useful for rendering a quoted record as an ancestor in the chat-like UI when 769 + // a full ReplyRef chain isn't available. 770 + func GetEmbeddedParentInfo(pv *bsky.FeedDefs_PostView) ParentInfo { 771 + pi := ParentInfo{} 772 + if pv == nil || pv.Embed == nil { 773 + return pi 774 + } 775 + // Prefer embedded record view 776 + if pv.Embed.EmbedRecord_View != nil && pv.Embed.EmbedRecord_View.Record != nil { 777 + rw := pv.Embed.EmbedRecord_View.Record 778 + // the wrapped record may be an EmbedRecord_ViewRecord 779 + if rw.EmbedRecord_ViewRecord != nil { 780 + r := rw.EmbedRecord_ViewRecord 781 + // author: use existing helper to get a friendly display name 782 + if r.Author != nil { 783 + pi.AuthorName = getDisplayNameFromProfile(r.Author) 784 + // attempt to extract a handle from known concrete author types by converting to interface{} 785 + switch a := interface{}(r.Author).(type) { 786 + case *bsky.ActorDefs_ProfileView: 787 + pi.AuthorHandle = a.Handle 788 + if a.Avatar != nil { 789 + pi.Avatar = *a.Avatar 790 + } 791 + case *bsky.ActorDefs_ProfileViewBasic: 792 + pi.AuthorHandle = a.Handle 793 + if a.Avatar != nil { 794 + pi.Avatar = *a.Avatar 795 + } 796 + case *bsky.ActorDefs_ProfileViewDetailed: 797 + pi.AuthorHandle = a.Handle 798 + if a.Avatar != nil { 799 + pi.Avatar = *a.Avatar 800 + } 801 + default: 802 + // unknown author shape - leave handle/avatar empty 803 + } 804 + } 805 + // text/value: use getPostText which accepts *util.LexiconTypeDecoder 806 + if r.Value != nil { 807 + pi.Text = getPostText(r.Value) 808 + } 809 + } 810 + } 811 + return pi 812 + } 813 + 814 + // HasEmbedRecord reports whether the given FeedPost has an embedded record view 815 + func HasEmbedRecord(item *bsky.FeedDefs_FeedViewPost) bool { 816 + if item == nil || item.Post == nil || item.Post.Embed == nil { 817 + return false 818 + } 819 + if item.Post.Embed.EmbedRecord_View != nil { 820 + return true 821 + } 822 + if item.Post.Embed.EmbedRecordWithMedia_View != nil { 823 + return true 824 + } 825 + return false 826 + } 827 + 828 + func IsReply(item *bsky.FeedDefs_FeedViewPost) bool { 829 + if item == nil || item.Post == nil { 830 + return false 831 + } 832 + return extractReplyParentURI(item.Post) != "" 833 + } 834 + 835 + func getIsFav(post *bsky.FeedDefs_PostView) bool { 836 + if post == nil || post.Viewer == nil || post.Viewer.Like == nil { 837 + return false 838 + } 839 + return len(*post.Viewer.Like) > 0 840 + }
+8
main.go
··· 1 + package main 2 + 3 + // main is a tiny entrypoint that delegates initialization and server startup 4 + // to Run() implemented in server.go. Keeping this file minimal avoids duplicate 5 + // declarations and keeps responsibilities focused. 6 + func main() { 7 + Run() 8 + }
+57
middleware.go
··· 1 + package main 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "strings" 7 + "time" 8 + ) 9 + 10 + type loggingResponseWriter struct { 11 + http.ResponseWriter 12 + status int 13 + bytes int 14 + } 15 + 16 + func (lrw *loggingResponseWriter) WriteHeader(code int) { 17 + lrw.status = code 18 + lrw.ResponseWriter.WriteHeader(code) 19 + } 20 + 21 + func (lrw *loggingResponseWriter) Write(b []byte) (int, error) { 22 + if lrw.status == 0 { 23 + lrw.status = http.StatusOK 24 + } 25 + n, err := lrw.ResponseWriter.Write(b) 26 + lrw.bytes += n 27 + return n, err 28 + } 29 + 30 + func redactHeaders(h http.Header) http.Header { 31 + out := make(http.Header) 32 + for k, vv := range h { 33 + if strings.EqualFold(k, "Authorization") || strings.EqualFold(k, "Cookie") { 34 + out[k] = []string{"<redacted>"} 35 + continue 36 + } 37 + out[k] = vv 38 + } 39 + return out 40 + } 41 + 42 + func loggingMiddleware(next http.Handler) http.Handler { 43 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 + start := time.Now() 45 + headers := redactHeaders(r.Header) 46 + log.Printf("DEBUG: Incoming request - method=%s url=%s remote=%s headers=%v", r.Method, r.URL.String(), r.RemoteAddr, headers) 47 + 48 + lrw := &loggingResponseWriter{ResponseWriter: w} 49 + next.ServeHTTP(lrw, r) 50 + 51 + if lrw.status == 0 { 52 + lrw.status = http.StatusOK 53 + } 54 + duration := time.Since(start) 55 + log.Printf("DEBUG: Response completed - method=%s url=%s status=%d bytes=%d duration=%s", r.Method, r.URL.String(), lrw.status, lrw.bytes, duration) 56 + }) 57 + }
+146
server.go
··· 1 + package main 2 + 3 + import ( 4 + "embed" 5 + "html/template" 6 + "io/fs" 7 + "log" 8 + "net/http" 9 + "os" 10 + 11 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 + "github.com/gorilla/sessions" 13 + ) 14 + 15 + const ( 16 + sessionName = "twitter-2006-session" 17 + ) 18 + 19 + //go:embed templates/* 20 + var templatesFS embed.FS 21 + 22 + //go:embed static/* 23 + var staticFS embed.FS 24 + 25 + var ( 26 + oauthApp *oauth.ClientApp 27 + store *sessions.CookieStore 28 + tpl *template.Template 29 + ) 30 + 31 + // Run initializes global state and starts the HTTP server. 32 + func Run() { 33 + config := oauth.NewPublicConfig( 34 + os.Getenv("BSKY_CLIENT_ID"), 35 + os.Getenv("BSKY_REDIRECT_URI"), 36 + []string{"atproto", "transition:generic"}, 37 + ) 38 + oauthApp = oauth.NewClientApp(&config, oauth.NewMemStore()) 39 + 40 + key := os.Getenv("SESSION_DB_KEY") 41 + if key == "" { 42 + log.Fatal("SESSION_DB_KEY environment variable not set") 43 + } 44 + 45 + derivedKey, err := deriveKeyFromEnv(key) 46 + if err != nil { 47 + log.Fatalf("failed to derive key from SESSION_DB_KEY: %v", err) 48 + } 49 + 50 + store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_SECRET"))) 51 + store.Options = &sessions.Options{HttpOnly: true, Secure: false, Path: "/"} 52 + 53 + var dbPath string 54 + if v := os.Getenv("SESSION_DB_PATH"); v != "" { 55 + dbPath = v 56 + } 57 + sqliteStore, err := NewSQLiteStore(dbPath, derivedKey) 58 + if err != nil { 59 + log.Fatalf("failed to initialize SQLite store: %v", err) 60 + } 61 + oauthApp.Store = sqliteStore 62 + 63 + funcMap := template.FuncMap{ 64 + "getPostText": getPostText, 65 + "getProfileURL": getProfileURL, 66 + "getPostURL": getPostURL, 67 + "getFollowingCount": getFollowingCount, 68 + "getFollowersCount": getFollowersCount, 69 + "getPostsCount": getPostsCount, 70 + "getDisplayName": getDisplayNameFromProfile, 71 + "getCursor": func(t interface{}) string { return getCursorFromAny(t) }, 72 + "getPostPrefix": GetPostPrefix, 73 + "getEmbedRecord": GetEmbedRecord, 74 + "embedContext": embedContext, 75 + "getPostMedia": GetPostMedia, 76 + "getMediaForTemplate": GetMediaForTemplate, 77 + "makeElementID": MakeElementID, 78 + "wrapThread": wrapThread, 79 + // newly added helpers 80 + "avatarURL": AvatarURL, 81 + "AvatarURL": AvatarURL, 82 + "hasAvatar": HasAvatar, 83 + "bannerURL": BannerURL, 84 + "postBoxInitial": PostBoxInitial, 85 + "postBoxPlaceholder": PostBoxPlaceholder, 86 + "isPostRetweet": IsPostRetweet, 87 + "isPostQuote": IsPostQuote, 88 + "buildPostVM": buildPostVMForTemplate, 89 + "hasItems": HasItems, 90 + // reply helpers 91 + "isPostReply": IsPostReply, 92 + "replyParentURI": ReplyParentURI, 93 + "shortURI": ShortURI, 94 + "getParentInfo": GetParentInfo, 95 + "getReplyChainInfos": GetReplyChainInfos, 96 + "getEmbeddedParentInfo": GetEmbeddedParentInfo, 97 + "hasEmbedRecord": HasEmbedRecord, 98 + "isReply": IsReply, 99 + // helper to build small maps in templates 100 + "dict": func(vals ...interface{}) map[string]interface{} { 101 + m := make(map[string]interface{}) 102 + for i := 0; i < len(vals); i += 2 { 103 + k, _ := vals[i].(string) 104 + if i+1 < len(vals) { 105 + m[k] = vals[i+1] 106 + } 107 + } 108 + return m 109 + }, 110 + "getIsFav": getIsFav, 111 + // expose like counts to templates 112 + "getLikeCount": getLikeCount, 113 + } 114 + 115 + tpl = template.Must(template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html")) 116 + 117 + subStaticFS, err := fs.Sub(staticFS, "static") 118 + if err != nil { 119 + log.Fatalf("failed to prepare static filesystem: %v", err) 120 + } 121 + 122 + http.HandleFunc("/", handleIndex) 123 + http.HandleFunc("/signin", handleSignin) 124 + http.HandleFunc("/post-status", handlePostStatus) 125 + http.HandleFunc("/login", handleLogin) 126 + http.HandleFunc("/logout", handleLogout) 127 + http.HandleFunc("/oauth-callback", handleOAuthCallback) 128 + http.HandleFunc("/oauth-client-metadata.json", handleClientMetadata) 129 + http.HandleFunc("/timeline", handleTimeline) 130 + http.HandleFunc("/timeline/post", handleTimelinePost) 131 + http.HandleFunc("/post/", handlePost) 132 + http.HandleFunc("/profile/", handleProfile) 133 + http.HandleFunc("/reply", handleReply) 134 + http.HandleFunc("/htmx/timeline", htmxTimelineFeed) 135 + http.HandleFunc("/htmx/profile", htmxProfileFeed) 136 + http.HandleFunc("/video/", handleVideo) 137 + http.HandleFunc("/about", handleAbout) 138 + http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(subStaticFS)))) 139 + 140 + port := os.Getenv("PORT") 141 + if port == "" { 142 + port = "8080" 143 + } 144 + log.Println("Listening on http://localhost:" + port) 145 + log.Fatal(http.ListenAndServe(":"+port, loggingMiddleware(http.DefaultServeMux))) 146 + }
+316
static/app.js
··· 1 + // Centralized application JavaScript for Tuiter 2006 2 + // This file replaces inline scripts previously embedded in templates. 3 + 4 + (function(){ 5 + 'use strict'; 6 + 7 + // Char count updater for post box 8 + function updateCharCount(remaining){ 9 + var charCountEl = document.getElementById('char-count'); 10 + if (charCountEl) { 11 + charCountEl.textContent = remaining; 12 + charCountEl.style.color = remaining < 20 ? 'red' : (remaining < 50 ? 'orange' : 'green'); 13 + } 14 + } 15 + 16 + // Expose to global for compatibility 17 + window.updateCharCount = updateCharCount; 18 + 19 + // Form wiring for post-box forms: update char count and reset on htmx:afterRequest 20 + function initPostBoxForms(){ 21 + var forms = document.querySelectorAll('.post-box-form'); 22 + forms.forEach(function(form){ 23 + var ta = form.querySelector('textarea[data-maxlength]'); 24 + var max = ta && parseInt(ta.getAttribute('data-maxlength'), 10) || 140; 25 + if (ta){ 26 + ta.addEventListener('input', function(){ updateCharCount(max - this.value.length); }); 27 + // set initial 28 + updateCharCount(max - ta.value.length); 29 + } 30 + 31 + // Listen for HTMX afterRequest to reset the form 32 + form.addEventListener('htmx:afterRequest', function(evt){ 33 + try{ form.reset(); if (ta) updateCharCount(max); } catch(e){ console.log('form reset error', e); } 34 + }); 35 + }); 36 + } 37 + 38 + // Lightbox handling (moved from footer template) 39 + function initLightbox(){ 40 + var overlay = document.getElementById('lightbox-overlay'); 41 + if (!overlay) return; 42 + var img = document.getElementById('lightbox-img'); 43 + var video = document.getElementById('lightbox-video'); 44 + 45 + function showOverlay(){ overlay.classList.add('visible'); overlay.setAttribute('aria-hidden','false'); } 46 + function hideOverlay(){ overlay.classList.remove('visible'); overlay.setAttribute('aria-hidden','true'); } 47 + 48 + function openImage(src, alt){ 49 + try{ video.pause(); } catch(e){} 50 + video.removeAttribute('src'); 51 + while(video.firstChild) video.removeChild(video.firstChild); 52 + try{ video.load(); } catch(e){} 53 + video.style.display = 'none'; 54 + 55 + img.src = src; 56 + img.alt = alt || ''; 57 + img.style.display = ''; 58 + showOverlay(); 59 + } 60 + 61 + function openVideo(src, mime){ 62 + img.src = ''; 63 + img.alt = ''; 64 + img.style.display = 'none'; 65 + 66 + var type = mime || 'video/mp4'; 67 + while(video.firstChild) video.removeChild(video.firstChild); 68 + var source = document.createElement('source'); 69 + source.src = src; 70 + source.type = type; 71 + video.appendChild(source); 72 + 73 + video.style.display = ''; 74 + try{ video.load(); var p = video.play(); if (p && typeof p.then === 'function') p.catch(function(){}); } catch(e){ console.log('DEBUG: video play error', e); } 75 + showOverlay(); 76 + } 77 + 78 + function closeLightbox(){ 79 + try{ video.pause(); } catch(e){} 80 + video.removeAttribute('src'); 81 + while(video.firstChild) video.removeChild(video.firstChild); 82 + try{ video.load(); } catch(e){} 83 + 84 + img.src = ''; 85 + img.alt = ''; 86 + img.style.display = ''; 87 + hideOverlay(); 88 + } 89 + 90 + document.addEventListener('click', function(e){ 91 + var t = e.target; 92 + if (!t || !t.classList) return; 93 + 94 + if (t.classList.contains('post-image')){ 95 + e.preventDefault(); 96 + var parent = t.closest('a'); 97 + var href = parent && parent.getAttribute('href'); 98 + if (href) openImage(href, t.getAttribute('alt')); 99 + return; 100 + } 101 + 102 + if (t.classList.contains('post-video-thumb')){ 103 + e.preventDefault(); 104 + var parent = t.closest('a'); 105 + var href = parent && parent.getAttribute('href'); 106 + var mime = parent && parent.dataset && parent.dataset.mime; 107 + if (href) openVideo(href, mime || 'video/mp4'); 108 + return; 109 + } 110 + 111 + if (t.id === 'lightbox-overlay') closeLightbox(); 112 + }, false); 113 + 114 + document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeLightbox(); }); 115 + } 116 + 117 + // Initialize banner backgrounds set via data attributes 118 + function initProfileBanners(){ 119 + var nodes = document.querySelectorAll('[data-banner-url]'); 120 + nodes.forEach(function(n){ 121 + var url = n.getAttribute('data-banner-url'); 122 + if (url && url !== '') n.style.backgroundImage = "url('" + url + "')"; 123 + }); 124 + } 125 + 126 + // Setup video element styling for embedded videos 127 + function initVideoStyling(){ 128 + var vids = document.querySelectorAll('.video-embedded'); 129 + vids.forEach(function(v){ 130 + v.style.maxWidth = '100%'; 131 + v.style.height = 'auto'; 132 + v.style.background = '#000'; 133 + }); 134 + 135 + var lightboxVideo = document.getElementById('lightbox-video'); 136 + if (lightboxVideo){ lightboxVideo.style.maxWidth = '100%'; lightboxVideo.style.maxHeight = '80vh'; lightboxVideo.style.display = 'none'; } 137 + } 138 + 139 + // Post page initialization: auto-scroll highlighted post and toggle flat/nested 140 + function initPostPage(){ 141 + // Auto-scroll to highlighted post 142 + var highlighted = document.querySelector('.highlighted-post'); 143 + if (highlighted) { 144 + highlighted.scrollIntoView({behavior: 'smooth', block: 'center'}); 145 + highlighted.style.transition = 'box-shadow 0.4s ease'; 146 + highlighted.style.boxShadow = '0 0 0 3px rgba(255,204,51,0.6)'; 147 + setTimeout(function(){ highlighted.style.boxShadow = ''; }, 2000); 148 + } 149 + 150 + // Toggle flat/nested views 151 + (function() { 152 + var flatBtn = document.getElementById('toggle-flat'); 153 + var nestedBtn = document.getElementById('toggle-nested'); 154 + var replies = document.getElementById('replies-container'); 155 + if (!flatBtn || !nestedBtn || !replies) return; 156 + flatBtn.addEventListener('click', function(){ 157 + flatBtn.classList.add('active'); 158 + nestedBtn.classList.remove('active'); 159 + replies.classList.add('flat-view'); 160 + }); 161 + nestedBtn.addEventListener('click', function(){ 162 + nestedBtn.classList.add('active'); 163 + flatBtn.classList.remove('active'); 164 + replies.classList.remove('flat-view'); 165 + }); 166 + })(); 167 + } 168 + 169 + // Reply input handling: create a slim chat-like reply input under a post or chat bubble 170 + function closeOpenReplyInput(){ 171 + var ex = document.querySelector('.reply-input-container.absolute'); 172 + if (ex && ex.parentNode) ex.parentNode.removeChild(ex); 173 + // remove transient listeners if any 174 + try{ window.removeEventListener('scroll', closeOpenReplyInput); window.removeEventListener('resize', closeOpenReplyInput); } catch(e){} 175 + } 176 + 177 + function createReplyInput(refEl, insertAfterEl){ 178 + // refEl is typically the clicked reply-button element; insertAfterEl is optional contextual element 179 + if (!refEl) return null; 180 + // Close any existing reply input 181 + closeOpenReplyInput(); 182 + 183 + var container = document.createElement('div'); 184 + container.className = 'reply-input-container absolute active'; 185 + container.setAttribute('role','region'); 186 + container.setAttribute('aria-label','Reply input'); 187 + 188 + // Try to obtain signed-in avatar from the page-level container data attribute 189 + var siteContainer = document.querySelector('.container'); 190 + var avatarUrl = siteContainer && siteContainer.dataset && siteContainer.dataset.signedInAvatar ? siteContainer.dataset.signedInAvatar : ''; 191 + 192 + // Avatar square (if available) or placeholder 193 + if (avatarUrl && avatarUrl !== ''){ 194 + try{ 195 + var avatarEl = document.createElement('img'); 196 + avatarEl.className = 'reply-avatar-square'; 197 + avatarEl.src = avatarUrl; 198 + avatarEl.alt = 'Your avatar'; 199 + avatarEl.setAttribute('aria-hidden','true'); 200 + container.appendChild(avatarEl); 201 + } catch(e){ /* ignore image construction errors */ } 202 + } else { 203 + var ph = document.createElement('div'); 204 + ph.className = 'reply-avatar-placeholder'; 205 + ph.setAttribute('aria-hidden','true'); 206 + container.appendChild(ph); 207 + } 208 + 209 + var input = document.createElement('input'); 210 + input.type = 'text'; 211 + input.className = 'reply-input'; 212 + input.setAttribute('placeholder', 'Write a reply...'); 213 + input.setAttribute('aria-label', 'Write a reply'); 214 + 215 + var btn = document.createElement('button'); 216 + btn.className = 'reply-submit'; 217 + btn.type = 'button'; 218 + btn.textContent = 'Reply'; 219 + // No-op click handler for now, keep a debug log 220 + btn.addEventListener('click', function(ev){ ev.preventDefault(); try{ console.debug('Reply button clicked (no-op)'); } catch(e){} }); 221 + 222 + container.appendChild(input); 223 + container.appendChild(btn); 224 + 225 + // Append to body to avoid layout shifts 226 + document.body.appendChild(container); 227 + 228 + // Compute position based on refEl (reply-button) bounding rect and available space 229 + try{ 230 + var rbRect = refEl.getBoundingClientRect(); 231 + var containerRect = siteContainer ? siteContainer.getBoundingClientRect() : { left: 8, width: Math.min(window.innerWidth - 16, 1000) }; 232 + 233 + // desired width: try to fit inside main container, account for avatar column (~44px) 234 + var desiredWidth = Math.min(760, Math.max(320, Math.floor(containerRect.width - 56))); 235 + 236 + // center the input around the reply button horizontally but keep it inside viewport/container 237 + var left = window.scrollX + Math.max(containerRect.left + 12, Math.min(rbRect.left + window.scrollX - (desiredWidth/2) + (rbRect.width/2), containerRect.left + window.scrollX + containerRect.width - desiredWidth - 12)); 238 + var top = window.scrollY + rbRect.top + rbRect.height + 8; // place just below the button 239 + 240 + container.style.position = 'absolute'; 241 + container.style.left = left + 'px'; 242 + container.style.top = top + 'px'; 243 + container.style.width = desiredWidth + 'px'; 244 + container.style.zIndex = 9999; 245 + } catch(e){ console.debug('reply input positioning error', e); } 246 + 247 + // Focus the input for convenience 248 + try{ input.focus(); } catch(e){} 249 + 250 + // Close when user scrolls or resizes to avoid floating orphaned inputs 251 + try{ window.addEventListener('scroll', closeOpenReplyInput, { passive: true }); window.addEventListener('resize', closeOpenReplyInput); } catch(e){} 252 + 253 + return container; 254 + } 255 + 256 + function initReplyButtons(){ 257 + // Toggle reply input when a reply button is clicked. Use event delegation. 258 + document.addEventListener('click', function(e){ 259 + var t = e.target; 260 + if (!t || !t.closest) return; 261 + 262 + // If a reply-button (or child) was clicked 263 + var rb = t.closest('.reply-button'); 264 + if (!rb) return; 265 + e.preventDefault(); 266 + 267 + // Find contextual element for width calculation (prefer post-content, then chat-bubble) 268 + var postContent = rb.closest('.post-content'); 269 + var chatBubble = rb.closest('.chat-bubble'); 270 + var chatNode = rb.closest('.chat-node'); 271 + var post = rb.closest('.post'); 272 + 273 + var contextEl = postContent || chatBubble || chatNode || post; 274 + 275 + // If there's already a reply input visible, toggle it closed 276 + var existing = document.querySelector('.reply-input-container.absolute'); 277 + if (existing){ 278 + closeOpenReplyInput(); 279 + // If the existing was opened for the same reply-button, stop here (toggle) 280 + return; 281 + } 282 + 283 + createReplyInput(rb, contextEl); 284 + }, false); 285 + 286 + // Close the reply input when clicking outside of it or a reply-button 287 + document.addEventListener('click', function(e){ 288 + var t = e.target; 289 + if (!t) return; 290 + if (t.closest && (t.closest('.reply-input-container') || t.closest('.reply-button'))) return; 291 + closeOpenReplyInput(); 292 + }, false); 293 + } 294 + 295 + // On DOM ready 296 + document.addEventListener('DOMContentLoaded', function(){ 297 + initLightbox(); 298 + initProfileBanners(); 299 + initVideoStyling(); 300 + initPostBoxForms(); 301 + 302 + // ensure initial char count reflects current textarea 303 + var ta = document.getElementById('status-input'); 304 + if (ta) updateCharCount(140 - ta.value.length); 305 + 306 + // init post page behaviour if present 307 + initPostPage(); 308 + 309 + // initialize reply button behaviour 310 + initReplyButtons(); 311 + }); 312 + 313 + // expose initPostPage for compatibility with small inline stub 314 + window.initPostPage = initPostPage; 315 + 316 + })();
+2053
static/style.css
··· 1 + .qt-label-link, .rt-label-link { 2 + text-decoration: none; 3 + color: var(--tuiter-muted); 4 + cursor: pointer; 5 + } 6 + .qt-label-link:hover, .rt-label-link:hover { 7 + color: var(--tuiter-text); 8 + text-decoration: none; 9 + } 10 + /* Quoted/Retweeted tweet box */ 11 + .quoted-tweet, .retweeted-tweet { 12 + margin: 2px 0 0 8px; 13 + padding: 6px 8px; 14 + background: var(--tuiter-surface-warm); 15 + border-radius: 8px; 16 + display: flex; 17 + align-items: center; 18 + } 19 + 20 + /* QT/RT label */ 21 + .qt-label, .rt-label { 22 + font-size: 1.1em; 23 + font-weight: 600; 24 + color: var(--tuiter-muted); 25 + margin-right: 8px; 26 + } 27 + 28 + /* Quoted/retweeted avatar */ 29 + .quoted-avatar, .retweeted-avatar { 30 + display: inline-block; 31 + vertical-align: middle; 32 + margin-right: 6px; 33 + } 34 + .quoted-avatar-img, .retweeted-avatar-img { 35 + width: 24px; 36 + height: 24px; 37 + border-radius: 8px; 38 + object-fit: cover; 39 + } 40 + 41 + /* Quoted/retweeted author name */ 42 + .quoted-author-name, .retweeted-author-name { 43 + font-weight: bold; 44 + color: var(--tuiter-text); 45 + margin-right: 6px; 46 + text-decoration: none; 47 + font-size: 11px; 48 + } 49 + .quoted-text, .retweeted-text { 50 + color: var(--tuiter-text); 51 + } 52 + /* Sidebar background color for profile header fallback */ 53 + .sidebar-bg { 54 + background-color: var(--tuiter-sidebar-bg); 55 + } 56 + /* Profile header background */ 57 + .profile-header-bg { 58 + position: relative; 59 + width: 100%; 60 + min-height: 260px; 61 + background-size: cover; 62 + background-position: center; 63 + } 64 + 65 + .profile-header-bg-overlay { 66 + position: absolute; 67 + inset: 0; 68 + background: rgba(var(--tuiter-media-black-rgb),0.15); 69 + } 70 + 71 + .profile-header-content { 72 + position: relative; 73 + z-index: 1; 74 + display: flex; 75 + flex-direction: row; 76 + align-items: flex-start; 77 + gap: 16px; 78 + padding: 24px; 79 + } 80 + 81 + .profile-main { 82 + color: var(--tuiter-white); 83 + display: flex; 84 + flex-direction: column; 85 + justify-content: flex-end; 86 + height: 100%; 87 + } 88 + 89 + .profile-names { 90 + margin-bottom: 0.7em; 91 + } 92 + 93 + .profile-displayname { 94 + margin-bottom: 0.2em; 95 + font-size: 2.2em; 96 + font-weight: bold; 97 + } 98 + 99 + .handle { 100 + font-size: 2em; 101 + font-weight: bold; 102 + color: var(--tuiter-text); 103 + background: rgba(var(--tuiter-white-rgb),0.85); 104 + padding: 0.2em 0.5em; 105 + border-radius: 8px; 106 + box-shadow: 0 1px 4px rgba(var(--tuiter-media-black-rgb),0.12); 107 + display: inline-block; 108 + } 109 + 110 + .profile-update-box { 111 + text-shadow: none; 112 + margin-top: 2.5em; 113 + } 114 + /* 2006 Twitter Tribute - Authentic Styling */ 115 + 116 + * { 117 + margin: 0; 118 + padding: 0; 119 + box-sizing: border-box; 120 + } 121 + 122 + body { 123 + background-color: var(--tuiter-bg); 124 + background-image: radial-gradient(circle at 10% 10%, rgba(var(--tuiter-white-rgb),0.10) 0.35rem, transparent 0.5rem), 125 + radial-gradient(circle at 90% 20%, rgba(var(--tuiter-white-rgb),0.08) 0.45rem, transparent 0.6rem), 126 + radial-gradient(circle at 30% 80%, rgba(var(--tuiter-white-rgb),0.06) 0.25rem, transparent 0.4rem); 127 + font-family: "Lucida Grande", "Lucida Sans Unicode", Verdana, sans-serif; 128 + font-size: 11px; 129 + color: var(--tuiter-text); 130 + min-height: 100vh; 131 + } 132 + 133 + /* Header with logo */ 134 + .header { 135 + background: var(--tuiter-bg); 136 + padding: 8px 12px; 137 + border-bottom: 1px solid var(--tuiter-header-accent); 138 + display: flex; 139 + justify-content: space-between; 140 + align-items: center; 141 + } 142 + 143 + .header h1 { 144 + margin: 0; 145 + font-size: 24px; 146 + font-weight: bold; 147 + } 148 + 149 + .header h1 a { 150 + color: var(--tuiter-white); 151 + text-decoration: none; 152 + text-shadow: 1px 1px 2px rgba(var(--tuiter-media-black-rgb),0.3); 153 + } 154 + 155 + /* Header navigation */ 156 + .header-nav { 157 + font-size: 11px; 158 + } 159 + 160 + .header-nav a { 161 + color: var(--tuiter-text); 162 + text-decoration: none; 163 + margin: 0 2px; 164 + } 165 + 166 + .header-nav a:hover { 167 + text-decoration: underline; 168 + } 169 + 170 + /* Search area in header */ 171 + .search-area { 172 + position: absolute; 173 + top: 20px; 174 + right: 20px; 175 + font-size: 11px; 176 + } 177 + 178 + .search-area input[type="text"] { 179 + width: 150px; 180 + padding: 2px 4px; 181 + border: 1px solid var(--tuiter-border-muted); 182 + font-size: 11px; 183 + background: var(--tuiter-white); 184 + } 185 + 186 + .search-area button { 187 + padding: 2px 6px; 188 + border: 1px solid var(--tuiter-border-muted); 189 + background: var(--tuiter-input-bg); 190 + font-size: 11px; 191 + cursor: pointer; 192 + margin-left: 2px; 193 + } 194 + 195 + .search-area a { 196 + color: var(--tuiter-link); 197 + text-decoration: none; 198 + margin-left: 5px; 199 + } 200 + 201 + .search-area a:hover { 202 + text-decoration: underline; 203 + } 204 + 205 + /* Main container */ 206 + .container { 207 + max-width: 1000px; 208 + margin: 0 auto; 209 + padding: 0 20px; 210 + } 211 + 212 + /* Two column layout */ 213 + .main-content { 214 + display: flex; 215 + gap: 20px; 216 + align-items: flex-start; 217 + } 218 + 219 + /* Left column - main content */ 220 + .content { 221 + flex: 1; 222 + background: var(--tuiter-card); 223 + border-radius: 10px; 224 + padding: 20px; 225 + box-shadow: 0 2px 5px rgba(var(--tuiter-media-black-rgb),0.1); 226 + min-height: 400px; 227 + max-width: 80%; 228 + } 229 + 230 + /* Right column - sidebar */ 231 + .sidebar { 232 + width: 250px; 233 + background: var(--tuiter-sidebar-bg); 234 + border-radius: 10px; 235 + padding: 15px; 236 + box-shadow: 0 2px 5px rgba(var(--tuiter-media-black-rgb),0.1); 237 + } 238 + 239 + /* Welcome message */ 240 + .welcome-message { 241 + font-size: 13px; 242 + line-height: 1.4; 243 + margin-bottom: 15px; 244 + color: var(--tuiter-text); 245 + } 246 + 247 + .welcome-message .highlight { 248 + background-color: var(--tuiter-highlight); 249 + padding: 1px 2px; 250 + } 251 + 252 + /* Timeline styles */ 253 + .timeline-intro { 254 + font-size: 12px; 255 + margin-bottom: 20px; 256 + color: var(--tuiter-muted); 257 + } 258 + 259 + .timeline-intro a { 260 + color: var(--tuiter-link); 261 + text-decoration: underline; 262 + } 263 + 264 + .post { 265 + border-bottom: 1px solid var(--tuiter-divider); 266 + padding: 12px 0; 267 + display: flex; 268 + gap: 10px; 269 + } 270 + 271 + .post-avatar img { 272 + width: 24px; 273 + height: 24px; 274 + background: var(--tuiter-avatar-bg); 275 + border: 1px solid var(--tuiter-border-muted); 276 + border-radius: 2px; 277 + flex-shrink: 0; 278 + display: flex; 279 + align-items: center; 280 + justify-content: center; 281 + font-size: 10px; 282 + color: var(--tuiter-muted); 283 + } 284 + 285 + .post-author { 286 + font-weight: bold; 287 + color: var(--tuiter-link); 288 + text-decoration: none; 289 + font-size: 11px; 290 + } 291 + 292 + .post-author:hover { 293 + text-decoration: underline; 294 + } 295 + 296 + .post-text { 297 + margin-top: 2px; 298 + font-size: 11px; 299 + line-height: 1.3; 300 + color: var(--tuiter-text); 301 + } 302 + 303 + .post-meta { 304 + font-size: 10px; 305 + color: var(--tuiter-muted); 306 + margin-top: 3px; 307 + } 308 + 309 + .post-meta a { 310 + color: var(--tuiter-link); 311 + text-decoration: none; 312 + } 313 + 314 + .post-meta a:hover { 315 + text-decoration: underline; 316 + } 317 + 318 + /* Sidebar styles */ 319 + .sidebar h3 { 320 + font-size: 12px; 321 + font-weight: bold; 322 + color: var(--tuiter-text); 323 + margin-bottom: 10px; 324 + border-bottom: 1px solid rgba(var(--tuiter-media-black-rgb),0.08); 325 + padding-bottom: 3px; 326 + } 327 + 328 + /* About section */ 329 + .about-section { 330 + margin-bottom: 20px; 331 + } 332 + 333 + .about-section .profile-pic { 334 + width: 48px; 335 + height: 48px; 336 + float: left; 337 + margin-right: 10px; 338 + margin-bottom: 10px; 339 + } 340 + 341 + .about-section .profile-pic img { 342 + width: 48px; 343 + height: 48px; 344 + border: 1px solid var(--tuiter-border-muted); 345 + border-radius: 2px; 346 + } 347 + 348 + .about-section .profile-pic a { 349 + display: flex; 350 + align-items: center; 351 + justify-content: center; 352 + width: 48px; 353 + height: 48px; 354 + background: var(--tuiter-avatar-bg); 355 + border: 1px solid var(--tuiter-border-muted); 356 + border-radius: 2px; 357 + font-size: 20px; 358 + color: var(--tuiter-muted); 359 + text-decoration: none; 360 + } 361 + 362 + .about-section .profile-info { 363 + overflow: hidden; 364 + font-size: 11px; 365 + line-height: 1.3; 366 + } 367 + 368 + .about-section .profile-info strong a { 369 + color: var(--tuiter-link); 370 + text-decoration: none; 371 + } 372 + 373 + .about-section .profile-info strong a:hover { 374 + text-decoration: underline; 375 + } 376 + 377 + .stats { 378 + clear: both; 379 + margin-top: 10px; 380 + font-size: 11px; 381 + } 382 + 383 + .stat-line { 384 + margin-bottom: 2px; 385 + } 386 + 387 + .stat-label { 388 + color: var(--tuiter-muted); 389 + } 390 + 391 + .stat-value { 392 + font-weight: bold; 393 + color: var(--tuiter-text); 394 + } 395 + 396 + /* Following section */ 397 + .following-section { 398 + margin-bottom: 20px; 399 + } 400 + 401 + .following-grid { 402 + display: grid; 403 + grid-template-columns: repeat(5, 1fr); 404 + gap: 3px; 405 + margin-top: 8px; 406 + } 407 + 408 + .following-avatar { 409 + width: 36px; 410 + height: 36px; 411 + } 412 + 413 + .following-avatar img { 414 + width: 36px; 415 + height: 36px; 416 + border: 1px solid var(--tuiter-border-muted); 417 + border-radius: 2px; 418 + } 419 + 420 + .following-avatar a { 421 + display: flex; 422 + align-items: center; 423 + justify-content: center; 424 + width: 36px; 425 + height: 36px; 426 + background: var(--tuiter-avatar-bg); 427 + border: 1px solid var(--tuiter-border-muted); 428 + border-radius: 2px; 429 + font-size: 12px; 430 + color: var(--tuiter-muted); 431 + text-decoration: none; 432 + } 433 + 434 + /* Actions */ 435 + .actions { 436 + margin-top: 15px; 437 + } 438 + 439 + .update-button { 440 + display: block; 441 + background: var(--tuiter-input-bg); 442 + border: 1px solid var(--tuiter-border-muted); 443 + padding: 6px 12px; 444 + font-size: 11px; 445 + color: var(--tuiter-text); 446 + text-decoration: none; 447 + text-align: center; 448 + border-radius: 3px; 449 + margin-bottom: 8px; 450 + } 451 + 452 + .update-button:hover { 453 + background: rgba(var(--tuiter-media-black-rgb),0.03); 454 + } 455 + 456 + .logout-btn { 457 + display: block; 458 + font-size: 10px; 459 + color: var(--tuiter-muted); 460 + text-decoration: none; 461 + text-align: center; 462 + } 463 + 464 + .logout-btn:hover { 465 + text-decoration: underline; 466 + } 467 + 468 + /* Sign in form */ 469 + .signin-form { 470 + margin-bottom: 20px; 471 + } 472 + 473 + .form-group { 474 + margin-bottom: 8px; 475 + } 476 + 477 + .form-group label { 478 + display: block; 479 + font-size: 11px; 480 + color: var(--tuiter-text); 481 + margin-bottom: 2px; 482 + } 483 + 484 + .form-group input[type="text"], 485 + .form-group input[type="password"] { 486 + width: 100%; 487 + padding: 3px 4px; 488 + border: 1px solid var(--tuiter-border-muted); 489 + font-size: 11px; 490 + background: var(--tuiter-white); 491 + } 492 + 493 + .form-group input[type="text"]:focus, 494 + .form-group input[type="password"]:focus { 495 + outline: none; 496 + border-color: var(--tuiter-link); 497 + } 498 + 499 + .checkbox-group { 500 + margin: 8px 0; 501 + font-size: 10px; 502 + } 503 + 504 + .checkbox-group input[type="checkbox"] { 505 + margin-right: 4px; 506 + } 507 + 508 + .checkbox-group a { 509 + color: var(--tuiter-link); 510 + text-decoration: none; 511 + } 512 + 513 + .checkbox-group a:hover { 514 + text-decoration: underline; 515 + } 516 + 517 + .signin-btn { 518 + background: var(--tuiter-input-bg); 519 + border: 1px solid var(--tuiter-border-muted); 520 + padding: 4px 8px; 521 + font-size: 11px; 522 + cursor: pointer; 523 + margin-bottom: 10px; 524 + } 525 + 526 + .signin-btn:hover { 527 + background: rgba(var(--tuiter-media-black-rgb),0.03); 528 + } 529 + 530 + /* Join section */ 531 + .join-section { 532 + text-align: center; 533 + margin-bottom: 20px; 534 + } 535 + 536 + .join-link { 537 + display: block; 538 + background: var(--tuiter-join-bg); 539 + color: var(--tuiter-dark-teal); 540 + padding: 6px 8px; 541 + text-decoration: none; 542 + font-weight: bold; 543 + font-size: 11px; 544 + border-radius: 3px; 545 + margin-bottom: 5px; 546 + } 547 + 548 + .join-link:hover { 549 + background: var(--tuiter-join-hover); 550 + } 551 + 552 + .join-subtext { 553 + font-size: 10px; 554 + color: var(--tuiter-muted); 555 + line-height: 1.2; 556 + } 557 + 558 + .join-subtext a { 559 + color: var(--tuiter-link); 560 + text-decoration: none; 561 + } 562 + 563 + .join-subtext a:hover { 564 + text-decoration: underline; 565 + } 566 + 567 + /* Footer */ 568 + .footer { 569 + text-align: center; 570 + padding: 20px 0; 571 + font-size: 10px; 572 + color: var(--tuiter-muted); 573 + } 574 + 575 + /* Post form */ 576 + .post-form { 577 + margin-bottom: 20px; 578 + } 579 + 580 + .post-form textarea { 581 + width: 100%; 582 + height: 60px; 583 + padding: 5px; 584 + border: 1px solid var(--tuiter-border-muted); 585 + font-family: inherit; 586 + font-size: 11px; 587 + resize: vertical; 588 + } 589 + 590 + .post-form button { 591 + background: var(--tuiter-join-bg); 592 + border: 1px solid var(--tuiter-link); 593 + padding: 4px 12px; 594 + font-size: 11px; 595 + cursor: pointer; 596 + margin-top: 5px; 597 + } 598 + 599 + .post-form button:hover { 600 + background: var(--tuiter-join-hover); 601 + } 602 + 603 + /* Timeline Post Box - 2006 Twitter Style */ 604 + .post-box { 605 + background: rgba(var(--tuiter-white-rgb),0.90); 606 + border: 1px solid var(--tuiter-border); 607 + margin-bottom: 15px; 608 + padding: 10px; 609 + border-radius: 5px; 610 + } 611 + 612 + .post-box-header { 613 + display: flex; 614 + justify-content: space-between; 615 + align-items: center; 616 + margin-bottom: 6px; 617 + } 618 + 619 + .post-box-header h3 { 620 + color: var(--tuiter-text); 621 + font-size: 14px; 622 + font-weight: bold; 623 + margin: 0; 624 + } 625 + 626 + .char-count { 627 + font-size: 11px; 628 + color: var(--tuiter-muted); 629 + } 630 + 631 + .post-box textarea { 632 + width: 100%; 633 + min-height: 60px; 634 + padding: 6px; 635 + font-size: 13px; 636 + border: 1px solid var(--tuiter-border-muted); 637 + border-radius: 4px; 638 + resize: vertical; 639 + background: var(--tuiter-white); 640 + } 641 + 642 + .post-box textarea:focus { 643 + border-color: var(--tuiter-accent); 644 + outline: none; 645 + } 646 + 647 + .post-box-actions { 648 + margin-top: 6px; 649 + text-align: right; 650 + } 651 + 652 + .update-btn { 653 + background: var(--tuiter-input-bg); 654 + border: 1px solid var(--tuiter-border-muted); 655 + padding: 6px 10px; 656 + font-size: 12px; 657 + cursor: pointer; 658 + } 659 + 660 + /* Generic error text */ 661 + .error-text { 662 + color: var(--tuiter-error); 663 + } 664 + .no-posts { 665 + padding: 12px 0; 666 + color: var(--tuiter-muted); 667 + } 668 + 669 + /* Profile header */ 670 + .profile-header { 671 + display: flex; 672 + gap: 16px; 673 + align-items: flex-start; 674 + margin-bottom: 16px; 675 + } 676 + .profile-avatar { 677 + flex: 0 0 96px; 678 + } 679 + .profile-avatar img { 680 + width: 96px; 681 + height: 96px; 682 + border: 1px solid var(--tuiter-border-muted); 683 + border-radius: 2px; 684 + display: block; 685 + } 686 + .profile-avatar-placeholder { 687 + width: 96px; 688 + height: 96px; 689 + background: var(--tuiter-avatar-bg); 690 + display: flex; 691 + align-items: center; 692 + justify-content: center; 693 + border: 1px solid var(--tuiter-border-muted); 694 + border-radius: 2px; 695 + font-size: 36px; 696 + } 697 + .profile-main { 698 + flex: 1; 699 + } 700 + .profile-main h1 { 701 + font-size: 28px; 702 + margin-bottom: 6px; 703 + } 704 + .handle { 705 + font-size: 14px; 706 + color: var(--tuiter-muted); 707 + margin-bottom: 8px; 708 + } 709 + .handle { 710 + font-size: 2em; 711 + font-weight: bold; 712 + color: var(--tuiter-text); 713 + background: rgba(var(--tuiter-white-rgb),0.85); 714 + padding: 0.2em 0.5em; 715 + border-radius: 8px; 716 + box-shadow: 0 1px 4px rgba(var(--tuiter-media-black-rgb),0.12); 717 + display: inline-block; 718 + } 719 + 720 + .bio pre { 721 + font-family: inherit; 722 + font-size: 13px; 723 + line-height: 1.4; 724 + white-space: pre-wrap; 725 + margin: 0 0 10px 0; 726 + } 727 + .stats .stat { 728 + margin-right: 12px; 729 + font-size: 13px; 730 + color: var(--tuiter-text); 731 + } 732 + 733 + /* Replies */ 734 + .reply-post, .reply-avatar, .reply-content { 735 + display: flex; 736 + gap: 8px; 737 + } 738 + .reply-post { 739 + padding: 8px 0; 740 + border-top: 1px solid rgba(var(--tuiter-media-black-rgb),0.04); 741 + } 742 + .reply-avatar img { 743 + width: 24px; 744 + height: 24px; 745 + } 746 + .reply-author { 747 + font-weight: bold; 748 + color: var(--tuiter-link); 749 + text-decoration: none; 750 + } 751 + .reply-text { 752 + margin-left: 6px; 753 + } 754 + 755 + /* View toggle */ 756 + .view-toggle { 757 + margin: 8px 0; 758 + } 759 + .toggle-btn { 760 + background: var(--tuiter-surface-subtle); 761 + border: 1px solid var(--tuiter-border); 762 + padding: 4px 8px; 763 + margin-left: 6px; 764 + cursor: pointer; 765 + border-radius: 4px; 766 + font-size: 12px; 767 + } 768 + .toggle-btn.active { 769 + background: var(--tuiter-toggle-active); 770 + border-color: var(--tuiter-highlight); 771 + } 772 + 773 + /* Flat-view makes replies render as a flat chronological list */ 774 + .child-replies.flat-view .reply-post { 775 + margin-left: 0; 776 + border-left: none; 777 + background: transparent; 778 + } 779 + 780 + /* reduce indentation for nested default */ 781 + .child-replies .reply-post { 782 + margin-left: 12px; 783 + } 784 + 785 + /* control flat/nested visibility */ 786 + .child-replies .flat-list { display: none; } 787 + .child-replies.flat-view .threaded-replies { display: none; } 788 + .child-replies.flat-view .flat-list { display: block; } 789 + /* ensure flat-list items look like normal replies */ 790 + .flat-list .reply-post { border-top: 1px solid var(--tuiter-divider); padding: 8px 0; display: flex; gap: 8px; } 791 + 792 + /* Small helper classes */ 793 + .note { 794 + font-size: 9px; 795 + color: var(--tuiter-muted); 796 + margin-top: 8px; 797 + } 798 + 799 + /* Footer spacing */ 800 + footer { 801 + margin-top: 12px; 802 + } 803 + 804 + /* Conversation chain (parent/ancestor posts) - IRC/Google Wave style */ 805 + .conversation-chain { 806 + display: flex; 807 + flex-direction: column; 808 + gap: 6px; 809 + margin: 10px 0 14px 0; 810 + position: relative; 811 + padding-left: 44px; /* space for avatar + connector */ 812 + } 813 + 814 + /* vertical connector line running alongside the chain */ 815 + .conversation-chain::before { 816 + content: ""; 817 + position: absolute; 818 + left: 28px; 819 + top: 8px; 820 + bottom: 8px; 821 + width: 2px; 822 + background: var(--tuiter-border-subtle); 823 + border-radius: 2px; 824 + } 825 + 826 + .chain-item { 827 + display: flex; 828 + gap: 8px; 829 + align-items: flex-start; 830 + padding: 6px 8px; 831 + border-radius: 6px; 832 + box-shadow: 0 1px 0 rgba(var(--tuiter-media-black-rgb),0.02) inset; 833 + } 834 + 835 + /* alternating subtle backgrounds to mimic chat bubbles */ 836 + .conversation-chain .chain-item:nth-child(odd) { 837 + background: var(--tuiter-surface-warm); /* warm */ 838 + } 839 + .conversation-chain .chain-item:nth-child(even) { 840 + background: var(--tuiter-surface-cool); /* cool */ 841 + } 842 + 843 + .chain-avatar { 844 + position: absolute; 845 + left: 0; 846 + width: 40px; 847 + display: flex; 848 + align-items: center; 849 + justify-content: center; 850 + } 851 + .chain-avatar img { 852 + width: 32px; 853 + height: 32px; 854 + border-radius: 50%; 855 + object-fit: cover; 856 + border: 1px solid rgba(var(--tuiter-media-black-rgb),0.06); 857 + } 858 + 859 + .chain-content { 860 + margin-left: 4px; 861 + } 862 + .chain-author { 863 + font-weight: 700; 864 + color: var(--tuiter-handle-blue); 865 + text-decoration: none; 866 + font-size: 12px; 867 + } 868 + .chain-author:hover { text-decoration: underline; } 869 + .chain-text { 870 + margin-top: 3px; 871 + font-size: 12px; 872 + line-height: 1.3; 873 + color: var(--tuiter-text); 874 + } 875 + .chain-meta { 876 + margin-top: 4px; 877 + font-size: 11px; 878 + color: var(--tuiter-muted); 879 + } 880 + 881 + /* compact chain layout on small viewports */ 882 + @media (max-width: 800px) { 883 + .conversation-chain { 884 + padding-left: 44px; 885 + } 886 + .conversation-chain::before { left: 28px; } 887 + .chain-avatar { left: 0; } 888 + } 889 + 890 + /* Highlight the viewed post */ 891 + .highlighted-post { 892 + border: 2px solid var(--tuiter-highlight); 893 + background: linear-gradient(180deg, rgba(var(--tuiter-white-rgb),1) 0%, rgba(var(--tuiter-white-rgb),1) 100%); 894 + padding: 8px; 895 + border-radius: 8px; 896 + } 897 + 898 + .child-replies { 899 + margin-top: 10px; 900 + } 901 + 902 + /* Remove connector line when conversation chain is rendered inside replies (avoids dark vertical line at left of Replies header) */ 903 + .child-replies .conversation-chain::before { 904 + display: none; 905 + } 906 + 907 + .child-replies h4 { 908 + font-size: 13px; 909 + margin-bottom: 6px; 910 + } 911 + 912 + .reply-post { 913 + padding: 8px 0; 914 + border-top: 1px solid var(--tuiter-divider); 915 + display: flex; 916 + gap: 8px; 917 + } 918 + 919 + .reply-avatar img { width: 28px; height: 28px; border-radius: 50%; } 920 + 921 + .reply-content { flex: 1; } 922 + 923 + /* Forum-style nested threaded replies (2000s forum feel + 2006 Twitter identity) */ 924 + .threaded-replies { 925 + margin-left: 6px; 926 + padding-left: 0; 927 + list-style: none; 928 + font-size: 13px; /* slightly larger for readability like forum posts */ 929 + } 930 + 931 + .thread-node { 932 + display: flex; 933 + gap: 10px; 934 + align-items: flex-start; 935 + margin: 8px 0; /* space between boxed replies like forum posts */ 936 + position: relative; 937 + } 938 + 939 + /* avatar: forum-like small square with border */ 940 + .thread-avatar img { 941 + width: 36px; 942 + height: 36px; 943 + border-radius: 3px; 944 + object-fit: cover; 945 + border: 1px solid var(--tuiter-border-muted); 946 + background: var(--tuiter-surface-subtle); 947 + } 948 + .thread-avatar a { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; } 949 + 950 + /* main content box for each reply - distinct boxed look from old forums */ 951 + .thread-content { 952 + flex: 1; 953 + background: linear-gradient(180deg, var(--tuiter-white), var(--tuiter-surface-subtle)); 954 + border: 1px solid var(--tuiter-thread-border); 955 + border-radius: 6px; 956 + padding: 8px 10px; 957 + box-shadow: 0 1px 0 rgba(var(--tuiter-media-black-rgb),0.03); 958 + } 959 + 960 + /* author and meta remain compact and recognizably 2006 */ 961 + .thread-content .reply-author { 962 + font-weight: 700; 963 + color: var(--tuiter-handle-blue); 964 + font-size: 12px; 965 + text-decoration: none; 966 + } 967 + .thread-content .reply-author:hover { text-decoration: underline; } 968 + 969 + .thread-node .reply-text { 970 + margin-top: 6px; 971 + font-size: 13px; 972 + line-height: 1.35; 973 + color: var(--tuiter-text); 974 + } 975 + .thread-node .reply-meta { 976 + margin-top: 6px; 977 + font-size: 11px; 978 + color: var(--tuiter-muted); 979 + } 980 + 981 + /* children are indented to the right and connected by a vertical rule (forum-style thread connector) */ 982 + .thread-children { 983 + margin-top: 10px; 984 + margin-left: 52px; /* indent children under parent (space for avatar + gutter) */ 985 + position: relative; 986 + } 987 + .thread-children::before { 988 + content: ""; 989 + position: absolute; 990 + left: -42px; /* place connector between avatar and parent box */ 991 + top: 6px; 992 + bottom: 6px; 993 + width: 2px; 994 + background: linear-gradient(180deg, rgba(var(--tuiter-media-black-rgb),0.06), rgba(var(--tuiter-media-black-rgb),0.00)); 995 + border-radius: 2px; 996 + } 997 + 998 + /* subtle alternating backgrounds for depth (gives forum nesting feel) */ 999 + .threaded-replies > .thread-node .thread-content { background: linear-gradient(180deg,var(--tuiter-white),var(--tuiter-surface-subtle)); } 1000 + .threaded-replies > .thread-node > .thread-children > .thread-node .thread-content { background: linear-gradient(180deg,var(--tuiter-surface-warm),var(--tuiter-white)); } 1001 + .threaded-replies > .thread-node > .thread-children > .thread-node > .thread-children > .thread-node .thread-content { background: linear-gradient(180deg,var(--tuiter-surface-cool),var(--tuiter-white)); } 1002 + 1003 + /* connector dot on parent to indicate connection point (keeps previous subtle dot concept) */ 1004 + .thread-node::before { 1005 + content: ""; 1006 + width: 8px; 1007 + height: 8px; 1008 + border-radius: 2px; 1009 + background: var(--tuiter-border-subtle); 1010 + position: absolute; 1011 + left: 28px; /* align near avatar */ 1012 + top: 18px; 1013 + box-shadow: 0 1px 0 rgba(var(--tuiter-media-black-rgb),0.02) inset; 1014 + } 1015 + 1016 + /* keep viewed/highlighted post styling but match new padding */ 1017 + .thread-content.highlighted-post { 1018 + border: 2px solid var(--tuiter-highlight); 1019 + background: linear-gradient(180deg, rgba(var(--tuiter-white-rgb),1) 0%, rgba(var(--tuiter-white-rgb),1) 100%); 1020 + padding: 10px; 1021 + border-radius: 6px; 1022 + } 1023 + 1024 + /* responsive tweaks - reduce avatar and indentation on small screens */ 1025 + @media (max-width: 800px) { 1026 + .thread-avatar img { width: 28px; height: 28px; } 1027 + .thread-children { margin-left: 40px; } 1028 + .thread-node::before { left: 22px; top: 14px; } 1029 + } 1030 + 1031 + /* ensure legacy flat-view rules still hide threaded markup when requested */ 1032 + .child-replies.flat-view .threaded-replies { display: none; } 1033 + /* Media styles for posts (images, video thumbnails, external link cards) */ 1034 + .post-media { margin-top: 8px; display: block; } 1035 + .media-images { display: flex; gap: 8px; flex-wrap: wrap; } 1036 + .post-image { max-width: 220px; max-height: 160px; border: 1px solid var(--tuiter-border-subtle); border-radius: 6px; object-fit: cover; } 1037 + .media-video { margin-top: 6px; } 1038 + .post-video-thumb { max-width: 320px; max-height: 200px; border: 1px solid var(--tuiter-border-subtle); border-radius: 6px; object-fit: cover; } 1039 + .media-external { margin-top: 6px; } 1040 + .external-link-card { display: flex; gap: 8px; align-items: center; text-decoration: none; border: 1px solid var(--tuiter-border-subtle); padding: 6px; border-radius: 6px; background: var(--tuiter-white); } 1041 + .external-thumb { width: 80px; height: 60px; object-fit: cover; border-radius: 4px; } 1042 + .external-meta { color: var(--tuiter-text); font-size: 12px; } 1043 + .external-desc { color: var(--tuiter-muted); font-size: 11px; margin-top: 4px; } 1044 + 1045 + /* Lightbox for viewing full-size images */ 1046 + #lightbox-overlay { 1047 + position: fixed; 1048 + inset: 0; 1049 + background: rgba(var(--tuiter-media-black-rgb),0.85); 1050 + display: none; 1051 + align-items: center; 1052 + justify-content: center; 1053 + z-index: 9999; 1054 + } 1055 + #lightbox-overlay img { 1056 + max-width: 90%; 1057 + max-height: 90%; 1058 + border-radius: 6px; 1059 + box-shadow: 0 8px 30px rgba(var(--tuiter-media-black-rgb),0.6); 1060 + } 1061 + #lightbox-overlay .close-hint { 1062 + position: absolute; 1063 + top: 18px; 1064 + right: 24px; 1065 + color: var(--tuiter-white); 1066 + font-size: 13px; 1067 + opacity: 0.9; 1068 + } 1069 + #lightbox-overlay.visible { display: flex; } 1070 + 1071 + /* Chat-style reply-thread bubbles (2000s-inspired) */ 1072 + .chat { display: flex; flex-direction: column; gap: 8px; } 1073 + .chat-node { display: flex; width: 100%; } 1074 + /* allow wrapping so media can be placed on its own line under the bubble */ 1075 + .chat-node, .reply-thread.chat .chat-node { flex-wrap: wrap; } 1076 + .chat-node.left { justify-content: flex-start; } 1077 + .chat-node.right { justify-content: flex-end; } 1078 + .chat-bubble { 1079 + max-width: 70%; 1080 + padding: 8px 10px; 1081 + border-radius: 14px; 1082 + background: linear-gradient(180deg,var(--tuiter-card),var(--tuiter-surface-cool)); 1083 + border: 1px solid var(--tuiter-border-subtle); 1084 + box-shadow: 0 2px 0 rgba(var(--tuiter-media-black-rgb),0.06); 1085 + font-size: 12px; 1086 + color: var(--tuiter-text); 1087 + position: relative; 1088 + } 1089 + /* Pale blue right bubble to match 2006 Twitter aesthetic */ 1090 + .chat-node.right .chat-bubble { 1091 + background: linear-gradient(180deg,var(--tuiter-cyan-pale),var(--tuiter-cyan-pale-2)); 1092 + color: var(--tuiter-dark-teal); 1093 + border-color: var(--tuiter-cyan-pale); 1094 + } 1095 + 1096 + .chat-author { font-weight: 700; color: var(--tuiter-handle-blue); font-size: 12px; margin-bottom: 4px; } 1097 + /* keep author link color consistent */ 1098 + .chat-node.right .chat-author { color: var(--tuiter-handle-blue); } 1099 + .chat-text { white-space: pre-wrap; line-height: 1.35; } 1100 + 1101 + /* small triangular tail effect (CSS pseudo-element) */ 1102 + .chat-node.left .chat-bubble::after { 1103 + content: ""; 1104 + position: absolute; 1105 + left: -8px; 1106 + top: 10px; 1107 + width: 12px; 1108 + height: 12px; 1109 + background: linear-gradient(180deg,var(--tuiter-card),var(--tuiter-surface-cool)); 1110 + border-right: 1px solid var(--tuiter-border-subtle); 1111 + transform: rotate(45deg); 1112 + border-bottom: 1px solid rgba(var(--tuiter-media-black-rgb),0.03); 1113 + } 1114 + .chat-node.right .chat-bubble::after { 1115 + content: ""; 1116 + position: absolute; 1117 + right: -8px; 1118 + top: 10px; 1119 + width: 12px; 1120 + height: 12px; 1121 + background: linear-gradient(180deg,var(--tuiter-cyan-pale),var(--tuiter-cyan-pale-2)); 1122 + border-left: 1px solid var(--tuiter-cyan-pale); 1123 + transform: rotate(45deg); 1124 + border-top: 1px solid rgba(var(--tuiter-white-rgb),0.6); 1125 + } 1126 + 1127 + .chat-author { font-weight: 700; color: var(--tuiter-handle-blue); font-size: 12px; margin-bottom: 4px; } 1128 + /* keep author link color consistent */ 1129 + .chat-node.right .chat-author { color: var(--tuiter-handle-blue); } 1130 + .chat-text { white-space: pre-wrap; line-height: 1.35; } 1131 + 1132 + /* current post highlight tweaks */ 1133 + .current-post .chat-bubble { box-shadow: 0 3px 0 rgba(var(--tuiter-media-black-rgb),0.08); border-color: var(--tuiter-highlight); } 1134 + 1135 + /* ensure bubbles are responsive */ 1136 + @media (max-width: 800px) { 1137 + .chat-bubble { max-width: 90%; } 1138 + } 1139 + 1140 + /* Avatar next to chat bubbles (tight chat spacing like classic chat UIs) */ 1141 + .chat-node.compact { align-items: center; gap: 6px; } 1142 + .chat-avatar { width: 36px; display:flex; align-items:center; justify-content:center; margin: 0; } 1143 + .chat-avatar img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; border: 1px solid rgba(var(--tuiter-media-black-rgb),0.06); } 1144 + .avatar-placeholder { width: 28px; height: 28px; background: var(--tuiter-avatar-bg); border-radius:50%; border:1px solid var(--tuiter-border-muted); } 1145 + /* tighten spacing between avatar and bubble */ 1146 + .chat-node.left .chat-avatar { margin-right: 6px; } 1147 + .chat-node.right .chat-avatar { margin-left: 6px; } 1148 + .chat-node.left .chat-bubble { margin-left: 4px; } 1149 + .chat-node.right .chat-bubble { margin-right: 4px; } 1150 + /* adjust tail vertical position for tighter layout */ 1151 + .chat-node.left .chat-bubble::after, .chat-node.right .chat-bubble::after { top: 12px; width: 10px; height: 10px; } 1152 + /* ensure compact bubbles remain visually balanced */ 1153 + .chat-bubble.small { padding: 6px 8px; } 1154 + 1155 + /* Flattened chat style tweaks */ 1156 + .chat.flattened .chat-node.compact { gap: 6px; align-items: flex-start; } 1157 + .chat-bubble.small { padding: 6px 8px; border-radius: 8px; font-size: 12px; max-width: 60%; } 1158 + .chat-bubble.small .chat-author { font-size: 12px; margin-bottom: 3px; } 1159 + .chat-bubble.small .chat-text { font-size: 12px; line-height: 1.3; } 1160 + .chat-meta { margin-top: 6px; font-size: 11px; color: var(--tuiter-muted); } 1161 + .chat-meta a { color: var(--tuiter-link); text-decoration: none; } 1162 + .chat-meta a:hover { text-decoration: underline; } 1163 + 1164 + /* make bubbles occupy less vertical space and appear flatter */ 1165 + .chat-bubble.small { box-shadow: none; border: 1px solid rgba(var(--tuiter-media-black-rgb),0.06); } 1166 + .chat-node.left .chat-bubble.small::after, .chat-node.right .chat-bubble.small::after { width: 10px; height: 10px; top: 12px; } 1167 + 1168 + /* slightly tighter avatar size for compact view */ 1169 + .chat-avatar img { width: 28px; height: 28px; } 1170 + .avatar-placeholder { width: 28px; height: 28px; } 1171 + 1172 + /* ensure compact nodes don't take too much horizontal space */ 1173 + .chat-node.compact .chat-bubble.small { max-width: 55%; } 1174 + @media (max-width: 800px) { 1175 + .chat-node.compact .chat-bubble.small { max-width: 78%; } 1176 + } 1177 + 1178 + /* Right-side bubble text alignment for author and timestamp only */ 1179 + .chat-node.right .chat-bubble { text-align: left; } 1180 + .chat-node.right .chat-author { display: block; text-align: right; } 1181 + .chat-node.right .chat-meta { text-align: right; } 1182 + 1183 + /* IRC-like conversation container for reply threads (2006 aesthetic) */ 1184 + .reply-thread.chat.flattened { 1185 + background: linear-gradient(160deg,var(--tuiter-white),var(--tuiter-cyan-pale)); 1186 + border: 1px solid var(--tuiter-cyan-pale); /* soft cyan border */ 1187 + border-radius: 6px; /* small, square-rounded corners */ 1188 + padding: 10px; /* snug padding */ 1189 + box-shadow: inset 0 1px 0 rgba(var(--tuiter-white-rgb),0.6), 0 1px 0 rgba(var(--tuiter-media-black-rgb),0.03); 1190 + margin: 8px 0; 1191 + width: 100%; 1192 + } 1193 + .reply-thread.chat.flattened .chat-node { margin-bottom: 6px; } 1194 + .reply-thread.chat.flattened .chat-bubble { background: transparent; border: none; box-shadow: none; } 1195 + .reply-thread.chat.flattened .chat-bubble.small { background: linear-gradient(180deg,var(--tuiter-white),var(--tuiter-surface-cool)); border: 1px solid rgba(var(--tuiter-media-black-rgb),0.06); } 1196 + /* reduce overall spacing to feel IRC-like */ 1197 + .reply-thread.chat.flattened .chat-avatar { margin-right: 8px; } 1198 + .reply-thread.chat.flattened .chat-node.right .chat-avatar { margin-left: 8px; margin-right: 0; } 1199 + 1200 + /* Tight avatar->bubble attachment: remove spacing so bubble appears to come out of the avatar */ 1201 + .chat-node.compact { gap: 0 !important; align-items: center; } 1202 + .chat-avatar { margin: 0 !important; position: relative; z-index: 3; } 1203 + .chat-avatar img { position: relative; z-index: 4; } 1204 + 1205 + /* remove any horizontal margins so bubble and avatar touch */ 1206 + .chat-node.left .chat-avatar { margin-right: 0 !important; } 1207 + .chat-node.right .chat-avatar { margin-left: 0 !important; } 1208 + 1209 + /* overlap the bubble slightly under the avatar so it looks like it emerges from it */ 1210 + .chat-node.left .chat-bubble { margin-left: -8px !important; z-index: 1; } 1211 + .chat-node.right .chat-bubble { margin-right: -8px !important; z-index: 1; } 1212 + 1213 + /* flattened thread variants */ 1214 + .reply-thread.chat.flattened .chat-avatar { margin-right: 0 !important; } 1215 + .reply-thread.chat.flattened .chat-node.right .chat-avatar { margin-left: 0 !important; } 1216 + 1217 + /* bring the avatar visually in front and nudge the triangular tail to meet the avatar edge */ 1218 + .chat-node.left .chat-bubble::after { left: -4px !important; top: 12px !important; width: 10px !important; height: 10px !important; } 1219 + .chat-node.right .chat-bubble::after { right: -4px !important; top: 12px !important; width: 10px !important; height: 10px !important; } 1220 + 1221 + /* ensure small/compact bubbles also follow the same tight layout */ 1222 + .chat-bubble.small { z-index: 1; } 1223 + .chat-node.compact .chat-bubble.small { margin-left: -6px !important; margin-right: -6px !important; } 1224 + 1225 + /* make sure the avatar border visually connects with the bubble by slightly reducing avatar border contrast */ 1226 + .chat-avatar img { border: 1px solid rgba(var(--tuiter-media-black-rgb),0.04); } 1227 + 1228 + /* Chat-media: ensure media appears below the bubble and aligns to the same side (left/right) */ 1229 + .chat-media { 1230 + display: block; 1231 + /* force the media element to occupy the full row inside the .chat-node flex container */ 1232 + flex-basis: 100%; 1233 + order: 99; /* place after avatar + bubble */ 1234 + margin-top: 6px; 1235 + width: 100%; 1236 + clear: both; /* ensure it sits below any floated/inline elements */ 1237 + } 1238 + 1239 + /* Ensure the shared post-media behaves as a block-level card, but can be centered/right-aligned by its container */ 1240 + .post-media { display: inline-block; } 1241 + 1242 + /* Offset the media so it lines up under the bubble, leaving space for the avatar */ 1243 + .chat-node.left .chat-media { 1244 + text-align: left; 1245 + padding-left: 44px; /* space for avatar + small gutter */ 1246 + padding-right: 0; 1247 + } 1248 + 1249 + .chat-node.right .chat-media { 1250 + text-align: right; 1251 + padding-right: 44px; /* space for avatar + small gutter */ 1252 + padding-left: 0; 1253 + } 1254 + 1255 + /* Keep individual media items inline-block so they respect text-align on the container */ 1256 + .chat-media .post-media .media-images, 1257 + .chat-media .post-media .media-video, 1258 + .chat-media .post-media .media-external { 1259 + display: inline-block; 1260 + vertical-align: top; 1261 + } 1262 + 1263 + /* Responsive tweak: reduce offset on small screens */ 1264 + @media (max-width: 800px) { 1265 + .chat-node.left .chat-media, 1266 + .chat-node.right .chat-media { 1267 + padding-left: 24px; 1268 + padding-right: 24px; 1269 + } 1270 + .chat-media .post-media { max-width: 100%; } 1271 + } 1272 + 1273 + /* Added rules to replace inline styles moved from templates */ 1274 + .post-avatar-img { 1275 + width: 48px; 1276 + height: 48px; 1277 + border-radius: 50%; 1278 + object-fit: cover; 1279 + } 1280 + 1281 + .post-author-img { 1282 + width: 48px; 1283 + height: 48px; 1284 + border-radius: 2px; 1285 + object-fit: cover; 1286 + } 1287 + 1288 + .post-content-inline { 1289 + display: inline-block; 1290 + vertical-align: top; 1291 + width: calc(100% - 56px); 1292 + } 1293 + 1294 + .post-box-textarea { 1295 + resize: none; 1296 + background: var(--tuiter-input-bg); 1297 + border-radius: 6px; 1298 + border: 1px solid var(--tuiter-border-muted); 1299 + color: var(--tuiter-text); 1300 + width: 100%; 1301 + min-height: 60px; 1302 + padding: 6px; 1303 + } 1304 + 1305 + .update-btn-large { 1306 + background: var(--tuiter-brand); 1307 + color: var(--tuiter-white); 1308 + border: none; 1309 + border-radius: 6px; 1310 + font-weight: bold; 1311 + padding: 8px 22px; 1312 + font-size: 1.1em; 1313 + box-shadow: 0 2px 6px rgba(var(--tuiter-media-black-rgb),0.08); 1314 + cursor: pointer; 1315 + transition: background 0.2s; 1316 + } 1317 + 1318 + .chain-avatar-img { 1319 + width: 32px; 1320 + height: 32px; 1321 + border-radius: 50%; 1322 + object-fit: cover; 1323 + } 1324 + 1325 + .profile-avatar-img { 1326 + width: 96px; 1327 + height: 96px; 1328 + border: 1px solid var(--tuiter-border-muted); 1329 + border-radius: 2px; 1330 + display: block; 1331 + } 1332 + 1333 + .video-embedded { 1334 + max-width: 100%; 1335 + height: auto; 1336 + background: var(--tuiter-media-black); 1337 + } 1338 + 1339 + #lightbox-video { 1340 + max-width: 100%; 1341 + max-height: 80vh; 1342 + display: none; 1343 + } 1344 + 1345 + #lightbox-img { max-width: 100%; max-height: 80vh; display: block; } 1346 + 1347 + /* make lightbox overlay hidden by default and visible when .visible is present */ 1348 + #lightbox-overlay { display: none; position: fixed; inset: 0; background: rgba(var(--tuiter-media-black-rgb),0.8); align-items: center; justify-content: center; z-index: 9999; } 1349 + #lightbox-overlay.visible { display: flex; } 1350 + /* Enhanced styling for standard (non-reply) posts inserted to match 2006 retro layout with better spacing */ 1351 + .post-avatar { 1352 + flex: 0 0 56px; /* reserve space for avatar */ 1353 + display: flex; 1354 + align-items: flex-start; 1355 + justify-content: center; 1356 + margin-top: 2px; 1357 + } 1358 + 1359 + .post-avatar a { display:inline-block; } 1360 + .post-avatar img { width:48px; height:48px; border-radius:4px; border:1px solid var(--tuiter-border-muted); object-fit:cover; } 1361 + 1362 + .post-content.post-card { 1363 + background: linear-gradient(180deg, rgba(var(--tuiter-white-rgb),0.98), var(--tuiter-surface-subtle)); 1364 + border: 1px solid rgba(var(--tuiter-media-black-rgb),0.04); 1365 + border-radius: 6px; 1366 + padding: 8px 10px; 1367 + width: 100%; 1368 + } 1369 + 1370 + .post-row { 1371 + display: flex; 1372 + flex-direction: column; 1373 + gap: 6px; 1374 + } 1375 + 1376 + .post-head { 1377 + display: flex; 1378 + gap: 8px; 1379 + align-items: center; 1380 + } 1381 + 1382 + .post-head .post-author { 1383 + font-weight: 700; 1384 + color: var(--tuiter-handle-blue); 1385 + font-size: 13px; 1386 + text-decoration: none; 1387 + } 1388 + 1389 + .post-handle { 1390 + font-size: 12px; 1391 + color: var(--tuiter-muted); 1392 + margin-left: 6px; 1393 + } 1394 + 1395 + .post-meta-inline { 1396 + margin-left: auto; 1397 + font-size: 11px; 1398 + color: var(--tuiter-muted); 1399 + white-space: nowrap; 1400 + } 1401 + 1402 + .post-body { 1403 + margin-top: 4px; 1404 + display: block; 1405 + } 1406 + 1407 + .post-text { 1408 + font-size: 12px; 1409 + line-height: 1.35; 1410 + color: var(--tuiter-text); 1411 + } 1412 + 1413 + .post-actions { 1414 + margin-top: 8px; 1415 + font-size: 11px; 1416 + } 1417 + 1418 + /* Make post-media sit comfortably under the text and align with content area */ 1419 + .post-content .post-media { margin-top: 8px; } 1420 + 1421 + /* Slightly increase spacing between posts for readability */ 1422 + .post { padding: 14px 0; } 1423 + 1424 + /* Retro subtle hover highlight for posts (non-reply) */ 1425 + .post:hover .post-content.post-card { box-shadow: 0 4px 14px rgba(var(--tuiter-media-black-rgb),0.06); transform: translateY(-1px); } 1426 + 1427 + /* Small screens: reduce avatar and padding to keep content readable */ 1428 + @media (max-width: 640px) { 1429 + .post-avatar { flex: 0 0 44px; } 1430 + .post-avatar img { width:40px; height:40px; } 1431 + .post-content.post-card { padding: 8px; } 1432 + .post-meta-inline { font-size: 10px; } 1433 + } 1434 + 1435 + /* Reply floating button (circular) shown at bottom-right of post content cards and chat bubbles */ 1436 + .reply-button { 1437 + position: absolute; 1438 + right: 10px; 1439 + bottom: 10px; 1440 + display: inline-flex; 1441 + align-items: center; 1442 + justify-content: center; 1443 + width: 28px; /* reduced from 32px */ 1444 + height: 28px; /* reduced from 32px */ 1445 + border-radius: 50%; 1446 + background: rgba(var(--tuiter-white-rgb),0.72); /* more translucent */ 1447 + border: 1px solid rgba(var(--tuiter-media-black-rgb),0.03); /* subtler border */ 1448 + box-shadow: 0 1px 3px rgba(var(--tuiter-media-black-rgb),0.04); /* very light shadow */ 1449 + z-index: 30; 1450 + padding: 0; 1451 + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s, opacity 0.12s; 1452 + opacity: 0.96; 1453 + } 1454 + 1455 + .reply-button:hover { 1456 + background: rgba(var(--tuiter-accent),0.98); 1457 + color: rgba(var(--tuiter-white-rgb),0.98); 1458 + box-shadow: 0 2px 6px rgba(var(--tuiter-media-black-rgb),0.06); 1459 + transform: translateY(-1px); 1460 + opacity: 1; 1461 + } 1462 + 1463 + .reply-button .reply-btn { 1464 + width: 100%; 1465 + height: 100%; 1466 + border: none; 1467 + background: transparent; 1468 + font-size: 12px; /* slightly smaller icon/text */ 1469 + line-height: 1; 1470 + cursor: pointer; 1471 + color: var(--tuiter-muted); 1472 + border-radius: 50%; 1473 + display: inline-flex; 1474 + align-items: center; 1475 + justify-content: center; 1476 + position: relative; /* allow nudging the arrow down */ 1477 + top: 2px; /* nudge arrow a bit downward */ 1478 + } 1479 + .reply-button .reply-btn:hover, .reply-button.active .reply-btn { 1480 + background-color: var(--tuiter-handle-blue); 1481 + color: var(--tuiter-white); 1482 + } 1483 + 1484 + /* notification-style badge for count - monochrome and low-contrast by default */ 1485 + .reply-button .reply-count { 1486 + position: absolute; 1487 + top: -2px; /* moved slightly down from -5px */ 1488 + right: -5px; 1489 + min-width: 14px; /* reduced from 18px */ 1490 + height: 14px; /* reduced from 18px */ 1491 + padding: 0 4px; 1492 + display: inline-flex; 1493 + align-items: center; 1494 + justify-content: center; 1495 + font-size: 10px; /* slightly smaller */ 1496 + font-weight: 700; 1497 + color: var(--tuiter-white); 1498 + background: rgba(var(--tuiter-media-black-rgb),0.28); /* monochrome, translucent */ 1499 + border-radius: 999px; 1500 + border: 1px solid rgba(var(--tuiter-white-rgb),0.85); 1501 + box-shadow: none; /* remove ornamental shadow */ 1502 + transition: background 0.12s ease, transform 0.12s ease, opacity 0.12s; 1503 + opacity: 0.92; 1504 + pointer-events: none; 1505 + } 1506 + .reply-button:hover .reply-count { 1507 + background: rgba(var(--tuiter-media-black-rgb),0.5); /* darker on hover to show affordance */ 1508 + opacity: 1; 1509 + } 1510 + 1511 + /* Use accent color for the reply-count badge when the button is hovered (main, chat and bubble variants) */ 1512 + .reply-button:hover .reply-count, 1513 + .chat-reply-button:hover .reply-count, 1514 + .chat-bubble .reply-button:hover .reply-count { 1515 + background: var(--tuiter-accent); 1516 + color: var(--tuiter-white); 1517 + border-color: rgba(var(--tuiter-white-rgb),0.9); 1518 + opacity: 1; 1519 + } 1520 + 1521 + .chat-reply-button { width: 26px; height: 26px; right: 6px; bottom: 6px; background: rgba(var(--tuiter-white-rgb),0.74); box-shadow: 0 1px 3px rgba(var(--tuiter-media-black-rgb),0.04); } 1522 + .chat-reply-button .reply-btn { font-size: 11px; position: relative; top: 1px; color: var(--tuiter-muted); } 1523 + .chat-reply-button .reply-count { top: -3px; right: -6px; min-width: 12px; height: 12px; font-size: 9px; background: rgba(var(--tuiter-media-black-rgb),0.28); color: var(--tuiter-white); border: 1px solid rgba(var(--tuiter-white-rgb),0.85); box-shadow: none; } 1524 + 1525 + /* small screen adjustments */ 1526 + @media (max-width: 640px) { 1527 + .reply-button { width: 26px; height: 26px; right: 8px; bottom: 8px; } 1528 + .reply-button .reply-count { top: -3px; right: -5px; min-width: 12px; height: 12px; font-size: 9px; } 1529 + } 1530 + 1531 + /* ensure reply button doesn't obstruct essential text - slightly transparent when overlapping text */ 1532 + .post-content.post-card .reply-button { background: rgba(var(--tuiter-white-rgb),0.68); } 1533 + 1534 + /* Allow absolute-positioned reply button to anchor to chat bubbles */ 1535 + .chat-bubble { position: relative; } 1536 + 1537 + /* Reply button when placed inside a chat bubble (smaller, tucked in) */ 1538 + .chat-bubble .reply-button { 1539 + position: absolute; /* anchor to bubble */ 1540 + bottom: 6px; 1541 + width: 24px; /* reduced from 26/30 */ 1542 + height: 24px; /* reduced */ 1543 + min-width: 24px; 1544 + border-radius: 50%; 1545 + box-shadow: 0 1px 3px rgba(var(--tuiter-media-black-rgb),0.04); 1546 + background: rgba(var(--tuiter-white-rgb),0.74); 1547 + border: 1px solid rgba(var(--tuiter-media-black-rgb),0.03); 1548 + padding: 0; 1549 + z-index: 25; 1550 + transition: background 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; 1551 + } 1552 + .chat-bubble .reply-button:hover { background: rgba(var(--tuiter-white-rgb),0.96); box-shadow: 0 2px 5px rgba(var(--tuiter-media-black-rgb),0.05); transform: translateY(-1px); } 1553 + .chat-bubble .reply-button .reply-btn { font-size: 11px; position: relative; top: 3px; } 1554 + .chat-bubble .reply-button .reply-count { top: 1px; right: -2px; min-width: 11px; height: 11px; font-size: 9px; background: rgba(var(--tuiter-media-black-rgb),0.28); color: var(--tuiter-white); border: 1px solid rgba(var(--tuiter-white-rgb),0.85); box-shadow: none; opacity: 0.92; } 1555 + 1556 + /* left-side bubble: place button at bubble's lower-right (so arrow visually points toward avatar on left) */ 1557 + .chat-bubble .reply-button.side-left { right: -26px; left: auto; } 1558 + 1559 + /* right-side bubble: place button at bubble's lower-left (so arrow visually points toward avatar on right) */ 1560 + .chat-bubble .reply-button.side-right { left: -26px; right: auto; } 1561 + 1562 + /* Ensure badge stands out on top of bubble */ 1563 + .chat-bubble .reply-button .reply-count { border: 1px solid rgba(var(--tuiter-white-rgb),0.9); } 1564 + 1565 + /* Keep previous chat-reply-button class for other placements but prefer bubble-scoped rules for bubbles */ 1566 + .reply-button .reply-count, 1567 + .chat-reply-button .reply-count, 1568 + .chat-bubble .reply-button .reply-count { 1569 + transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease, transform 0.12s ease, opacity 0.12s ease; 1570 + } 1571 + 1572 + /* Hide reply button by default and reveal on hover of the post/chat container */ 1573 + .reply-button { 1574 + /* keep existing sizing/appearance but start hidden */ 1575 + opacity: 0; 1576 + transform: translateY(4px) scale(0.98); 1577 + pointer-events: none; 1578 + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s, opacity 0.18s ease; 1579 + } 1580 + 1581 + /* Reveal when user hovers the relevant container (post card, post wrapper or chat bubble) */ 1582 + .post:hover .reply-button, 1583 + .post-content.post-card:hover .reply-button, 1584 + .post-content.post-card:focus-within .reply-button, 1585 + .post:hover .post-content.post-card .reply-button, 1586 + .chat-node:hover .reply-button, 1587 + .chat-bubble:hover .reply-button, 1588 + .thread-node:hover .reply-button { 1589 + opacity: 0.96; /* match previous visible state */ 1590 + transform: translateY(0) scale(1); 1591 + pointer-events: auto; 1592 + } 1593 + 1594 + /* On touch devices (no hover), keep button visible so it's usable */ 1595 + @media (hover: none) { 1596 + .reply-button { 1597 + opacity: 0.96; 1598 + transform: none; 1599 + pointer-events: auto; 1600 + } 1601 + } 1602 + 1603 + 1604 + /* fav floating button (circular) shown at bottom-right of post content cards and chat bubbles */ 1605 + .fav-button { 1606 + position: absolute; 1607 + right: 40px; 1608 + bottom: 10px; 1609 + display: inline-flex; 1610 + align-items: center; 1611 + justify-content: center; 1612 + width: 28px; /* reduced from 32px */ 1613 + height: 28px; /* reduced from 32px */ 1614 + border-radius: 50%; 1615 + background: rgba(var(--tuiter-white-rgb),0.72); /* more translucent */ 1616 + border: 1px solid rgba(var(--tuiter-media-black-rgb),0.03); /* subtler border */ 1617 + box-shadow: 0 1px 3px rgba(var(--tuiter-media-black-rgb),0.04); /* very light shadow */ 1618 + z-index: 30; 1619 + padding: 0; 1620 + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s, opacity 0.12s; 1621 + opacity: 0.96; 1622 + } 1623 + 1624 + .fav-button:hover { 1625 + background: rgba(var(--tuiter-highlight),0.98); 1626 + color: rgba(var(--tuiter-white-rgb),0.98); 1627 + box-shadow: 0 2px 6px rgba(var(--tuiter-media-black-rgb),0.06); 1628 + transform: translateY(-1px); 1629 + opacity: 1; 1630 + } 1631 + 1632 + .fav-button .fav-btn { 1633 + width: 100%; 1634 + height: 100%; 1635 + border: none; 1636 + background: transparent; 1637 + font-size: 12px; /* slightly smaller icon/text */ 1638 + line-height: 1; 1639 + cursor: pointer; 1640 + color: var(--tuiter-muted); 1641 + border-radius: 50%; 1642 + display: inline-flex; 1643 + align-items: center; 1644 + justify-content: center; 1645 + position: relative; /* allow nudging the arrow down */ 1646 + top: 2px; /* nudge arrow a bit downward */ 1647 + } 1648 + .fav-button .fav-btn:hover, .fav-button.active .fav-btn { 1649 + background-color: var(--tuiter-highlight); 1650 + color: var(--tuiter-white); 1651 + } 1652 + 1653 + /* notification-style badge for count - monochrome and low-contrast by default */ 1654 + .fav-button .fav-count { 1655 + position: absolute; 1656 + top: -2px; /* moved slightly down from -5px */ 1657 + right: -5px; 1658 + min-width: 14px; /* reduced from 18px */ 1659 + height: 14px; /* reduced from 18px */ 1660 + padding: 0 4px; 1661 + display: inline-flex; 1662 + align-items: center; 1663 + justify-content: center; 1664 + font-size: 10px; /* slightly smaller */ 1665 + font-weight: 700; 1666 + color: var(--tuiter-white); 1667 + background: rgba(var(--tuiter-media-black-rgb),0.28); /* monochrome, translucent */ 1668 + border-radius: 999px; 1669 + border: 1px solid rgba(var(--tuiter-white-rgb),0.85); 1670 + box-shadow: none; /* remove ornamental shadow */ 1671 + transition: background 0.12s ease, transform 0.12s ease, opacity 0.12s; 1672 + opacity: 0.92; 1673 + pointer-events: none; 1674 + } 1675 + .fav-button:hover .fav-count { 1676 + background: rgba(var(--tuiter-media-black-rgb),0.5); /* darker on hover to show affordance */ 1677 + opacity: 1; 1678 + } 1679 + 1680 + /* Use accent color for the fav-count badge when the button is hovered (main, chat and bubble variants) */ 1681 + .fav-button:hover .fav-count, 1682 + .chat-fav-button:hover .fav-count, 1683 + .chat-bubble .fav-button:hover .fav-count { 1684 + background: var(--tuiter-highlight); 1685 + color: var(--tuiter-white); 1686 + border-color: rgba(var(--tuiter-white-rgb),0.9); 1687 + opacity: 1; 1688 + } 1689 + 1690 + .chat-fav-button { width: 26px; height: 26px; right: 6px; bottom: 6px; background: rgba(var(--tuiter-white-rgb),0.74); box-shadow: 0 1px 3px rgba(var(--tuiter-media-black-rgb),0.04); } 1691 + .chat-fav-button .fav-btn { font-size: 11px; position: relative; top: 1px; color: var(--tuiter-muted); } 1692 + .chat-fav-button .fav-count { top: -3px; right: -6px; min-width: 12px; height: 12px; font-size: 9px; background: rgba(var(--tuiter-media-black-rgb),0.28); color: var(--tuiter-white); border: 1px solid rgba(var(--tuiter-white-rgb),0.85); box-shadow: none; } 1693 + 1694 + /* small screen adjustments */ 1695 + @media (max-width: 640px) { 1696 + .fav-button { width: 26px; height: 26px; right: 8px; bottom: 8px; } 1697 + .fav-button .fav-count { top: -3px; right: -5px; min-width: 12px; height: 12px; font-size: 9px; } 1698 + } 1699 + 1700 + /* ensure fav button doesn't obstruct essential text - slightly transparent when overlapping text */ 1701 + .post-content.post-card .fav-button { background: rgba(var(--tuiter-white-rgb),0.68); } 1702 + 1703 + /* Allow absolute-positioned fav button to anchor to chat bubbles */ 1704 + .chat-bubble { position: relative; } 1705 + 1706 + /* fav button when placed inside a chat bubble (smaller, tucked in) */ 1707 + .chat-bubble .fav-button { 1708 + position: absolute; /* anchor to bubble */ 1709 + bottom: 6px; 1710 + width: 24px; /* reduced from 26/30 */ 1711 + height: 24px; /* reduced */ 1712 + min-width: 24px; 1713 + border-radius: 50%; 1714 + box-shadow: 0 1px 3px rgba(var(--tuiter-media-black-rgb),0.04); 1715 + background: rgba(var(--tuiter-white-rgb),0.74); 1716 + border: 1px solid rgba(var(--tuiter-media-black-rgb),0.03); 1717 + padding: 0; 1718 + z-index: 25; 1719 + transition: background 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; 1720 + } 1721 + .chat-bubble .fav-button:hover { background: rgba(var(--tuiter-white-rgb),0.96); box-shadow: 0 2px 5px rgba(var(--tuiter-media-black-rgb),0.05); transform: translateY(-1px); } 1722 + .chat-bubble .fav-button .fav-btn { font-size: 11px; position: relative; top: 3px; } 1723 + .chat-bubble .fav-button .fav-count { top: 1px; right: -2px; min-width: 11px; height: 11px; font-size: 9px; background: rgba(var(--tuiter-media-black-rgb),0.28); color: var(--tuiter-white); border: 1px solid rgba(var(--tuiter-white-rgb),0.85); box-shadow: none; opacity: 0.92; } 1724 + 1725 + /* left-side bubble: place button at bubble's lower-right (so arrow visually points toward avatar on left) */ 1726 + .chat-bubble .fav-button.side-left { right: -26px; left: auto; bottom: 32px; } 1727 + 1728 + /* right-side bubble: place button at bubble's lower-left (so arrow visually points toward avatar on right) */ 1729 + .chat-bubble .fav-button.side-right { left: -26px; right: auto; bottom: 32px; } 1730 + 1731 + /* Ensure badge stands out on top of bubble */ 1732 + .chat-bubble .fav-button .fav-count { border: 1px solid rgba(var(--tuiter-white-rgb),0.9); } 1733 + 1734 + /* Keep previous chat-fav-button class for other placements but prefer bubble-scoped rules for bubbles */ 1735 + .fav-button .fav-count, 1736 + .chat-fav-button .fav-count, 1737 + .chat-bubble .fav-button .fav-count { 1738 + transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease, transform 0.12s ease, opacity 0.12s ease; 1739 + } 1740 + 1741 + 1742 + /* Hide fav button by default and reveal on hover of the post/chat container */ 1743 + .fav-button { 1744 + /* keep existing sizing/appearance but start hidden */ 1745 + opacity: 0; 1746 + transform: translateY(4px) scale(0.98); 1747 + pointer-events: none; 1748 + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s, opacity 0.18s ease; 1749 + } 1750 + 1751 + /* Reveal when user hovers the relevant container (post card, post wrapper or chat bubble) */ 1752 + .post:hover .fav-button, 1753 + .post-content.post-card:hover .fav-button, 1754 + .post-content.post-card:focus-within .fav-button, 1755 + .post:hover .post-content.post-card .fav-button, 1756 + .chat-node:hover .fav-button, 1757 + .chat-bubble:hover .fav-button, 1758 + .thread-node:hover .fav-button { 1759 + opacity: 0.96; /* match previous visible state */ 1760 + transform: translateY(0) scale(1); 1761 + pointer-events: auto; 1762 + } 1763 + 1764 + /* On touch devices (no hover), keep button visible so it's usable */ 1765 + @media (hover: none) { 1766 + .fav-button { 1767 + opacity: 0.96; 1768 + transform: none; 1769 + pointer-events: auto; 1770 + } 1771 + } 1772 + 1773 + /* rt floating button (circular) shown at bottom-right of post content cards and chat bubbles */ 1774 + .rt-button { 1775 + position: absolute; 1776 + right: 70px; 1777 + bottom: 10px; 1778 + display: inline-flex; 1779 + align-items: center; 1780 + justify-content: center; 1781 + width: 28px; /* reduced from 32px */ 1782 + height: 28px; /* reduced from 32px */ 1783 + border-radius: 50%; 1784 + background: rgba(var(--tuiter-white-rgb),0.72); /* more translucent */ 1785 + border: 1px solid rgba(var(--tuiter-media-black-rgb),0.03); /* subtler border */ 1786 + box-shadow: 0 1px 3px rgba(var(--tuiter-media-black-rgb),0.04); /* very light shadow */ 1787 + z-index: 30; 1788 + padding: 0; 1789 + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s, opacity 0.12s; 1790 + opacity: 0.96; 1791 + } 1792 + 1793 + .rt-button:hover { 1794 + background: rgba(var(--tuiter-sidebar-bg),0.98); 1795 + color: rgba(var(--tuiter-white-rgb),0.98); 1796 + box-shadow: 0 2px 6px rgba(var(--tuiter-media-black-rgb),0.06); 1797 + transform: translateY(-1px); 1798 + opacity: 1; 1799 + } 1800 + 1801 + .rt-button .rt-btn { 1802 + width: 100%; 1803 + height: 100%; 1804 + border: none; 1805 + background: transparent; 1806 + font-size: 12px; /* slightly smaller icon/text */ 1807 + line-height: 1; 1808 + cursor: pointer; 1809 + color: var(--tuiter-muted); 1810 + border-radius: 50%; 1811 + display: inline-flex; 1812 + align-items: center; 1813 + justify-content: center; 1814 + position: relative; /* allow nudging the arrow down */ 1815 + top: 2px; /* nudge arrow a bit downward */ 1816 + } 1817 + .rt-button .rt-btn:hover, .rt-button.active .rt-btn { 1818 + background-color: var(--tuiter-sidebar-bg); 1819 + color: var(--tuiter-white); 1820 + } 1821 + 1822 + /* notification-style badge for count - monochrome and low-contrast by default */ 1823 + .rt-button .rt-count { 1824 + position: absolute; 1825 + top: -2px; /* moved slightly down from -5px */ 1826 + right: -5px; 1827 + min-width: 14px; /* reduced from 18px */ 1828 + height: 14px; /* reduced from 18px */ 1829 + padding: 0 4px; 1830 + display: inline-flex; 1831 + align-items: center; 1832 + justify-content: center; 1833 + font-size: 10px; /* slightly smaller */ 1834 + font-weight: 700; 1835 + color: var(--tuiter-white); 1836 + background: rgba(var(--tuiter-media-black-rgb),0.28); /* monochrome, translucent */ 1837 + border-radius: 999px; 1838 + border: 1px solid rgba(var(--tuiter-white-rgb),0.85); 1839 + box-shadow: none; /* remove ornamental shadow */ 1840 + transition: background 0.12s ease, transform 0.12s ease, opacity 0.12s; 1841 + opacity: 0.92; 1842 + pointer-events: none; 1843 + } 1844 + .rt-button:hover .rt-count { 1845 + background: rgba(var(--tuiter-media-black-rgb),0.5); /* darker on hover to show affordance */ 1846 + opacity: 1; 1847 + } 1848 + 1849 + /* Use accent color for the rt-count badge when the button is hovered (main, chat and bubble variants) */ 1850 + .rt-button:hover .rt-count, 1851 + .chat-rt-button:hover .rt-count, 1852 + .chat-bubble .rt-button:hover .rt-count { 1853 + background: var(--tuiter-sidebar-bg); 1854 + color: var(--tuiter-white); 1855 + border-color: rgba(var(--tuiter-white-rgb),0.9); 1856 + opacity: 1; 1857 + } 1858 + 1859 + .chat-rt-button { width: 26px; height: 26px; right: 6px; bottom: 6px; background: rgba(var(--tuiter-white-rgb),0.74); box-shadow: 0 1px 3px rgba(var(--tuiter-media-black-rgb),0.04); } 1860 + .chat-rt-button .rt-btn { font-size: 11px; position: relative; top: 1px; color: var(--tuiter-muted); } 1861 + .chat-rt-button .rt-count { top: -3px; right: -6px; min-width: 12px; height: 12px; font-size: 9px; background: rgba(var(--tuiter-media-black-rgb),0.28); color: var(--tuiter-white); border: 1px solid rgba(var(--tuiter-white-rgb),0.85); box-shadow: none; } 1862 + 1863 + /* small screen adjustments */ 1864 + @media (max-width: 640px) { 1865 + .rt-button { width: 26px; height: 26px; right: 8px; bottom: 8px; } 1866 + .rt-button .rt-count { top: -3px; right: -5px; min-width: 12px; height: 12px; font-size: 9px; } 1867 + } 1868 + 1869 + /* ensure rt button doesn't obstruct essential text - slightly transparent when overlapping text */ 1870 + .post-content.post-card .rt-button { background: rgba(var(--tuiter-white-rgb),0.68); } 1871 + 1872 + /* Allow absolute-positioned rt button to anchor to chat bubbles */ 1873 + .chat-bubble { position: relative; } 1874 + 1875 + /* rt button when placed inside a chat bubble (smaller, tucked in) */ 1876 + .chat-bubble .rt-button { 1877 + position: absolute; /* anchor to bubble */ 1878 + bottom: 6px; 1879 + width: 24px; /* reduced from 26/30 */ 1880 + height: 24px; /* reduced */ 1881 + min-width: 24px; 1882 + border-radius: 50%; 1883 + box-shadow: 0 1px 3px rgba(var(--tuiter-media-black-rgb),0.04); 1884 + background: rgba(var(--tuiter-white-rgb),0.74); 1885 + border: 1px solid rgba(var(--tuiter-media-black-rgb),0.03); 1886 + padding: 0; 1887 + z-index: 25; 1888 + transition: background 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; 1889 + } 1890 + .chat-bubble .rt-button:hover { background: rgba(var(--tuiter-white-rgb),0.96); box-shadow: 0 2px 5px rgba(var(--tuiter-media-black-rgb),0.05); transform: translateY(-1px); } 1891 + .chat-bubble .rt-button .rt-btn { font-size: 11px; position: relative; top: 3px; } 1892 + .chat-bubble .rt-button .rt-count { top: 1px; right: -2px; min-width: 11px; height: 11px; font-size: 9px; background: rgba(var(--tuiter-media-black-rgb),0.28); color: var(--tuiter-white); border: 1px solid rgba(var(--tuiter-white-rgb),0.85); box-shadow: none; opacity: 0.92; } 1893 + 1894 + /* left-side bubble: place button at bubble's lower-right (so arrow visually points toward avatar on left) */ 1895 + .chat-bubble .rt-button.side-left { right: -26px; left: auto; bottom: 58px; } 1896 + 1897 + /* right-side bubble: place button at bubble's lower-left (so arrow visually points toward avatar on right) */ 1898 + .chat-bubble .rt-button.side-right { left: -26px; right: auto; bottom: 58px; } 1899 + 1900 + /* Ensure badge stands out on top of bubble */ 1901 + .chat-bubble .rt-button .rt-count { border: 1px solid rgba(var(--tuiter-white-rgb),0.9); } 1902 + 1903 + /* Keep previous chat-rt-button class for other placements but prefer bubble-scoped rules for bubbles */ 1904 + .rt-button .rt-count, 1905 + .chat-rt-button .rt-count, 1906 + .chat-bubble .rt-button .rt-count { 1907 + transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease, transform 0.12s ease, opacity 0.12s ease; 1908 + } 1909 + 1910 + 1911 + /* Hide rt button by default and reveal on hover of the post/chat container */ 1912 + .rt-button { 1913 + /* keep existing sizing/appearance but start hidden */ 1914 + opacity: 0; 1915 + transform: translateY(4px) scale(0.98); 1916 + pointer-events: none; 1917 + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s, opacity 0.18s ease; 1918 + } 1919 + 1920 + /* Reveal when user hovers the relevant container (post card, post wrapper or chat bubble) */ 1921 + .post:hover .rt-button, 1922 + .post-content.post-card:hover .rt-button, 1923 + .post-content.post-card:focus-within .rt-button, 1924 + .post:hover .post-content.post-card .rt-button, 1925 + .chat-node:hover .rt-button, 1926 + .chat-bubble:hover .rt-button, 1927 + .thread-node:hover .rt-button { 1928 + opacity: 0.96; /* match previous visible state */ 1929 + transform: translateY(0) scale(1); 1930 + pointer-events: auto; 1931 + } 1932 + 1933 + /* On touch devices (no hover), keep button visible so it's usable */ 1934 + @media (hover: none) { 1935 + .rt-button { 1936 + opacity: 0.96; 1937 + transform: none; 1938 + pointer-events: auto; 1939 + } 1940 + } 1941 + 1942 + /* Reply input UI (slim, wide chat-like) */ 1943 + .reply-input-container { 1944 + display: flex; 1945 + gap: 8px; 1946 + align-items: center; 1947 + margin-top: 8px; 1948 + margin-bottom: 6px; 1949 + padding: 6px; 1950 + background: rgba(var(--tuiter-white-rgb),0.04); 1951 + border-radius: 8px; 1952 + border: 1px solid rgba(var(--tuiter-media-black-rgb),0.04); 1953 + width: 100%; 1954 + max-width: 100%; 1955 + box-shadow: 0 1px 4px rgba(var(--tuiter-media-black-rgb),0.03); 1956 + } 1957 + 1958 + .reply-input-container .reply-input { 1959 + flex: 1 1 auto; 1960 + height: 32px; 1961 + padding: 6px 8px; 1962 + font-size: 12px; 1963 + border-radius: 6px; 1964 + border: 1px solid rgba(var(--tuiter-media-black-rgb),0.06); 1965 + background: rgba(var(--tuiter-white-rgb),0.9); 1966 + color: var(--tuiter-text); 1967 + } 1968 + 1969 + .reply-input-container .reply-input:focus { 1970 + outline: none; 1971 + box-shadow: 0 0 0 3px rgba(var(--tuiter-accent-rgb),0.08); 1972 + border-color: var(--tuiter-accent); 1973 + } 1974 + 1975 + .reply-input-container .reply-submit { 1976 + flex: 0 0 auto; 1977 + height: 32px; 1978 + padding: 6px 10px; 1979 + border-radius: 6px; 1980 + border: none; 1981 + background: var(--tuiter-accent); 1982 + color: var(--tuiter-white); 1983 + font-weight: 600; 1984 + cursor: pointer; 1985 + } 1986 + 1987 + /* Make sure reply input inserted under a bubble/post aligns with the content width */ 1988 + .chat-node .reply-input-container, 1989 + .post-content .reply-input-container, 1990 + .post .reply-input-container { 1991 + margin-left: 44px; /* account for avatar column */ 1992 + max-width: calc(100% - 44px); 1993 + } 1994 + 1995 + /* For chat-style nodes, reduce left margin for right-side nodes */ 1996 + .chat-node.right .reply-input-container { margin-left: 8px; margin-right: 44px; max-width: calc(100% - 52px); } 1997 + 1998 + @media (max-width: 640px) { 1999 + .chat-node .reply-input-container, 2000 + .post-content .reply-input-container, 2001 + .post .reply-input-container { margin-left: 52px; max-width: calc(100% - 52px); } 2002 + .reply-input-container .reply-input { height: 36px; } 2003 + } 2004 + 2005 + /* Small avatar square and placeholder inside the reply input container */ 2006 + .reply-avatar-square { 2007 + width: 28px; 2008 + height: 28px; 2009 + border-radius: 6px; 2010 + object-fit: cover; 2011 + flex: 0 0 auto; 2012 + border: 1px solid rgba(var(--tuiter-media-black-rgb),0.06); 2013 + } 2014 + 2015 + .reply-avatar-placeholder { 2016 + width: 28px; 2017 + height: 28px; 2018 + border-radius: 6px; 2019 + background: rgba(var(--tuiter-avatar-bg-rgb),0.18); 2020 + border: 1px solid rgba(var(--tuiter-media-black-rgb),0.04); 2021 + flex: 0 0 auto; 2022 + } 2023 + 2024 + /* Adjust input spacing to account for avatar */ 2025 + .reply-input-container .reply-input { margin-left: 0; } 2026 + 2027 + @media (max-width: 640px) { 2028 + .reply-avatar-square, .reply-avatar-placeholder { width: 32px; height: 32px; } 2029 + } 2030 + 2031 + /* Absolutely positioned reply input container styles */ 2032 + .reply-input-container.absolute { 2033 + position: absolute; /* set by JS but provide default styling */ 2034 + box-shadow: 0 8px 30px rgba(var(--tuiter-media-black-rgb),0.12); 2035 + background: rgba(var(--tuiter-white-rgb),0.98); 2036 + border-radius: 10px; 2037 + padding: 8px 10px; 2038 + } 2039 + 2040 + /* reduce avatar size for floating input to keep it slim */ 2041 + .reply-input-container.absolute .reply-avatar-square, 2042 + .reply-input-container.absolute .reply-avatar-placeholder { 2043 + width: 32px; 2044 + height: 32px; 2045 + border-radius: 6px; 2046 + } 2047 + 2048 + .reply-input-container.absolute .reply-input { height: 36px; } 2049 + 2050 + /* small screens: make floating input nearly full width */ 2051 + @media (max-width: 640px) { 2052 + .reply-input-container.absolute { left: 8px !important; right: 8px !important; width: auto !important; } 2053 + }
+46
static/tuiter-2006-base24.css
··· 1 + /* Tuiter 2006 base24 CSS variables 2 + Generated from tuiter-2006-base24.yaml 3 + This file defines color variables only. It should be loaded before the main style.css 4 + Theme authors can replace variables by providing an alternate CSS generated from another YAML. 5 + */ 6 + :root { 7 + --tuiter-bg: #9AE4E8; /* base00 - page background */ 8 + --tuiter-header-accent: #7DD0D5; /* base01 - header accent */ 9 + --tuiter-sidebar-bg: #C8E68A; /* base02 - sidebar background */ 10 + --tuiter-surface-warm: #fffef6; /* base03 - warm surface */ 11 + --tuiter-surface-cool: #f6fbff; /* base04 - cool surface */ 12 + --tuiter-card: #ffffff; /* base05 - card background */ 13 + --tuiter-surface-subtle: #fbfbfb; /* base06 */ 14 + --tuiter-input-bg: #f8f9fa; /* base07 - inputs, textarea */ 15 + --tuiter-brand: #00aced; /* base08 - primary brand blue */ 16 + --tuiter-link: #0066CC; /* base09 - links and handles */ 17 + --tuiter-accent: #6BB6FF; /* base0A - accent highlights */ 18 + --twitter-accent: var(--tuiter-accent); /* alias used for label hover backgrounds */ 19 + --tuiter-highlight: #FFCC33; /* base0B - viewed post highlight */ 20 + --tuiter-border-subtle: #E6E6E6; /* base0C - subtle borders */ 21 + --tuiter-border: #D0D0D0; /* base0D - main border */ 22 + --tuiter-border-muted: #CCCCCC; /* base0E - muted border */ 23 + --tuiter-text: #333333; /* base0F - primary text */ 24 + --tuiter-muted: #666666; /* base10 - muted text */ 25 + --tuiter-error: #C0392B; /* base11 - error red */ 26 + --tuiter-cyan-pale: #EAF9FB; /* base12 */ 27 + --tuiter-cyan-pale-2: #D6F4FB; /* base13 */ 28 + --tuiter-dark-teal: #0B2B33; /* base14 - contrast text */ 29 + --tuiter-handle-blue: #0A66C2; /* base15 - handle link */ 30 + --tuiter-join-bg: #99CCFF; /* base16 - join link bg */ 31 + --tuiter-join-hover: #88BBEE; /* base17 - join link hover */ 32 + --tuiter-divider: #E8E8E8; /* base18 - post divider */ 33 + --tuiter-avatar-bg: #DDDDDD; /* base19 - avatar placeholder */ 34 + --tuiter-thread-border: #DFDFDF; /* base20 - thread border */ 35 + --tuiter-toggle-active: #FFF4D6; /* base21 - toggle active bg */ 36 + --tuiter-media-black: #000000; /* base22 - media/video black */ 37 + --tuiter-white: #FFFFFF; /* base23 - utility white */ 38 + 39 + /* RGB helper variables for use with rgba(var(--<name>-rgb), <alpha>) */ 40 + --tuiter-media-black-rgb: 0,0,0; 41 + --tuiter-white-rgb: 255,255,255; 42 + --tuiter-text-rgb: 51,51,51; /* #333333 */ 43 + --tuiter-muted-rgb: 102,102,102; /* #666666 */ 44 + --tuiter-border-subtle-rgb: 230,230,230; /* #E6E6E6 */ 45 + --tuiter-border-rgb: 208,208,208; /* #D0D0D0 */ 46 + }
+45
static/tuiter-2006-base24.yaml
··· 1 + # Tuiter 2006 base24 color scheme (default) 2 + # This file follows the base24 layout used by tinted-theming/schemes (spec-0.11) 3 + # Colors chosen to match the current Tuiter 2006 retro palette. 4 + # Theme authors: supply a replacement YAML with same keys to generate alternate themes. 5 + 6 + scheme: tuiter-2006-base24 7 + author: auto-generated 8 + 9 + # base00..base23 - semantic palette for theme tooling 10 + base00: "#9AE4E8" # page background (main body) 11 + base01: "#7DD0D5" # header accent / subtle border 12 + base02: "#C8E68A" # sidebar background 13 + base03: "#fffef6" # warm light surface (panels) 14 + base04: "#f6fbff" # cool light surface (alternate panels) 15 + base05: "#ffffff" # primary card background 16 + base06: "#fbfbfb" # subtle surface / form background 17 + base07: "#f8f9fa" # input / textarea background 18 + base08: "#00aced" # brand blue (primary brand color) 19 + base09: "#0066CC" # link / handle blue 20 + base0A: "#6BB6FF" # accent blue (buttons / highlights) 21 + base0B: "#FFCC33" # accent yellow (highlights, viewed post) 22 + base0C: "#E6E6E6" # subtle borders, dividers 23 + base0D: "#D0D0D0" # secondary border 24 + base0E: "#CCCCCC" # muted borders / disabled 25 + base0F: "#333333" # primary text color 26 + base10: "#666666" # muted text 27 + base11: "#C0392B" # error / danger red 28 + base12: "#EAF9FB" # pale cyan (decorative) 29 + base13: "#D6F4FB" # pale cyan 2 (decorative) 30 + base14: "#0B2B33" # dark teal (contrast text) 31 + base15: "#0A66C2" # secondary handle blue 32 + base16: "#99CCFF" # join-link background 33 + base17: "#88BBEE" # join-link hover 34 + base18: "#E8E8E8" # post divider 35 + base19: "#DDDDDD" # avatar / placeholder background 36 + base20: "#DFDFDF" # thread border 37 + base21: "#FFF4D6" # toggle active background 38 + base22: "#000000" # media/video black 39 + base23: "#FFFFFF" # pure white (utility) 40 + 41 + # Notes for theme creators: 42 + # - Map your base16 colors to these base24 slots to approximate the Tuiter UI. 43 + # - base00..base07 typically control background and surface tones; base08..base0F control accents & text. 44 + # - Semantic mapping in the CSS uses these bases (brand, link, bg, muted, accent, card, border, etc.). 45 + # - See the styling guide: https://github.com/tinted-theming/home/blob/main/styling.md
static/tuiter1.png

This is a binary file and will not be displayed.

static/tuiter2.png

This is a binary file and will not be displayed.

static/tuiter3.png

This is a binary file and will not be displayed.

static/tuiter4.png

This is a binary file and will not be displayed.

+103
templates/about.html
··· 1 + {{template "header.html" .}} 2 + 3 + <div class="tuiter-about"> 4 + <div class="main-content"> 5 + <div class="content"> 6 + 7 + <div class="about-hero post"> 8 + <div class="post-content"> 9 + <h2>About Tuiter 2006</h2> 10 + <p class="lead">A small, text-first social place inspired by how the web used to feel: conversational, quick, and focused on people — not on attention optimization.</p> 11 + </div> 12 + </div> 13 + 14 + <div class="about-columns"> 15 + <div class="post about-column"> 16 + <div class="post-content"> 17 + <h3>Why this exists</h3> 18 + <p> 19 + Modern social platforms increasingly prize content that maximizes attention. Endless short-form feeds and attention-optimized placements make conversation noisy and transactional. Tuiter 2006 is a deliberate counterpoint: simple, text-first, and tuned for readable exchange where people come to speak and listen, not to be optimized for ad dollars. 20 + </p> 21 + 22 + <h3>Text first, always</h3> 23 + <p> 24 + Text scales well: it’s quick to skim, easy to quote, and friendly to thoughtful replies. By keeping the interface lightweight and avoiding media-first mechanics we make it easy to follow conversations and participate without distraction. 25 + </p> 26 + 27 + <h3>Obsolete by design</h3> 28 + <p> 29 + This project embraces minimalism. It is intentionally old-fashioned so the social experience — voices, replies, and threads — stays front and center. That obsolescence is the feature: fewer bells and whistles, more room for people. 30 + </p> 31 + </div> 32 + </div> 33 + 34 + <div class="post about-column"> 35 + <div class="post-content"> 36 + <h3>What people do here</h3> 37 + <p> 38 + People use Tuiter 2006 to jot quick thoughts, follow conversations, reply, and collect small threads of discussion. It favors readable text over polished feeds and keeps interactions light and human. 39 + </p> 40 + 41 + <h3>Share with friends</h3> 42 + <p> 43 + If this appeals to you, please tell a friend. Word-of-mouth sharing — a copied link, some screenshots of what you like or a short post on your other accounts, or an invitation to someone who loves old fashioned text conversation — is the best way to grow a calm, thoughtful community. 44 + </p> 45 + 46 + <h3>Report bugs</h3> 47 + <p> 48 + Found a bug or something behaving oddly? Report issues on Bluesky to <a href="https://bsky.app/profile/oeiuwq.bsky.social"><strong>@oeiuwq.bsky.social</strong></a>. Remember that this site is obsolete by design, so most features will not be implemented, but clear bug reports help prioritize fixes and improve the experience for everyone. 49 + </p> 50 + 51 + 52 + </div> 53 + </div> 54 + 55 + </div> 56 + </div> 57 + 58 + <div class="sidebar"> 59 + <div class="donation-box"> 60 + <div class="donation-content"> 61 + <h3>Support ongoing development</h3> 62 + <p> 63 + This project is made and maintained by <a href="https://github.com/vic"><code>vic</code></a> <strong>out of love</strong>. 64 + </p> 65 + <p class="muted small">Any contribution helps: from a one-time donation to a monthly sponsorship. Or even better, tell someone that you love them, today.</p> 66 + <div class="donate-buttons"> 67 + <a class="donate-link" href="https://ko-fi.com/oeiuwq" target="_blank" rel="noopener noreferrer">Donate on Ko‑fi</a> 68 + <a class="donate-link" href="https://github.com/sponsors/vic" target="_blank" rel="noopener noreferrer">Sponsor on GitHub</a> 69 + </div> 70 + <p class="muted small">Why support? Sponsorships and donations offset time and infrastructure costs and keep small, non-profit projects alive.</p> 71 + </div> 72 + </div> 73 + 74 + <div class="donation-box"> 75 + <div class="donation-content"> 76 + <h3> 77 + About the author 78 + </h3> 79 + <div> 80 + <quote> 81 + My name is Victor Borja. I'm not a designer as you can see, but I do try my best, I enjoy creating stuff for others. 82 + And I really miss the old good days of twitter. Hope you like this site. 83 + </quote> 84 + 85 + <br/> 86 + <p>You can find me here:</p> 87 + 88 + <ul> 89 + <li>https://github.com/vic</li> 90 + <li>https://bsky.app/profile/oeiuwq.bsky.social</li> 91 + <li>https://x.com/oeiuwq</li> 92 + <li>vborja@apache.org</li> 93 + <li>near your heart</li> 94 + </ul> 95 + </div> 96 + </div> 97 + </div> 98 + 99 + 100 + </div> 101 + </div> 102 + 103 + {{template "footer.html" .}}
+29
templates/conversation_chain.html
··· 1 + {{define "conversation_chain"}} 2 + <div class="conversation-chain"> 3 + {{if .}} 4 + {{range .}} 5 + <div class="chain-item"> 6 + <div class="chain-avatar"> 7 + {{/* Use helpers to keep template logic minimal */}} 8 + <a href="{{getProfileURL .Author}}"> 9 + {{if hasAvatar .Author}} 10 + <img src="{{avatarURL .Author}}" alt="{{getDisplayName .Author}}" class="chain-avatar-img"/> 11 + {{else}} 12 + 👤 13 + {{end}} 14 + </a> 15 + </div> 16 + <div class="chain-content"> 17 + <a href="{{getProfileURL .Author}}" class="chain-author">{{getDisplayName .Author}}</a> 18 + <div class="chain-text">{{getPostText .Record}}</div> 19 + 20 + {{/* Render embedded media in conversation chain */}} 21 + {{template "post_media" .}} 22 + 23 + <div class="chain-meta"><a href="{{getPostURL .}}">{{.IndexedAt}}</a></div> 24 + </div> 25 + </div> 26 + {{end}} 27 + {{end}} 28 + </div> 29 + {{end}}
+7
templates/fav_button.html
··· 1 + {{define "fav_button"}} 2 + {{/* dot is a dict {"Class": *string, "Count": int, "IsFav": bool} */}} 3 + <div class="fav-button {{.Class}} {{if .IsFav}}active{{else}}{{end}}" aria-hidden="false" title="Fav"> 4 + <button class="fav-btn" aria-label="fav">{{if .IsFav}}★{{else}}☆{{end}}</button> 5 + <span class="fav-count">{{.Count}}</span> 6 + </div> 7 + {{end}}
+15
templates/footer.html
··· 1 + <div class="footer"> 2 + <p>Tuiter 2006 - A nostalgic clone</p> 3 + </div> 4 + 5 + <!-- Lightbox overlay (used by post image thumbnails and video thumbnails) --> 6 + <div id="lightbox-overlay" aria-hidden="true"> 7 + <div class="close-hint">Click or press Esc to close</div> 8 + <div id="lightbox-media"> 9 + <img id="lightbox-img" src="" alt="" /> 10 + <video id="lightbox-video" controls playsinline></video> 11 + </div> 12 + </div> 13 + </div> 14 + </body> 15 + </html>
+31
templates/header.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Tuiter 2006</title> 7 + <link rel="stylesheet" href="/static/tuiter-2006-base24.css"> 8 + <link rel="stylesheet" href="/static/style.css"> 9 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 10 + <script src="/static/app.js"></script> 11 + </head> 12 + <body> 13 + <div class="container" {{if .SignedIn}}data-signed-in-avatar="{{.SignedIn.Avatar}}"{{end}}> 14 + <div class="header"> 15 + <h1><a href="/">Tuiter 2006</a></h1> 16 + <div class="header-nav"> 17 + {{if .SignedIn}} 18 + <a href="/">Home</a> | 19 + <a href="/about">About Tuiter2006</a> | 20 + <a href="/profile/{{.SignedIn.Handle}}">Your profile</a> | 21 + <a href="#">Invite</a> | 22 + <a href="https://bsky.app/profile/{{.SignedIn.Handle}}">@{{.SignedIn.Handle}}</a> | 23 + <a href="/logout">Sign out</a> 24 + {{else}} 25 + <a href="/">Home</a> | 26 + <a href="/about">About Tuiter2006</a> | 27 + <a href="#">Invite</a> | 28 + <a href="/signin">Sign in</a> 29 + {{end}} 30 + </div> 31 + </div>
+24
templates/main_post_partial.html
··· 1 + {{define "main_post"}} 2 + <div id="{{makeElementID .Post.Uri}}" class="main-post {{if eq .Post.Uri .ViewedURI}}highlighted-post{{end}}"> 3 + <div class="post-avatar"> 4 + <a href="{{getProfileURL .Post.Author}}"> 5 + {{if hasAvatar .Post.Author}} 6 + <img src="{{avatarURL .Post.Author}}" alt="{{getDisplayName .Post.Author}}" /> 7 + {{else}} 8 + 👤 9 + {{end}} 10 + </a> 11 + </div> 12 + <div class="post-content"> 13 + <h3 class="post-author-name">{{getDisplayName .Post.Author}}</h3> 14 + <div class="post-text">{{getPostText .Post.Record}}</div> 15 + 16 + {{/* Render embedded media using the shared fragment. Styles can target .main-post .post-media separately. */}} 17 + {{template "post_media" .Post}} 18 + 19 + <div class="post-meta"> 20 + <a href="{{getPostURL .Post}}">{{.Post.IndexedAt}}</a> from web 21 + </div> 22 + </div> 23 + </div> 24 + {{end}}
+41
templates/post-status.html
··· 1 + {{template "header.html" .}} 2 + 3 + <div class="main-content"> 4 + <div class="content"> 5 + {{if .ErrorMsg}} 6 + <div class="status-message error">{{.ErrorMsg}}</div> 7 + {{else if .StatusMsg}} 8 + <div class="status-message success">{{.StatusMsg}}</div> 9 + {{end}} 10 + 11 + <div class="about-section"> 12 + <div class="profile-pic"> 13 + {{if .Profile.Avatar}} 14 + <img src="{{.Profile.Avatar}}" alt="{{getDisplayName .Profile}}" /> 15 + {{else}} 16 + 👤 17 + {{end}} 18 + </div> 19 + <div class="profile-info"> 20 + <strong>{{getDisplayName .Profile}}</strong><br> 21 + @{{.Profile.Handle}} 22 + </div> 23 + </div> 24 + 25 + <div class="post-form"> 26 + <form action="/post-status" method="post"> 27 + <textarea name="status" placeholder="What are you doing?"></textarea> 28 + <button type="submit">update</button> 29 + </form> 30 + </div> 31 + 32 + <div class="timeline-intro"> 33 + <a href="/timeline">Return to timeline</a> 34 + </div> 35 + </div> 36 + 37 + {{template "sidebar.html" .}} 38 + 39 + </div> 40 + 41 + {{template "footer.html" .}}
+122
templates/post.html
··· 1 + {{template "header.html" .}} 2 + 3 + <div class="main-content"> 4 + <div class="content"> 5 + {{if .Post}} 6 + 7 + <!-- Parent chain (if any) --> 8 + {{if .ParentChain}} 9 + <div class="parent-chain-wrapper"> 10 + <h4>Conversation context</h4> 11 + {{template "conversation_chain" .ParentChain}} 12 + </div> 13 + {{end}} 14 + 15 + <!-- View mode toggle --> 16 + <div class="view-toggle"> 17 + <label>View:</label> 18 + <button id="toggle-flat" class="toggle-btn">Flat</button> 19 + <button id="toggle-nested" class="toggle-btn active">Nested</button> 20 + </div> 21 + 22 + <!-- Main post --> 23 + {{template "main_post" .}} 24 + 25 + <!-- Child replies (all descendants) --> 26 + {{if .Replies}} 27 + {{template "replies_partial" .}} 28 + {{end}} 29 + 30 + {{else}} 31 + <div class="error-message"> 32 + <div class="post-avatar">!</div> 33 + <div class="post-content"> 34 + <div class="post-text error-text">{{if .ErrorMsg}}{{.ErrorMsg}}{{else}}Post not found{{end}}</div> 35 + </div> 36 + </div> 37 + {{end}} 38 + </div> 39 + 40 + <!-- Sidebar shows the POSTER'S info, not current user --> 41 + <div class="sidebar"> 42 + {{if .PostAuthor}} 43 + <div class="about-section"> 44 + <h3>About</h3> 45 + <div class="profile-pic"> 46 + {{if hasAvatar .PostAuthor}} 47 + <a href="/profile/{{.PostAuthor.Handle}}"><img src="{{avatarURL .PostAuthor}}" alt="{{getDisplayName .PostAuthor}}" class="sidebar-avatar-img" /></a> 48 + {{else}} 49 + <a href="/profile/{{.PostAuthor.Handle}}">👤</a> 50 + {{end}} 51 + </div> 52 + <div class="profile-info"> 53 + <strong>Name</strong> {{if (getDisplayName .PostAuthor)}}{{getDisplayName .PostAuthor}}{{else}}{{.PostAuthor.Handle}}{{end}}<br> 54 + {{if .PostAuthor.Description}} 55 + <strong>Location</strong> {{.PostAuthor.Description}}<br> 56 + {{end}} 57 + <strong>Web</strong> <a href="/profile/{{.PostAuthor.Handle}}">{{.PostAuthor.Handle}}</a><br> 58 + <strong>Bio</strong> {{if .PostAuthor.Description}}{{.PostAuthor.Description}}{{else}}No bio available{{end}} 59 + </div> 60 + 61 + <div class="stats"> 62 + <h4>Stats</h4> 63 + <div class="stat-line"> 64 + <span class="stat-label">Following</span> 65 + <span class="stat-value">{{getFollowingCount .PostAuthor}}</span> 66 + </div> 67 + <div class="stat-line"> 68 + <span class="stat-label">Followers</span> 69 + <span class="stat-value">{{getFollowersCount .PostAuthor}}</span> 70 + </div> 71 + <div class="stat-line"> 72 + <span class="stat-label">Favorites</span> 73 + <span class="stat-value">0</span> 74 + </div> 75 + <div class="stat-line"> 76 + <span class="stat-label">Updates</span> 77 + <span class="stat-value">{{getPostsCount .PostAuthor}}</span> 78 + </div> 79 + </div> 80 + </div> 81 + 82 + {{if .PostAuthorFollows}} 83 + <div class="following-section"> 84 + <h3>Following</h3> 85 + <div class="following-grid"> 86 + {{range .PostAuthorFollows}} 87 + <div class="following-avatar"> 88 + {{if hasAvatar .}} 89 + <a href="/profile/{{.Handle}}" title="{{getDisplayName .}} ({{.Handle}})"> 90 + <img src="{{avatarURL .}}" alt="{{getDisplayName .}}" /> 91 + </a> 92 + {{else}} 93 + <a href="/profile/{{.Handle}}" title="{{getDisplayName .}} ({{.Handle}})">👤</a> 94 + {{end}} 95 + </div> 96 + {{end}} 97 + </div> 98 + </div> 99 + {{end}} 100 + 101 + <div class="actions"> 102 + <a href="/timeline" class="back-button">← Back to Timeline</a> 103 + {{if .CurrentUser}} 104 + <a href="/logout" class="logout-btn">Sign out</a> 105 + {{end}} 106 + </div> 107 + {{else}} 108 + <div class="signin-form"> 109 + <h3>Sign In</h3> 110 + <form action="/login" method="post"> 111 + <div class="form-group"> 112 + <label for="identifier">Username or Email:</label> 113 + <input type="text" id="identifier" name="identifier" required> 114 + </div> 115 + <input type="submit" value="Sign In" class="signin-btn"> 116 + </form> 117 + </div> 118 + {{end}} 119 + </div> 120 + </div> 121 + 122 + {{template "footer.html" .}}
+18
templates/post_box_partial.html
··· 1 + {{define "post_box_partial.html"}} 2 + <div class="post-box"> 3 + <div class="post-box-header"> 4 + <h3>{{if .PostBoxHandle}}Mention {{.PostBoxHandle}}{{else}}What are you doing?{{end}}</h3> 5 + <span class="char-count">Characters available: <span id="char-count">140</span></span> 6 + </div> 7 + <form class="post-box-form" hx-post="/timeline/post" hx-target="#timeline-posts" hx-swap="innerHTML"> 8 + <textarea name="status" id="status-input" 9 + placeholder="{{postBoxPlaceholder .PostBoxHandle}}" 10 + maxlength="140" 11 + class="post-box-textarea" data-maxlength="140" 12 + >{{postBoxInitial .PostBoxHandle}}</textarea> 13 + <div class="post-box-actions"> 14 + <button type="submit" class="update-btn update-btn-large">update</button> 15 + </div> 16 + </form> 17 + </div> 18 + {{end}}
+12
templates/post_item.html
··· 1 + {{define "post_item"}} 2 + <div class="post"> 3 + {{/* dot is a dict {"Post": *bsky.FeedDefs_FeedViewPost, "PostsList": PostsList} */}} 4 + {{ $post := .Post }} 5 + {{ $pl := .PostsList }} 6 + {{if isReply $post}} 7 + {{template "post_item_reply" (dict "Post" $post "PostsList" $pl)}} 8 + {{else}} 9 + {{template "post_item_standard" (dict "Post" $post "PostsList" $pl)}} 10 + {{end}} 11 + </div> 12 + {{end}}
+86
templates/post_item_reply.html
··· 1 + {{define "post_item_reply"}} 2 + {{/* dot is dict {"Post": *bsky.FeedDefs_FeedViewPost, "PostsList": PostsList} */}} 3 + {{ $root := dict "Post" .Post "PostsList" .PostsList }} 4 + 5 + {{/* The reply UI shows a compact chat with available preview info. We need to use the inner PostView for recording text/author */}} 6 + {{ $chain := getReplyChainInfos .Post.Post }} 7 + 8 + <div class="reply-thread chat flattened"> 9 + {{ if gt (len $chain) 0 }} 10 + {{/* show root/parent previews if present */}} 11 + {{ range $idx, $pi := $chain }} 12 + {{ if $pi.Uri }} 13 + {{ $pv := index $.PostsList.ParentPreviews $pi.Uri }} 14 + {{ if $pv.Uri }} 15 + {{/* determine side: left if authored by the current post author, right otherwise */}} 16 + {{ $isLeft := eq $pv.AuthorHandle $.Post.Post.Author.Handle }} 17 + <div class="chat-node {{if $isLeft}}left{{else}}right{{end}} compact"> 18 + {{ if $isLeft }} 19 + <div class="chat-avatar"> 20 + {{ if $pv.Avatar }}<img src="{{$pv.Avatar}}" alt="{{$pv.AuthorHandle}}" />{{ else }}<div class="avatar-placeholder"></div>{{ end }} 21 + </div> 22 + <div class="chat-bubble small"> 23 + <div class="chat-author"><a href="{{getProfileURL $pv.AuthorHandle}}">{{ $pv.AuthorHandle }}</a></div> 24 + <div class="chat-text">{{ $pv.Text }}</div> 25 + {{ if $pv.PostURL }}<div class="chat-meta"><a href="{{$pv.PostURL}}">{{ $pv.IndexedAt }}</a></div>{{ end }} 26 + 27 + {{template "rt_button" (dict "Class" "chat-rt-button side-left" "Count" $pv.RepostCount "IsRt" $pv.IsFav) }} 28 + {{template "fav_button" (dict "Class" "chat-fav-button side-left" "Count" $pv.LikeCount "IsFav" $pv.IsFav) }} 29 + {{template "reply_button" (dict "Class" "chat-reply-button side-left" "Count" $pv.ReplyCount "IsLeft" true) }} 30 + </div> 31 + {{/* Render any media for parent previews below their bubble, aligned with node side */}} 32 + {{ if $pv.Media }} 33 + <div class="chat-media"> 34 + {{ template "post_media" $pv.Media }} 35 + </div> 36 + {{ end }} 37 + {{ else }} 38 + <div class="chat-bubble small"> 39 + <div class="chat-author"><a href="{{getProfileURL $pv.AuthorHandle}}">{{ $pv.AuthorHandle }}</a></div> 40 + <div class="chat-text">{{ $pv.Text }}</div> 41 + {{ if $pv.PostURL }}<div class="chat-meta"><a href="{{$pv.PostURL}}">{{ $pv.IndexedAt }}</a></div>{{ end }} 42 + 43 + {{template "rt_button" (dict "Class" "chat-rt-button side-right" "Count" $pv.RepostCount "IsRt" $pv.IsFav) }} 44 + {{template "fav_button" (dict "Class" "chat-fav-button side-right" "Count" $pv.LikeCount "IsFav" $pv.IsFav) }} 45 + {{template "reply_button" (dict "Class" "chat-reply-button side-right" "Count" $pv.ReplyCount "IsLeft" false) }} 46 + </div> 47 + <div class="chat-avatar"> 48 + {{ if $pv.Avatar }}<img src="{{$pv.Avatar}}" alt="{{$pv.AuthorHandle}}" />{{ else }}<div class="avatar-placeholder"></div>{{ end }} 49 + </div> 50 + {{/* Render parent media on right-side node as well */}} 51 + {{ if $pv.Media }} 52 + <div class="chat-media"> 53 + {{ template "post_media" $pv.Media }} 54 + </div> 55 + {{ end }} 56 + {{ end }} 57 + </div> 58 + {{ end }} 59 + {{ end }} 60 + {{ end }} 61 + {{ end }} 62 + 63 + {{/* the current post should be shown as the left-side (authored by the viewed actor) */}} 64 + <div class="chat-node left compact current-post"> 65 + <div class="chat-avatar"> 66 + {{ $a := AvatarURL .Post.Post }} 67 + {{ if $a }}<img src="{{$a}}" alt="{{ .Post.Post.Author.Handle }}" />{{ else }}<div class="avatar-placeholder"></div>{{ end }} 68 + </div> 69 + <div class="chat-bubble small"> 70 + <div class="chat-author"><a href="{{getProfileURL .Post.Post.Author}}">{{ .Post.Post.Author.Handle }}</a></div> 71 + <div class="chat-text">{{getPostText .Post.Post.Record}}</div> 72 + {{ if .Post.Post.Uri }}<div class="chat-meta"><a href="{{getPostURL .Post.Post}}">{{ .Post.Post.IndexedAt }}</a></div>{{ end }} 73 + 74 + <!-- reply button for current left bubble --> 75 + {{template "fav_button" (dict "Class" "chat-fav-button side-left" "Count" (getLikeCount .Post.Post) "IsFav" (getIsFav .Post.Post)) }} 76 + {{template "reply_button" (dict "Class" "chat-reply-button side-left" "Count" (.Post.Post.ReplyCount) "IsLeft" true) }} 77 + </div> 78 + 79 + {{/* Render embedded media (images, video, external link cards) below the bubble. The shared "post_media" fragment expects a *bsky.FeedDefs_PostView, so pass .Post.Post. */}} 80 + <div class="chat-media"> 81 + {{ template "post_media" .Post.Post }} 82 + </div> 83 + </div> 84 + </div> 85 + 86 + {{end}}
+51
templates/post_item_standard.html
··· 1 + {{define "post_item_standard"}} 2 + {{/* Render non-reply (standard) posts. Placing avatar as a sibling to the content so outer .post (display:flex) aligns items properly. */}} 3 + <div class="post-avatar"> 4 + <a href="{{getProfileURL .Post.Post.Author}}"><img src="{{.Post.Post.Author.Avatar}}" alt="{{getDisplayName .Post.Post.Author}}" class="post-author-img"/></a> 5 + </div> 6 + 7 + <div class="post-content post-card"> 8 + <div class="post-row"> 9 + <div class="post-head"> 10 + <a href="{{getProfileURL .Post.Post.Author}}" class="post-author">{{getDisplayName .Post.Post.Author}}</a> 11 + <span class="post-handle">@{{.Post.Post.Author.Handle}}</span> 12 + <div class="post-meta-inline"> 13 + <a href="{{getPostURL .Post.Post}}">{{.Post.Post.IndexedAt}}</a> from web 14 + </div> 15 + </div> 16 + 17 + <div class="post-body"> 18 + <div class="post-text">{{getPostText .Post.Post.Record}}</div> 19 + 20 + {{/* media rendering: images, video, external link preview */}} 21 + {{template "post_media" .Post.Post}} 22 + 23 + {{/* embedded quoted/retweeted record handling */}} 24 + {{if isPostQuote .Post}} 25 + {{$e := getEmbedRecord .Post.Post}} 26 + {{if $e}} 27 + {{template "quoted_tweet" (embedContext .Post.Post $e)}} 28 + {{end}} 29 + {{else if isPostRetweet .Post}} 30 + {{$e := getEmbedRecord .Post.Post}} 31 + {{if $e}} 32 + {{template "retweeted_tweet" (embedContext .Post.Post $e)}} 33 + {{end}} 34 + {{end}} 35 + 36 + {{/* display reply link if there are replies (kept minimal) */}} 37 + {{if .Post.Post.ReplyCount}} 38 + <div class="post-actions"> 39 + <a href="{{getPostURL .Post.Post}}">in reply to {{.Post.Post.Author.Handle}}</a> 40 + </div> 41 + {{end}} 42 + </div> 43 + 44 + </div> 45 + 46 + {{template "rt_button" (dict "Class" "" "Count" (.Post.Post.RepostCount) "IsRt" (getIsFav .Post.Post)) }} 47 + {{template "fav_button" (dict "Class" "" "Count" (getLikeCount .Post.Post) "IsFav" (getIsFav .Post.Post)) }} 48 + {{template "reply_button" (dict "Class" "" "Count" .Post.Post.ReplyCount "IsLeft" true) }} 49 + 50 + </div> 51 + {{end}}
+37
templates/post_media_partial.html
··· 1 + {{define "post_media"}} 2 + {{/* Accept either a *bsky.FeedDefs_PostView or a *MediaVM */}} 3 + {{ $media := getMediaForTemplate . }} 4 + {{ if $media }} 5 + <div class="post-media"> 6 + {{ if $media.Images }} 7 + <div class="media-images"> 8 + {{ range $idx, $img := $media.Images }} 9 + <a href="{{ $img.Full }}" target="_blank"><img src="{{ $img.Thumb }}" alt="{{ $img.Alt }}" class="post-image" /></a> 10 + {{ end }} 11 + </div> 12 + {{ end }} 13 + 14 + {{ if $media.Video }} 15 + <div class="media-video"> 16 + {{ if $media.Video.Thumb }} 17 + <a href="{{if ne $media.Video.OwnerDid ""}}/video/{{ $media.Video.OwnerDid }}/{{ $media.Video.Cid }}{{else}}/video/{{ $media.Video.Cid }}{{end}}" data-mime="video/mp4"><img src="{{ $media.Video.Thumb }}" alt="video thumbnail" class="post-video-thumb" /></a> 18 + {{ else }} 19 + <a href="{{if ne $media.Video.OwnerDid ""}}/video/{{ $media.Video.OwnerDid }}/{{ $media.Video.Cid }}{{else}}/video/{{ $media.Video.Cid }}{{end}}" data-mime="video/mp4">[video]</a> 20 + {{ end }} 21 + </div> 22 + {{ end }} 23 + 24 + {{ if $media.External }} 25 + <div class="media-external"> 26 + <a href="{{ $media.External.Uri }}" target="_blank" class="external-link-card"> 27 + {{ if $media.External.Thumb }}<img src="{{ $media.External.Thumb }}" alt="thumb" class="external-thumb" />{{ end }} 28 + <div class="external-meta"> 29 + <strong>{{ $media.External.Title }}</strong> 30 + <div class="external-desc">{{ $media.External.Description }}</div> 31 + </div> 32 + </a> 33 + </div> 34 + {{ end }} 35 + </div> 36 + {{ end }} 37 + {{end}}
+34
templates/posts_list_partial.html
··· 1 + {{/* Shared posts list partial. Accepts either a PostsList or a page struct with .Posts (PostsList). */}} 2 + {{if .Posts}} 3 + {{/* called with full page data; use .Posts */}} 4 + {{$postsList := .Posts}} 5 + {{$ppmap := $postsList.ParentPreviews}} 6 + {{if $postsList.Items}} 7 + {{range $postsList.Items}} 8 + {{template "post_item" (dict "Post" . "PostsList" $postsList)}} 9 + {{end}} 10 + {{else}} 11 + <div class="post"> 12 + <div class="post-avatar">📱</div> 13 + <div class="post-content"> 14 + <div class="post-text">No updates available.</div> 15 + </div> 16 + </div> 17 + {{end}} 18 + {{else}} 19 + {{/* called with PostsList as dot */}} 20 + {{$postsList := .}} 21 + {{$ppmap := $postsList.ParentPreviews}} 22 + {{if $postsList.Items}} 23 + {{range $postsList.Items}} 24 + {{template "post_item" (dict "Post" . "PostsList" $postsList)}} 25 + {{end}} 26 + {{else}} 27 + <div class="post"> 28 + <div class="post-avatar">📱</div> 29 + <div class="post-content"> 30 + <div class="post-text">No updates available.</div> 31 + </div> 32 + </div> 33 + {{end}} 34 + {{end}}
+36
templates/profile.html
··· 1 + {{template "header.html" .}} 2 + 3 + <div class="main-content"> 4 + <div class="content"> 5 + {{if .Profile}} 6 + <!-- Profile header --> 7 + {{template "profile_header_partial.html" .}} 8 + 9 + <!-- Posts area: delegate to shared partial that uses post_item --> 10 + <div class="posts-area"> 11 + <div id="profile-posts"> 12 + {{template "posts_list_partial.html" .}} 13 + </div> 14 + </div> 15 + 16 + <div id="profile-more"> 17 + {{if .Posts.Cursor}} 18 + <button hx-get="/htmx/profile?did={{.Profile.Did}}&cursor={{.Posts.Cursor}}" hx-target="#profile-posts" hx-swap="beforeend" class="load-more-btn">Load more</button> 19 + {{end}} 20 + </div> 21 + 22 + {{else}} 23 + <div class="post"> 24 + <div class="post-avatar">!</div> 25 + <div class="post-content"> 26 + <div class="post-text error-text">{{if .ErrorMsg}}{{.ErrorMsg}}{{else}}Profile not found{{end}}</div> 27 + </div> 28 + </div> 29 + {{end}} 30 + </div> 31 + 32 + {{template "sidebar.html" .}} 33 + 34 + </div> 35 + 36 + {{template "footer.html" .}}
+31
templates/profile_header_partial.html
··· 1 + {{/* profile_header_partial.html - reusable header for profile and timeline */}} 2 + {{if .Profile}} 3 + <div class="profile-header"> 4 + {{/* Choose background with a clear if/else to avoid template parsing context errors */}} 5 + {{if ne (bannerURL .Profile) ""}} 6 + <div class="profile-header-bg" data-banner-url="{{bannerURL .Profile}}"> 7 + {{else}} 8 + <div class="profile-header-bg sidebar-bg"> 9 + {{end}} 10 + <div class="profile-header-bg-overlay"></div> 11 + <div class="profile-header-content"> 12 + <div class="profile-avatar"> 13 + {{if hasAvatar .Profile}} 14 + <img src="{{avatarURL .Profile}}" alt="{{getDisplayName .Profile}}" class="profile-avatar-img" /> 15 + {{else}} 16 + <div class="profile-avatar-placeholder">👤</div> 17 + {{end}} 18 + </div> 19 + <div class="profile-main"> 20 + <div class="profile-names"> 21 + <h1 class="profile-displayname">{{getDisplayName .Profile}}</h1> 22 + <a class="handle" href="https://bsky.app/profile/{{.Profile.Handle}}" target="_blank" rel="noopener">@{{.Profile.Handle}}</a> 23 + </div> 24 + <div class="profile-update-box"> 25 + {{template "post_box_partial.html" .}} 26 + </div> 27 + </div> 28 + </div> 29 + </div> 30 + </div> 31 + {{end}}
+5
templates/profile_more.html
··· 1 + <div id="profile-more" hx-swap-oob="innerHTML"> 2 + {{if .Cursor}} 3 + <button hx-get="/htmx/profile?did={{.Did}}&cursor={{.Cursor}}" hx-target="#profile-posts" hx-swap="beforeend" class="load-more-btn">Load more</button> 4 + {{end}} 5 + </div>
+19
templates/quoted_tweet.html
··· 1 + {{define "quoted_tweet"}} 2 + {{with .}} 3 + <div class="quoted-tweet"> 4 + <a href="{{getPostURL .Parent}}" class="qt-label qt-label-link" title="{{getDisplayName .Parent.Author}} quoted {{getDisplayName .Embed.Author}}">QT</a> 5 + <span class="quoted-avatar"> 6 + {{if .Embed.Author.Avatar}} 7 + <a href="{{getProfileURL .Embed.Author}}"><img src="{{.Embed.Author.Avatar}}" alt="{{getDisplayName .Embed.Author}}" class="quoted-avatar-img" /></a> 8 + {{else}} 9 + <a href="{{getProfileURL .Embed.Author}}">👤</a> 10 + {{end}} 11 + </span> 12 + <a href="{{getProfileURL .Embed.Author}}" class="quoted-author-name">{{getDisplayName .Embed.Author}}</a> 13 + <span class="quoted-text">{{getPostText .Embed.Value}}</span> 14 + 15 + {{/* Render media for the quoted record if present using the shared media partial */}} 16 + {{template "post_media" .Parent}} 17 + </div> 18 + {{end}} 19 + {{end}}
+26
templates/replies_partial.html
··· 1 + {{define "replies_partial"}} 2 + <div class="child-replies" id="replies-container"> 3 + <h4>Replies</h4> 4 + {{if .ThreadRoot}} 5 + <div class="threaded-replies" id="threaded-replies"> 6 + {{ $root := wrapThread .ThreadRoot .ViewedURI }} 7 + {{range $idx, $child := .ThreadRoot.Replies}} 8 + {{if and $child.FeedDefs_ThreadViewPost (ne $child.FeedDefs_ThreadViewPost.Post.Uri $.ViewedURI)}} 9 + {{template "thread_node" (wrapThread $child.FeedDefs_ThreadViewPost $.ViewedURI)}} 10 + {{end}} 11 + {{end}} 12 + </div> 13 + <div class="flat-list"> 14 + {{range .Replies}} 15 + {{if ne .Uri $.ViewedURI}} 16 + {{template "reply_item" .}} 17 + {{end}} 18 + {{end}} 19 + </div> 20 + {{else}} 21 + {{range .Replies}} 22 + {{template "reply_item" .}} 23 + {{end}} 24 + {{end}} 25 + </div> 26 + {{end}}
+7
templates/reply_button.html
··· 1 + {{define "reply_button"}} 2 + {{/* dot is a dict {"Class": *string, "Count": int, "IsLeft": bool, "IsActive": bool} */}} 3 + <div class="reply-button {{.Class}} {{if .IsActive}}active{{else}}{{end}}" aria-hidden="false" title="Reply"> 4 + <button class="reply-btn" aria-label="Reply">{{if .IsLeft}}↩{{else}}↪{{end}}</button> 5 + <span class="reply-count">{{.Count}}</span> 6 + </div> 7 + {{end}}
+24
templates/reply_item.html
··· 1 + {{define "reply_item"}} 2 + <div id="{{makeElementID .Uri}}" class="reply-post"> 3 + <div class="reply-avatar"> 4 + <a href="{{getProfileURL .Author}}"> 5 + {{if hasAvatar .Author}} 6 + <img src="{{avatarURL .Author}}" alt="{{getDisplayName .Author}}" /> 7 + {{else}} 8 + 👤 9 + {{end}} 10 + </a> 11 + </div> 12 + <div class="reply-content"> 13 + <a href="{{getProfileURL .Author}}" class="reply-author">{{getDisplayName .Author}}</a> 14 + <span class="reply-text">{{getPostText .Record}}</span> 15 + 16 + {{/* Render embedded media for replies using the shared partial. It accepts a PostView. */}} 17 + {{template "post_media" .}} 18 + 19 + <div class="reply-meta"> 20 + <a href="{{getPostURL .}}">{{.IndexedAt}}</a> from <span class="source">web</span> 21 + </div> 22 + </div> 23 + </div> 24 + {{end}}
+19
templates/retweeted_tweet.html
··· 1 + {{define "retweeted_tweet"}} 2 + {{with .}} 3 + <div class="retweeted-tweet"> 4 + <a href="{{getPostURL .Parent}}" class="rt-label rt-label-link" title="{{getDisplayName .Parent.Author}} retweeted {{getDisplayName .Embed.Author}}">RT</a> 5 + <span class="retweeted-avatar"> 6 + {{if .Embed.Author.Avatar}} 7 + <a href="{{getProfileURL .Embed.Author}}"><img src="{{.Embed.Author.Avatar}}" alt="{{getDisplayName .Embed.Author}}" class="retweeted-avatar-img" /></a> 8 + {{else}} 9 + <a href="{{getProfileURL .Embed.Author}}">👤</a> 10 + {{end}} 11 + </span> 12 + <a href="{{getProfileURL .Embed.Author}}" class="retweeted-author-name">{{getDisplayName .Embed.Author}}</a> 13 + <span class="retweeted-text">{{getPostText .Embed.Value}}</span> 14 + 15 + {{/* Render media for the retweeted record if present */}} 16 + {{template "post_media" .Parent}} 17 + </div> 18 + {{end}} 19 + {{end}}
+7
templates/rt_button.html
··· 1 + {{define "rt_button"}} 2 + {{/* dot is a dict {"Class": *string, "Count": int, "IsRt": bool} */}} 3 + <div class="rt-button {{.Class}} {{if .IsRt}}active{{else}}{{end}}" aria-hidden="false" title="Retweet"> 4 + <button class="rt-btn" aria-label="rt">{{if .IsRt}}♻{{else}}♲{{end}}</button> 5 + <span class="rt-count">{{.Count}}</span> 6 + </div> 7 + {{end}}
+69
templates/sidebar.html
··· 1 + <div class="sidebar"> 2 + {{if .Profile}} 3 + <div class="about-section"> 4 + <h3>About</h3> 5 + <div class="profile-pic"> 6 + {{if .Profile.Avatar}} 7 + <a href="/profile/{{.Profile.Handle}}"><img src="{{.Profile.Avatar}}" alt="{{getDisplayName .Profile}}" /></a> 8 + {{else}} 9 + <a href="/profile/{{.Profile.Handle}}">👤</a> 10 + {{end}} 11 + </div> 12 + <div class="profile-info"> 13 + <strong><a href="/profile/{{.Profile.Handle}}">{{getDisplayName .Profile}}</a></strong><br> 14 + {{if .Profile.Description}} 15 + {{.Profile.Description}} 16 + {{end}} 17 + </div> 18 + 19 + <div class="stats"> 20 + <p> 21 + <span class="stat"><strong>{{getFollowersCount .Profile}}</strong> followers</span> 22 + <span class="stat"><strong>{{getFollowingCount .Profile}}</strong> following</span> 23 + </p> 24 + <p> 25 + <span class="stat"><strong>{{getPostsCount .Profile}}</strong> updates</span> 26 + </p> 27 + </div> 28 + </div> 29 + 30 + {{if .Follows}} 31 + <div class="following-section"> 32 + <h3>Following</h3> 33 + <div class="following-grid"> 34 + {{range .Follows}} 35 + <div class="following-avatar"> 36 + {{if .Avatar}} 37 + <a href="/profile/{{.Handle}}" title="{{getDisplayName .}} ({{.Handle}})"> 38 + <img src="{{.Avatar}}" alt="{{getDisplayName .}}" /> 39 + </a> 40 + {{else}} 41 + <a href="/profile/{{.Handle}}" title="{{getDisplayName .}} ({{.Handle}})">👤</a> 42 + {{end}} 43 + </div> 44 + {{end}} 45 + </div> 46 + </div> 47 + {{end}} 48 + 49 + <div class="actions"> 50 + <a href="/logout" class="logout-btn">Sign out</a> 51 + <p>Made with <code>&lt;3</code> by <a href="https://x.com/oeiuwq">@oeiuwq</a></p> 52 + </div> 53 + {{else}} 54 + <div class="signin-form"> 55 + <h3>Sign In</h3> 56 + <form action="/login" method="post"> 57 + <div class="form-group"> 58 + <label for="identifier">Username or Email:</label> 59 + <input type="text" id="identifier" name="identifier" required> 60 + </div> 61 + <input type="submit" value="Sign In" class="signin-btn"> 62 + </form> 63 + <div class="join-section"> 64 + <a href="https://bsky.app" class="join-link">Join Twitter today!</a> 65 + <p class="join-subtext">Already using Twitter on your phone? <a href="/login">Sign in here</a></p> 66 + </div> 67 + </div> 68 + {{end}} 69 + </div>
+64
templates/signin.html
··· 1 + {{template "header.html" .}} 2 + 3 + <div class="main-content"> 4 + <div class="content"> 5 + <div class="welcome-message"> 6 + <h2>Welcome to Tuiter 2006!</h2> 7 + <br /> 8 + <p>Tuiter 2006 is a tribute to the early days of Twitter, using Bluesky's social protocol.</p> 9 + <p>It is totally <a href="/about">free</a>, and will always be. Made out of Love, like all the good things.</p> 10 + </div> 11 + 12 + <div class="post"> 13 + <div class="post-content"> 14 + <h3>Early Alpha Preview</h3> 15 + <br /> 16 + <p>You are most than welcome to try Tuiter 2006 right now!, just keep in mind it will evolve quickly. We still have to show a good looking fail whale on errors.</p> 17 + <p><a href="/about">Feedback</a> is more than welcome, please share with your friends, let's get them out of X.</p> 18 + <p>It currently looks like this:</p> 19 + <div class="post-media"> 20 + <div class="media-images"> 21 + <a href="/static/tuiter1.png" target="_blank"><img class="post-image" src="/static/tuiter1.png"></img></a> 22 + <a href="/static/tuiter2.png" target="_blank"><img class="post-image" src="/static/tuiter2.png"></img></a> 23 + <a href="/static/tuiter3.png" target="_blank"><img class="post-image" src="/static/tuiter3.png"></img></a> 24 + <a href="/static/tuiter4.png" target="_blank"><img class="post-image" src="/static/tuiter4.png"></img></a> 25 + </div> 26 + </div> 27 + </div> 28 + </div> 29 + 30 + <div class="post"> 31 + <div class="post-content"> 32 + <h5>Privacy Policy</h5> 33 + <br /> 34 + <p>This site will NEVER ask you for your password. It uses Bluesky authentication and stores a cookie for keeping you signed.</p> 35 + <p>No other data is saved, all your messages are sent directly to the Bluesky API.</p> 36 + <p>The code for this site is <a href="https://tangled.sh/@oeiuwq.bsky.social/tuiter">opensource</a> under the Apache-2 license.</p> 37 + <p>This service and its code is provided on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.</p> 38 + </div> 39 + </div> 40 + 41 + </div> 42 + 43 + <div class="sidebar"> 44 + <div class="signin-form"> 45 + <p>Sign in with your Bluesky account to get started!</p> 46 + <form action="/login" method="post"> 47 + <div class="form-group"> 48 + <input type="text" id="identifier" name="identifier" placeholder="you.bsky.social"> 49 + <input type="submit" value="Log In" class="signin-btn"> 50 + </div> 51 + </form> 52 + </div> 53 + 54 + <div class="join-section"> 55 + <div class="note">💡 Uses Bluesky OAuth - we will never touch your password!</div> 56 + <p> 57 + If you don't already have a Bluesky account, create one <a href="https://bsky.app">HERE</a>. 58 + </p> 59 + </div> 60 + 61 + </div> 62 + </div> 63 + 64 + {{template "footer.html" .}}
+46
templates/threaded_replies.html
··· 1 + {{define "thread_node"}} 2 + {{/* If this node is the viewed post, skip rendering the box and render children only */}} 3 + {{if eq .Post.Post.Uri .ViewedURI}} 4 + {{if .Post.Replies}} 5 + <div class="thread-children"> 6 + {{ $parent := . }} 7 + {{range $idx, $r := .Post.Replies}} 8 + {{if $r.FeedDefs_ThreadViewPost}} 9 + {{template "thread_node" (wrapThread $r.FeedDefs_ThreadViewPost $parent.ViewedURI)}} 10 + {{end}} 11 + {{end}} 12 + </div> 13 + {{end}} 14 + {{else}} 15 + <div class="thread-node" id="{{makeElementID .Post.Post.Uri}}"> 16 + <div class="thread-avatar"> 17 + <a href="{{getProfileURL .Post.Post.Author}}"> 18 + {{if hasAvatar .Post.Post.Author}} 19 + <img src="{{avatarURL .Post.Post.Author}}" alt="{{getDisplayName .Post.Post.Author}}" /> 20 + {{else}} 21 + 👤 22 + {{end}} 23 + </a> 24 + </div> 25 + <div class="thread-content {{if eq .Post.Post.Uri .ViewedURI}}highlighted-post{{end}}"> 26 + <a href="{{getProfileURL .Post.Post.Author}}" class="reply-author">{{getDisplayName .Post.Post.Author}}</a> 27 + <div class="reply-text">{{getPostText .Post.Post.Record}}</div> 28 + 29 + {{/* Render embedded media for this thread node */}} 30 + {{template "post_media" .Post.Post}} 31 + 32 + <div class="reply-meta"><a href="{{getPostURL .Post.Post}}">{{.Post.Post.IndexedAt}}</a></div> 33 + </div> 34 + </div> 35 + {{if .Post.Replies}} 36 + <div class="thread-children"> 37 + {{ $parent := . }} 38 + {{range $idx, $r := .Post.Replies}} 39 + {{if $r.FeedDefs_ThreadViewPost}} 40 + {{template "thread_node" (wrapThread $r.FeedDefs_ThreadViewPost $parent.ViewedURI)}} 41 + {{end}} 42 + {{end}} 43 + </div> 44 + {{end}} 45 + {{end}} 46 + {{end}}
+44
templates/timeline.html
··· 1 + {{template "header.html" .}} 2 + 3 + <div class="main-content"> 4 + <div class="content"> 5 + {{if .Profile}} 6 + 7 + <!-- Profile header (reused from profile) --> 8 + {{template "profile_header_partial.html" .}} 9 + 10 + <!-- Navigation tabs --> 11 + <div class="timeline-nav"> 12 + <span class="active-tab">Archive</span> 13 + <span class="tab">Replies</span> 14 + <span class="tab">Recent</span> 15 + </div> 16 + 17 + <!-- Timeline feed --> 18 + <div id="timeline-posts"> 19 + {{template "posts_list_partial.html" .}} 20 + </div> 21 + 22 + <!-- Load more container; will be updated via HTMX out-of-band swaps --> 23 + <div id="timeline-more"> 24 + {{if .Posts.Cursor}} 25 + <button hx-get="/htmx/timeline?cursor={{.Posts.Cursor}}" hx-target="#timeline-posts" hx-swap="beforeend" class="load-more-btn">Load more</button> 26 + {{end}} 27 + </div> 28 + 29 + {{else}} 30 + <div class="post"> 31 + <div class="post-avatar">!</div> 32 + <div class="post-content"> 33 + <div class="post-text error-text">Unable to load profile. Please sign in again.</div> 34 + </div> 35 + </div> 36 + {{end}} 37 + </div> 38 + 39 + {{template "sidebar.html" .}} 40 + 41 + </div> 42 + 43 + 44 + {{template "footer.html" .}}
+5
templates/timeline_more.html
··· 1 + <div id="timeline-more" hx-swap-oob="innerHTML"> 2 + {{if .Cursor}} 3 + <button hx-get="/htmx/timeline?cursor={{.Cursor}}" hx-target="#timeline-posts" hx-swap="beforeend" class="load-more-btn">Load more</button> 4 + {{end}} 5 + </div>
+11
templates/timeline_posts_partial.html
··· 1 + {{/* Partial that renders the list of timeline posts. Used by HTMX to refresh the feed. */}} 2 + {{if .Posts.Items}} 3 + {{template "posts_list_partial.html" .Posts}} 4 + {{else}} 5 + <div class="post"> 6 + <div class="post-avatar">📱</div> 7 + <div class="post-content"> 8 + <div class="post-text">No updates available in your timeline yet.</div> 9 + </div> 10 + </div> 11 + {{end}}
+89
types.go
··· 1 + package main 2 + 3 + import ( 4 + bsky "github.com/bluesky-social/indigo/api/bsky" 5 + ) 6 + 7 + // Page data structs 8 + 9 + type PostStatusPageData struct { 10 + Title string 11 + CurrentUser *bsky.ActorDefs_ProfileViewDetailed 12 + Profile *bsky.ActorDefs_ProfileViewDetailed 13 + Follows []*bsky.ActorDefs_ProfileView 14 + // SignedIn is the currently signed-in profile (typed, may be nil) 15 + SignedIn *bsky.ActorDefs_ProfileViewDetailed 16 + } 17 + 18 + type TimelinePageData struct { 19 + Title string 20 + CurrentUser *bsky.ActorDefs_ProfileViewDetailed 21 + Profile *bsky.ActorDefs_ProfileViewDetailed 22 + Timeline *bsky.FeedGetTimeline_Output 23 + Follows []*bsky.ActorDefs_ProfileView 24 + Posts PostsList 25 + PostBoxHandle string 26 + // SignedIn is the currently signed-in profile (typed, may be nil) 27 + SignedIn *bsky.ActorDefs_ProfileViewDetailed 28 + } 29 + 30 + type TimelinePartialData struct { 31 + Timeline *bsky.FeedGetTimeline_Output 32 + Posts PostsList 33 + // SignedIn may be present for partials that need header links 34 + SignedIn *bsky.ActorDefs_ProfileViewDetailed 35 + } 36 + 37 + type PostPageData struct { 38 + Title string 39 + Post *bsky.FeedDefs_PostView 40 + Replies []*bsky.FeedDefs_PostView 41 + ParentChain []*bsky.FeedDefs_PostView 42 + ViewedURI string 43 + ThreadRoot *bsky.FeedDefs_ThreadViewPost 44 + CurrentUser *bsky.ActorDefs_ProfileViewDetailed 45 + PostAuthor *bsky.ActorDefs_ProfileViewDetailed 46 + PostAuthorFollows []*bsky.ActorDefs_ProfileView 47 + // SignedIn is the currently signed-in profile (typed, may be nil) 48 + SignedIn *bsky.ActorDefs_ProfileViewDetailed 49 + } 50 + 51 + type ProfilePageData struct { 52 + Title string 53 + Profile *bsky.ActorDefs_ProfileViewDetailed 54 + Feed *bsky.FeedGetAuthorFeed_Output 55 + Follows []*bsky.ActorDefs_ProfileView 56 + Posts PostsList 57 + PostBoxHandle string 58 + // SignedIn is the currently signed-in profile (typed, may be nil) 59 + SignedIn *bsky.ActorDefs_ProfileViewDetailed 60 + } 61 + 62 + type TimelineProvider struct{ T *bsky.FeedGetTimeline_Output } 63 + 64 + func (p TimelineProvider) Posts() []*bsky.FeedDefs_FeedViewPost { 65 + if p.T == nil || p.T.Feed == nil { 66 + return nil 67 + } 68 + return p.T.Feed 69 + } 70 + 71 + func (p TimelineProvider) Cursor() string { 72 + if p.T == nil || p.T.Cursor == nil { 73 + return "" 74 + } 75 + return *p.T.Cursor 76 + } 77 + 78 + type AuthorProvider struct { 79 + F *bsky.FeedGetAuthorFeed_Output 80 + } 81 + 82 + func (p AuthorProvider) Posts() []*bsky.FeedDefs_FeedViewPost { 83 + if p.F == nil || p.F.Feed == nil { 84 + return nil 85 + } 86 + return p.F.Feed 87 + } 88 + 89 + func (p AuthorProvider) Cursor() string { return "" }