Merge 2538f3303f35fa09afea1a0aefd2442503a30f8c into 9b84b361225facd8e623f25d76f889d3dc54a879

This commit is contained in:
Taksh Kothari 2026-08-11 17:08:19 +00:00 committed by GitHub
commit caacef252d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1363 additions and 0 deletions

View File

@ -13579,6 +13579,936 @@
}
}
},
"message.link.copy" : {
"comment" : "Context-menu action that copies a bitchat:// deep link to this message",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "نسخ رابط الرسالة"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "বার্তার লিঙ্ক কপি করো"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "nachrichtenlink kopieren"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "copy message link"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "copiar enlace del mensaje"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "کپی پیوند پیام"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "kopyahin ang link ng mensahe"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "copier le lien du message"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "העתקת קישור להודעה"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "संदेश लिंक कॉपी करें"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "salin tautan pesan"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "copia link del messaggio"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "メッセージリンクをコピー"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "메시지 링크 복사"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "salin pautan mesej"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "सन्देश लिङ्क कपी गर्नुहोस्"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "berichtlink kopiëren"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "kopiuj link do wiadomości"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "copiar ligação da mensagem"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "copiar link da mensagem"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "скопировать ссылку на сообщение"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "kopiera meddelandelänk"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "செய்தி இணைப்பை நகலெடு"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "คัดลอกลิงก์ข้อความ"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "mesaj bağlantısını kopyala"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "скопіювати посилання на повідомлення"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "پیغام کا لنک کاپی کریں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "sao chép liên kết tin nhắn"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "复制消息链接"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "複製訊息連結"
}
}
}
},
"message.link.plaintext" : {
"comment" : "Plain-text payload when copying a message deep link; %@ is the bitchat:// URL",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "افتح في bitchat: %@"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat-এ খোলো: %@"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "in bitchat öffnen: %@"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "open in bitchat: %@"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "abrir en bitchat: %@"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "در bitchat باز کنید: %@"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "buksan sa bitchat: %@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "ouvrir dans bitchat : %@"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "פתיחה ב-bitchat: %@"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat में खोलें: %@"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "buka di bitchat: %@"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "apri in bitchat: %@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat で開く: %@"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat에서 열기: %@"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "buka dalam bitchat: %@"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat मा खोल्नुहोस्: %@"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "openen in bitchat: %@"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "otwórz w bitchat: %@"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "abrir no bitchat: %@"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "abrir no bitchat: %@"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "открыть в bitchat: %@"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "öppna i bitchat: %@"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat இல் திற: %@"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "เปิดใน bitchat: %@"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat'te aç: %@"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "відкрити в bitchat: %@"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat میں کھولیں: %@"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "mở trong bitchat: %@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "在 bitchat 中打开:%@"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "在 bitchat 中開啟:%@"
}
}
}
},
"message.link.precision_warning.confirm" : {
"comment" : "Confirms copying a message link that discloses a fine-precision geohash",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "انسخ على أي حال"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "তবুও কপি করো"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "trotzdem kopieren"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "copy anyway"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "copiar de todos modos"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "به‌هرحال کپی کن"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "kopyahin pa rin"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "copier quand même"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "להעתיק בכל זאת"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "फिर भी कॉपी करें"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "tetap salin"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "copia comunque"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "それでもコピー"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "그래도 복사"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "salin juga"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "जे भए पनि कपी गर्नुहोस्"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "toch kopiëren"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "kopiuj mimo to"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "copiar mesmo assim"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "copiar mesmo assim"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "всё равно скопировать"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "kopiera ändå"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "இருப்பினும் நகலெடு"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "คัดลอกต่อไป"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "yine de kopyala"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "усе одно скопіювати"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "پھر بھی کاپی کریں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "vẫn sao chép"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "仍然复制"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "仍然複製"
}
}
}
},
"message.link.precision_warning.message" : {
"comment" : "Body of the confirmation before copying a fine-precision geohash message link",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "يحمل هذا الرابط geohash القناة، وهو يغطي منطقة صغيرة. كل من تلصقه له يعرف اهتمامك بذلك المكان، لا مجرد أنك تستخدم bitchat."
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "এই লিঙ্কে চ্যানেলের geohash আছে, যা ছোট একটি এলাকা ঢাকে। যাকে পেস্ট করবে সে ওই জায়গার প্রতি আগ্রহ জানবে, শুধু bitchat ব্যবহারের কথা নয়।"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "dieser link enthält den geohash des kanals, der ein kleines gebiet abdeckt. wem du ihn einfügst, erfährt interesse an diesem ort — nicht nur, dass jemand bitchat nutzt."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "this link carries the channel geohash, which covers a small area. anyone you paste it to learns interest in that place, not only that someone uses bitchat."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "este enlace lleva el geohash del canal, que cubre un área pequeña. a quien se lo pegues sabrá tu interés por ese lugar, no solo que alguien usa bitchat."
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "این پیوند geohash کانال را دارد که ناحیه‌ای کوچک را می‌پوشاند. هرکس آن را ببیند از علاقه به آن مکان آگاه می‌شود، نه فقط استفاده از bitchat."
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "dala ng link na ito ang geohash ng channel, na maliit na lugar. malalaman ng pinagpi-paste mo ang interes sa lugar na iyon, hindi lang na may gumagamit ng bitchat."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "ce lien contient le geohash du canal, qui couvre une petite zone. la personne à qui tu le colles apprend un intérêt pour ce lieu, pas seulement que quelqu'un utilise bitchat."
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "הקישור נושא את ה-geohash של הערוץ, שמכסה שטח קטן. מי שתדביק לו ילמד על עניין באותו מקום, לא רק שמישהו משתמש ב-bitchat."
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "इस लिंक में चैनल का geohash है, जो छोटे क्षेत्र को कवर करता है। जिसे भी पेस्ट करोगे उसे उस जगह में रुचि का पता चलेगा, सिर्फ bitchat उपयोग का नहीं।"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "tautan ini membawa geohash kanal yang mencakup area kecil. siapa pun yang kamu kirimi tahu ketertarikan pada tempat itu, bukan sekadar bahwa seseorang memakai bitchat."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "questo link contiene il geohash del canale, che copre un'area piccola. chi lo riceve apprende un interesse per quel luogo, non solo che qualcuno usa bitchat."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "このリンクにはチャンネルの geohash が含まれ、狭い範囲を示します。貼り付けた相手にはその場所への関心が伝わります。bitchat の利用だけではありません。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "이 링크에는 좁은 범위를 가리키는 채널 geohash가 들어 있습니다. 붙여넣는 상대는 그 장소에 대한 관심까지 알게 됩니다."
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "pautan ini membawa geohash saluran yang meliputi kawasan kecil. sesiapa yang anda tampal akan tahu minat terhadap tempat itu, bukan sekadar penggunaan bitchat."
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "यो लिङ्कमा च्यानलको geohash छ, जसले सानो क्षेत्र समेट्छ। तपाईंले पेस्ट गर्ने जोसुकैले त्यो ठाउँप्रतिको चासो थाहा पाउँछ।"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "deze link bevat de geohash van het kanaal, die een klein gebied beslaat. wie hem krijgt, leert interesse in die plek — niet alleen dat iemand bitchat gebruikt."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "ten link zawiera geohash kanału obejmujący mały obszar. każdy, komu go wkleisz, pozna zainteresowanie tym miejscem, nie tylko to, że ktoś używa bitchat."
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "esta ligação contém o geohash do canal, que cobre uma área pequena. quem a receber fica a saber do interesse nesse local, não apenas que alguém usa o bitchat."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "este link contém o geohash do canal, que cobre uma área pequena. quem receber fica sabendo do interesse naquele lugar, não apenas que alguém usa o bitchat."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "ссылка содержит geohash канала, охватывающий небольшую область. тот, кому вы её вставите, узнает об интересе к этому месту, а не только о том, что кто-то пользуется bitchat."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "länken innehåller kanalens geohash, som täcker ett litet område. den du klistrar in den till får veta om intresse för platsen, inte bara att någon använder bitchat."
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "இந்த இணைப்பில் சேனலின் geohash உள்ளது, இது சிறிய பகுதியைக் குறிக்கிறது. ஒட்டுபவருக்கு அந்த இடத்தின் மீதான ஆர்வம் தெரியும்."
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ลิงก์นี้มี geohash ของช่องซึ่งครอบคลุมพื้นที่เล็ก ๆ ใครที่คุณวางให้จะรู้ถึงความสนใจในสถานที่นั้น ไม่ใช่แค่ว่ามีคนใช้ bitchat"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "bu bağlantı, küçük bir alanı kapsayan kanal geohash'ini taşır. yapıştırdığın kişi o yere duyulan ilgiyi öğrenir, yalnızca bitchat kullanıldığını değil."
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "це посилання містить geohash каналу, що охоплює невелику ділянку. той, кому ви його вставите, дізнається про інтерес до того місця, а не лише що хтось користується bitchat."
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "اس لنک میں چینل کا geohash ہے جو چھوٹے علاقے کا احاطہ کرتا ہے۔ جسے بھی پیسٹ کریں گے وہ اس جگہ میں دلچسپی جان لے گا۔"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "liên kết này mang geohash của kênh, bao phủ một khu vực nhỏ. người bạn dán cho sẽ biết sự quan tâm tới nơi đó, không chỉ là ai đó dùng bitchat."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "此链接包含频道的 geohash,覆盖范围很小。你粘贴给谁,谁就知道你对那个地点的兴趣,而不只是有人在用 bitchat。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "此連結包含頻道的 geohash,涵蓋範圍很小。你貼給誰,誰就知道你對那個地點的興趣,而不只是有人在用 bitchat。"
}
}
}
},
"message.link.precision_warning.title" : {
"comment" : "Title of the confirmation before copying a message link that carries a fine-precision geohash",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "نسخ رابط موقع دقيق؟"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "নির্দিষ্ট অবস্থানের লিঙ্ক কপি করবে?"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "einen präzisen standortlink kopieren?"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "copy a precise location link?"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "¿copiar un enlace de ubicación precisa?"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "پیوند مکان دقیق کپی شود؟"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "kopyahin ang link ng tumpak na lokasyon?"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "copier un lien de localisation précise ?"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "להעתיק קישור למיקום מדויק?"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "सटीक स्थान लिंक कॉपी करें?"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "salin tautan lokasi presisi?"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "copiare un link di posizione precisa?"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "詳細な位置のリンクをコピーしますか?"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "정확한 위치 링크를 복사할까요?"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "salin pautan lokasi tepat?"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "सटीक स्थानको लिङ्क कपी गर्ने?"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "een precieze locatielink kopiëren?"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "skopiować link do dokładnej lokalizacji?"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "copiar uma ligação de localização precisa?"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "copiar um link de localização precisa?"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "скопировать ссылку с точным местом?"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "kopiera en exakt platslänk?"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "துல்லியமான இட இணைப்பை நகலெடுக்கவா?"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "คัดลอกลิงก์ตำแหน่งที่แม่นยำไหม"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "hassas konum bağlantısı kopyalansın mı?"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "скопіювати посилання з точним місцем?"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "درست مقام کا لنک کاپی کریں؟"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "sao chép liên kết vị trí chính xác?"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "复制精确位置链接?"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "複製精確位置連結?"
}
}
}
},
"notification.nearby.body_many" : {
"comment" : "Body of the nearby notification; placeholder is the peer count",
"extractionState" : "manual",

View File

@ -0,0 +1,123 @@
//
// MessageDeepLink.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
/// Builds shareable deep links for individual messages (#587).
///
/// Links use the existing `bitchat://` scheme so they work today without
/// universal-link setup. Recipients with bitchat installed can open the
/// conversation scope; the message ID helps locate the line in-thread.
enum MessageDeepLink {
enum Scope: Equatable {
case mesh
case geohash(String)
case direct(peerID: PeerID)
var host: String {
switch self {
case .mesh: return "mesh"
case .geohash: return "geohash"
case .direct: return "dm"
}
}
var path: String {
switch self {
case .mesh:
return "/"
case .geohash(let geohash):
return "/\(geohash.lowercased())"
case .direct(let peerID):
return "/\(peerID.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? peerID.id)"
}
}
}
static func url(for messageID: String, scope: Scope) -> URL? {
var components = URLComponents()
components.scheme = "bitchat"
components.host = scope.host
components.path = scope.path
components.queryItems = [
URLQueryItem(name: "mid", value: messageID)
]
return components.url
}
/// The message ID carried by a `bitchat://` link, if any.
static func messageID(from url: URL) -> String? {
URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.first(where: { $0.name == "mid" })?
.value?
.trimmedOrNilIfEmpty
}
/// The row identity `MessageListView` gives a message, so a deep link can
/// scroll to it. Must match how `MessageDisplayItem.id` is composed.
static func rowID(contextKey: String, messageID: String) -> String {
"\(contextKey)|\(messageID)"
}
/// Validates the peer ID in a `bitchat://dm/<id>` link.
///
/// The path arrives from any app or webpage, so its shape is checked before
/// it reaches conversation state — an arbitrary string would open a
/// conversation against an identity that never existed. This mirrors the
/// charset/length gate the geohash handler already applies.
///
/// Only the conversation shapes this file emits links for are accepted:
/// a bare 16-hex mesh peer, a `nostr_`-prefixed geo DM, or a `group_`
/// conversation. Routing-only prefixes (`noise:`, `name:`, `mesh:`,
/// `bridge:`) and geohash *chat* IDs are rejected.
static func directConversationTarget(fromPath path: String) -> PeerID? {
let trimmed = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let decoded = trimmed.removingPercentEncoding ?? trimmed
guard let candidate = decoded.trimmedOrNilIfEmpty, candidate.count <= 64 else { return nil }
let peerID = PeerID(str: candidate)
let hex = Set("0123456789abcdef")
guard !peerID.bare.isEmpty, peerID.bare.allSatisfy({ hex.contains($0) }) else { return nil }
switch peerID.prefix {
case .empty, .geoDM:
return peerID.bare.count == 16 ? peerID : nil
case .group:
return peerID.bare.count == 32 ? peerID : nil
case .mesh, .name, .noise, .geoChat, .bridge:
return nil
}
}
/// How large the render window must be for `index` to be inside it.
///
/// A link can point at a message older than the window currently renders;
/// without growing it the row is not in the hierarchy and `scrollTo` is a
/// no-op. Never shrinks an already-larger window.
static func windowCount(toReveal index: Int, inTotal total: Int, current: Int) -> Int {
guard (0..<total).contains(index) else { return current }
return max(current, total - index)
}
static func plainText(for messageID: String, scope: Scope) -> String {
guard let link = url(for: messageID, scope: scope)?.absoluteString else {
return messageID
}
return String(
format: String(
localized: "message.link.plaintext",
defaultValue: "open in bitchat: %@",
comment: "Plain-text payload when copying a message deep link; %@ is the bitchat:// URL"
),
locale: .current,
link
)
}
}

View File

@ -14,6 +14,12 @@ private struct MessageDisplayItem: Identifiable {
let message: BitchatMessage
}
/// A message-link copy held back pending the fine-precision confirmation.
struct PendingMessageLinkCopy {
let messageID: String
let scope: MessageDeepLink.Scope
}
struct MessageListView: View {
@EnvironmentObject private var publicChatModel: PublicChatModel
@EnvironmentObject private var privateInboxModel: PrivateInboxModel
@ -34,6 +40,11 @@ struct MessageListView: View {
@Binding var imagePreviewURL: URL?
@Binding var windowCountPublic: Int
@Binding var windowCountPrivate: [PeerID: Int]
/// Message a `bitchat://` link asked us to reveal. Consumed once the
/// destination conversation has rendered, so the row exists to scroll to.
@State private var pendingDeepLinkMessageID: String?
@State private var pendingLinkCopy: PendingMessageLinkCopy?
@State private var showLinkPrecisionWarning = false
@Binding var showSidebar: Bool
var isTextFieldFocused: FocusState<Bool>.Binding
@ -152,6 +163,18 @@ struct MessageListView: View {
pb.setString(message.content, forType: .string)
#endif
}
Button(String(localized: "message.link.copy", defaultValue: "copy message link", comment: "Context-menu action that copies a bitchat:// deep link to this message")) {
let scope: MessageDeepLink.Scope = {
if let peer = privatePeer {
return .direct(peerID: peer)
}
if case .location(let channel) = locationChannelsModel.selectedChannel {
return .geohash(channel.geohash)
}
return .mesh
}()
requestCopyMessageLink(for: message.id, scope: scope)
}
if isResendableFailedMessage(message) {
Button("content.actions.resend") {
conversationUIModel.resendFailedPrivateMessage(message)
@ -217,6 +240,26 @@ struct MessageListView: View {
.onChange(of: locationChannelsModel.selectedChannel) { newChannel in
onSelectedChannelChange(newChannel, proxy: proxy)
}
// Runs after the destination conversation has rendered, so the
// target row exists in the hierarchy for scrollTo to find.
.onChange(of: pendingDeepLinkMessageID) { _ in
revealPendingDeepLinkMessage(proxy: proxy)
}
.confirmationDialog(
String(localized: "message.link.precision_warning.title", defaultValue: "copy a precise location link?", comment: "Title of the confirmation before copying a message link that carries a fine-precision geohash"),
isPresented: $showLinkPrecisionWarning,
titleVisibility: .visible
) {
Button(String(localized: "message.link.precision_warning.confirm", defaultValue: "copy anyway", comment: "Confirms copying a message link that discloses a fine-precision geohash")) {
if let pending = pendingLinkCopy {
copyMessageLink(for: pending.messageID, scope: pending.scope)
}
pendingLinkCopy = nil
}
Button("common.cancel", role: .cancel) { pendingLinkCopy = nil }
} message: {
Text(String(localized: "message.link.precision_warning.message", defaultValue: "this link carries the channel geohash, which covers a small area. anyone you paste it to learns interest in that place, not only that someone uses bitchat.", comment: "Body of the confirmation before copying a fine-precision geohash message link"))
}
.confirmationDialog(
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
isPresented: $showMessageActions,
@ -529,6 +572,72 @@ private extension MessageListView {
.frame(maxWidth: .infinity, alignment: .leading)
}
/// Copies a message link, warning first when the link would disclose a
/// fine-precision geohash.
///
/// A geohash link carries the channel's location at whatever precision the
/// channel uses, and a pasteboard is a wide surface. Same gate #1513 put in
/// front of invite sharing, reusing its threshold so the two agree.
func requestCopyMessageLink(for messageID: String, scope: MessageDeepLink.Scope) {
if case .geohash(let geohash) = scope, ChannelShare.shouldWarn(forGeohash: geohash) {
pendingLinkCopy = PendingMessageLinkCopy(messageID: messageID, scope: scope)
showLinkPrecisionWarning = true
return
}
copyMessageLink(for: messageID, scope: scope)
}
func copyMessageLink(for messageID: String, scope: MessageDeepLink.Scope) {
let payload = MessageDeepLink.plainText(for: messageID, scope: scope)
#if os(iOS)
UIPasteboard.general.string = payload
#else
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(payload, forType: .string)
#endif
}
/// Scrolls to the message a `bitchat://…?mid=` link pointed at.
///
/// Grows the render window first when the target sits behind it: an
/// unrendered row is not in the hierarchy and `scrollTo` would silently do
/// nothing. Unknown IDs are dropped — the message may simply not have
/// reached this device.
func revealPendingDeepLinkMessage(proxy: ScrollViewProxy) {
guard let messageID = pendingDeepLinkMessageID else { return }
pendingDeepLinkMessageID = nil
let all = conversationMessages(for: privatePeer)
guard let index = all.firstIndex(where: { $0.id == messageID }) else { return }
let contextKey: String = {
if let peer = privatePeer {
return "dm:\(peer)"
}
return locationChannelsModel.selectedChannel.contextKey
}()
if let peer = privatePeer {
let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
windowCountPrivate[peer] = MessageDeepLink.windowCount(
toReveal: index, inTotal: all.count, current: current
)
} else {
windowCountPublic = MessageDeepLink.windowCount(
toReveal: index, inTotal: all.count, current: windowCountPublic
)
}
let rowID = MessageDeepLink.rowID(contextKey: contextKey, messageID: messageID)
// One hop so the widened window has rendered before we scroll into it.
DispatchQueue.main.async {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
proxy.scrollTo(rowID, anchor: .center)
}
}
}
func expandWindow(ifNeededFor message: BitchatMessage,
allMessages: [BitchatMessage],
privatePeer: PeerID?,
@ -587,6 +696,26 @@ private extension MessageListView {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
locationChannelsModel.openLocationChannel(for: gh)
pendingDeepLinkMessageID = MessageDeepLink.messageID(from: url)
case "mesh":
privateConversationModel.endConversation()
locationChannelsModel.select(.mesh)
pendingDeepLinkMessageID = MessageDeepLink.messageID(from: url)
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = false
}
case "dm":
// The path comes from any app or webpage; an unvalidated string
// would open a conversation against an identity that never
// existed. Same class of gate the geohash case applies.
guard let peerID = MessageDeepLink.directConversationTarget(fromPath: url.path) else { return }
privateConversationModel.openConversation(for: peerID)
pendingDeepLinkMessageID = MessageDeepLink.messageID(from: url)
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
}
default:
return

View File

@ -0,0 +1,132 @@
//
// MessageDeepLinkTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
struct MessageDeepLinkTests {
@Test func meshLinkIncludesMessageID() throws {
let url = try #require(MessageDeepLink.url(for: "msg-1", scope: .mesh))
#expect(url.host == "mesh")
#expect(url.query?.contains("mid=msg-1") == true)
}
@Test func geohashLinkLowercasesPath() throws {
let url = try #require(MessageDeepLink.url(for: "abc", scope: .geohash("U4PRU")))
#expect(url.path == "/u4pru")
}
@Test func directLinkUsesPeerPath() throws {
let peer = PeerID(str: "deadbeef")
let url = try #require(MessageDeepLink.url(for: "x", scope: .direct(peerID: peer)))
#expect(url.host == "dm")
#expect(url.path.contains("deadbeef"))
}
@Test func plainTextWrapsURL() {
let text = MessageDeepLink.plainText(for: "mid", scope: .mesh)
#expect(text.contains("bitchat://"))
#expect(text.contains("mid=mid"))
}
@Test func meshURLUsesMeshHostAndMessageQuery() throws {
let url = try #require(MessageDeepLink.url(for: "msg-42", scope: .mesh))
#expect(url.host == "mesh")
let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))
#expect(components.queryItems?.first(where: { $0.name == "mid" })?.value == "msg-42")
}
// MARK: - Opening links
/// The `dm` path arrives from any app or webpage. Before this gate it went
/// straight into `openConversation(for:)`, so an arbitrary string opened a
/// conversation against an identity that never existed.
@Test("A bare 16-hex mesh peer is accepted")
func acceptsMeshPeerPath() throws {
let peerID = try #require(MessageDeepLink.directConversationTarget(fromPath: "/a1b2c3d4e5f60718"))
#expect(peerID.id == "a1b2c3d4e5f60718")
}
@Test("Geo DM and group conversation shapes are accepted")
func acceptsGeoDMAndGroupPaths() {
#expect(MessageDeepLink.directConversationTarget(fromPath: "/nostr_0123456789abcdef") != nil)
#expect(MessageDeepLink.directConversationTarget(fromPath: "/group_" + String(repeating: "a", count: 32)) != nil)
}
@Test("Malformed and hostile paths are rejected")
func rejectsMalformedPaths() {
let rejected = [
"/", // empty
"/../../etc/passwd", // traversal-ish junk
"/zzzzzzzzzzzzzzzz", // 16 chars, not hex
"/a1b2c3", // too short
"/a1b2c3d4e5f607189", // 17 hex, wrong length
"/nostr_0123", // geo DM, wrong length
"/group_abc", // group, wrong length
"/noise:" + String(repeating: "a", count: 64), // routing-only prefix
"/name:alice", // routing-only prefix
"/bridge:0123456789abcdef", // not a routable conversation
"/nostr:01234567", // geohash chat, not a DM
"/" + String(repeating: "a", count: 128) // over-length
]
for path in rejected {
#expect(
MessageDeepLink.directConversationTarget(fromPath: path) == nil,
"should have rejected \(path)"
)
}
}
@Test("Percent-encoded paths decode before validation")
func decodesPercentEncoding() {
#expect(MessageDeepLink.directConversationTarget(fromPath: "/nostr%5F0123456789abcdef") != nil)
}
@Test("The message ID is read back off a link we built")
func readsMessageIDBack() throws {
let url = try #require(MessageDeepLink.url(for: "msg-42", scope: .mesh))
#expect(MessageDeepLink.messageID(from: url) == "msg-42")
}
@Test("A link with no usable mid yields nil")
func missingMessageIDIsNil() throws {
#expect(MessageDeepLink.messageID(from: try #require(URL(string: "bitchat://mesh/"))) == nil)
#expect(MessageDeepLink.messageID(from: try #require(URL(string: "bitchat://mesh/?mid="))) == nil)
#expect(MessageDeepLink.messageID(from: try #require(URL(string: "bitchat://mesh/?mid=%20"))) == nil)
}
/// The row must be addressed exactly as `MessageDisplayItem.id` composes it,
/// or `scrollTo` silently finds nothing.
@Test("Row identity matches the display item composition")
func rowIDMatchesDisplayItemComposition() {
#expect(MessageDeepLink.rowID(contextKey: "dm:peer1", messageID: "m1") == "dm:peer1|m1")
}
/// A link can point behind the render window; without growing it the row is
/// not in the hierarchy and the scroll is a no-op.
@Test("The window grows just enough to reveal an older message")
func windowGrowsToRevealTarget() {
// 100 messages, target at index 10 -> needs the last 90 rendered.
#expect(MessageDeepLink.windowCount(toReveal: 10, inTotal: 100, current: 50) == 90)
}
@Test("An already-visible target never shrinks the window")
func windowNeverShrinks() {
#expect(MessageDeepLink.windowCount(toReveal: 95, inTotal: 100, current: 50) == 50)
#expect(MessageDeepLink.windowCount(toReveal: 99, inTotal: 100, current: 50) == 50)
}
@Test("An out-of-range index leaves the window alone")
func windowIgnoresBadIndex() {
#expect(MessageDeepLink.windowCount(toReveal: -1, inTotal: 100, current: 50) == 50)
#expect(MessageDeepLink.windowCount(toReveal: 100, inTotal: 100, current: 50) == 50)
#expect(MessageDeepLink.windowCount(toReveal: 0, inTotal: 0, current: 50) == 50)
}
}

View File

@ -0,0 +1,49 @@
# Message deep links
Related: [#587](https://github.com/permissionlesstech/bitchat/issues/587)
## Format
`MessageDeepLink` builds `bitchat://` URLs with a message ID query parameter:
| Scope | Example |
|-------|---------|
| mesh | `bitchat://mesh/?mid=<id>` |
| geohash | `bitchat://geohash/u4pru?mid=<id>` |
| direct | `bitchat://dm/<peer-id>?mid=<id>` |
The context menu **copy message link** action copies a plain-text line containing the URL. Universal links are out of scope; custom-scheme links work on iOS today and give people a stable reference for a specific line.
## Opening a link
`MessageListView.handleOpenURL` switches to the link's conversation, then reveals the message named by `mid`:
1. The message ID is held in `pendingDeepLinkMessageID` rather than acted on immediately, so the destination conversation has rendered before we look for the row.
2. If the target sits behind the render window, the window is grown to include it (`MessageDeepLink.windowCount(toReveal:inTotal:current:)`). An unrendered row is not in the view hierarchy and `scrollTo` would silently do nothing — this is what makes the parameter more than decoration.
3. The row is addressed as `"<contextKey>|<messageID>"`, matching how `MessageDisplayItem.id` is composed, and scrolled to centre.
An unknown `mid` is dropped: the referenced message may simply never have reached this device.
## Validating the `dm` path
A `bitchat://dm/<id>` link can be opened by any app or webpage, so the path is shape-checked before it reaches conversation state — otherwise an arbitrary string opens a conversation against an identity that never existed.
`MessageDeepLink.directConversationTarget(fromPath:)` accepts only the conversation shapes this file emits links for: a bare 16-hex mesh peer, a `nostr_` geo DM, or a `group_` conversation. Routing-only prefixes (`noise:`, `name:`, `mesh:`, `bridge:`) and geohash *chat* IDs are rejected, as is anything non-hex or over-length. This mirrors the charset/length gate the geohash handler already applies.
## Location precision
Copying a **geohash** message link puts the channel's geohash on the pasteboard, disclosing interest in a place rather than merely that someone uses bitchat. Copying is therefore gated behind the same confirmation that [#1513](https://github.com/permissionlesstech/bitchat/pull/1513) put in front of invite sharing, reusing `ChannelShare.shouldWarn(forGeohash:)` so the two thresholds cannot drift apart.
Mesh and DM links carry no location and are copied without a prompt.
## Known limitation: DM links embed a routing peer ID
`bitchat://dm/<peer-id>` addresses the conversation by its current routing peer ID. Those rotate, so a copied DM link goes stale once the peer rotates, and the link discloses the live routing ID at the time of copying.
Fingerprint-based addressing would be durable and would not leak the routing ID, but the fingerprint is not always known at copy time (it needs a completed handshake) and resolving one back to a conversation needs a lookup that does not exist yet. Left as follow-up rather than shipped half-done.
## Future work
- Address DM links by fingerprint instead of routing peer ID.
- Highlight the revealed message briefly, not just scroll to it.
- Adopt associated domains when a web fallback exists.